psy пре 1 дан
родитељ
комит
a57c8ccf6e
100 измењених фајлова са 10702 додато и 2188 уклоњено
  1. 1 0
      .gitignore
  2. 1 0
      README.md
  3. 42 0
      docs/CHANGELOG.md
  4. 77 11
      src/AI/routes_index.js
  5. 1618 112
      src/backend/backend.js
  6. 2 2
      src/backend/blobHandler.js
  7. 228 0
      src/backend/smartContractPdf.js
  8. 40 37
      src/client/assets/styles/mobile.css
  9. 466 8
      src/client/assets/styles/style.css
  10. 40 37
      src/client/assets/themes/OasisMobile.css
  11. 203 9
      src/client/assets/translations/oasis_ar.js
  12. 203 9
      src/client/assets/translations/oasis_de.js
  13. 221 8
      src/client/assets/translations/oasis_en.js
  14. 204 10
      src/client/assets/translations/oasis_es.js
  15. 202 8
      src/client/assets/translations/oasis_eu.js
  16. 204 10
      src/client/assets/translations/oasis_fr.js
  17. 203 9
      src/client/assets/translations/oasis_hi.js
  18. 202 8
      src/client/assets/translations/oasis_it.js
  19. 202 8
      src/client/assets/translations/oasis_pt.js
  20. 203 9
      src/client/assets/translations/oasis_ru.js
  21. 203 9
      src/client/assets/translations/oasis_zh.js
  22. 89 14
      src/client/middleware.js
  23. 0 4
      src/configs/blockchain-cycle.json
  24. 2 1
      src/configs/config-manager.js
  25. 3 2
      src/configs/oasis-config.json
  26. 16 1
      src/configs/shared-state.js
  27. 67 5
      src/models/activity_model.js
  28. 62 9
      src/models/agenda_model.js
  29. 2 0
      src/models/audios_model.js
  30. 62 4
      src/models/banking_model.js
  31. 58 6
      src/models/blockchain_model.js
  32. 30 12
      src/models/calendars_model.js
  33. 21 12
      src/models/chats_model.js
  34. 538 0
      src/models/crypto.js
  35. 8 1
      src/models/cv_model.js
  36. 12 6
      src/models/events_model.js
  37. 190 19
      src/models/inhabitants_model.js
  38. 52 8
      src/models/jobs_model.js
  39. 1 13
      src/models/logs_model.js
  40. 241 13
      src/models/main_models.js
  41. 51 29
      src/models/maps_model.js
  42. 16 2
      src/models/market_model.js
  43. 117 0
      src/models/melody_model.js
  44. 15 6
      src/models/pads_model.js
  45. 4 2
      src/models/projects_model.js
  46. 83 1
      src/models/shops_model.js
  47. 56 8
      src/models/stats_model.js
  48. 55 1
      src/models/tasks_model.js
  49. 20 14
      src/models/torrents_model.js
  50. 13 2
      src/models/transfers_model.js
  51. 1 387
      src/models/tribe_crypto.js
  52. 114 145
      src/models/tribes_content_model.js
  53. 676 628
      src/models/tribes_model.js
  54. 33 2
      src/server/SSB_server.js
  55. 68 0
      src/server/lanRouter.js
  56. 1 1
      src/server/package-lock.json
  57. 1 1
      src/server/package.json
  58. 29 2
      src/server/ssb_metadata.js
  59. 32 0
      src/views/activity_view.js
  60. 7 1
      src/views/agenda_view.js
  61. 19 3
      src/views/audio_view.js
  62. 92 3
      src/views/banking_views.js
  63. 81 2
      src/views/blockchain_view.js
  64. 2 1
      src/views/bookmark_view.js
  65. 251 0
      src/views/clearnet_view.js
  66. 27 1
      src/views/cv_view.js
  67. 17 3
      src/views/document_view.js
  68. 54 4
      src/views/event_view.js
  69. 3 2
      src/views/forum_view.js
  70. 2 1
      src/views/graphos_view.js
  71. 17 3
      src/views/image_view.js
  72. 34 31
      src/views/indexing_view.js
  73. 148 30
      src/views/inhabitants_view.js
  74. 52 3
      src/views/invites_view.js
  75. 201 8
      src/views/jobs_view.js
  76. 11 8
      src/views/logs_view.js
  77. 610 162
      src/views/main_views.js
  78. 46 79
      src/views/market_view.js
  79. 68 0
      src/views/melody_view.js
  80. 3 2
      src/views/modules_view.js
  81. 1 1
      src/views/pads_view.js
  82. 82 29
      src/views/peers_view.js
  83. 5 7
      src/views/pm_view.js
  84. 73 13
      src/views/projects_view.js
  85. 33 10
      src/views/search_view.js
  86. 2 1
      src/views/settings_view.js
  87. 124 8
      src/views/shops_view.js
  88. 85 89
      src/views/stats_view.js
  89. 17 3
      src/views/torrents_view.js
  90. 114 10
      src/views/transfer_view.js
  91. 5 2
      src/views/tribes_view.js
  92. 17 3
      src/views/video_view.js
  93. 151 0
      src/views/welcome_view.js
  94. 243 0
      test/README.md
  95. 43 0
      test/helpers/assert.js
  96. 167 0
      test/helpers/mock-ssb.js
  97. 156 0
      test/helpers/setup.js
  98. 31 0
      test/mods/activity/activity.test.js
  99. 4 0
      test/mods/activity/run.sh
  100. 0 0
      test/mods/agenda/agenda.test.js

+ 1 - 0
.gitignore

@@ -7,3 +7,4 @@ src/AI/embeddings/
 src/AI/.cache/
 .update_required
 cache/
+src/configs/banking-eco-history.json

+ 1 - 0
README.md

@@ -90,6 +90,7 @@ Oasis is TRULY MODULAR. Here's a list of what comes deployed with the "core".
  + Logs: Module to record (via AI assistant) your experiences.
  + Maps: Module to manage and share offline maps.
  + Market: Module to exchange goods or services.
+ + Melody: Module to generate and share the "sound" of your blockchain.
  + Multiverse: Module to receive content from other federated peers.
  + Opinions: Module to discover and vote on opinions.	
  + Pads: Module to manage collaborative encrypted text editors.

+ 42 - 0
docs/CHANGELOG.md

@@ -13,6 +13,48 @@ All notable changes to this project will be documented in this file.
 ### Security
 -->
 
+## v0.7.7 - 2026-05-16
+
+### Added
+
+- Proxy to Clearnet (Core plugin).
+- Lifetime model: content decay (Core plugin).
+- Suggested candidates section on job detail (Jobs plugin).
+- LAN peer discovery and feed replication (Core plugin).
+- AI navigator response page that always renders suggestions (AI plugin).
+- Carbon footprint per block shown in /blockexplorer (Blockchain plugin).
+- QR codes beside inhabitant name in /inhabitants, CV, jobs candidatures and activity cards (Core plugin).
+- OasisID in footer linked to /profile (Core plugin).
+- New Peers section in /invites with Direct Connect form (host, port, public key) (Invites plugin).
+- Smart Contract PDF export from transfer detail (Transfers plugin).
+- Melody module: generate and download personal melodies (Melody plugin).
+- Calendar dates surfaced as agenda items and overdue task reminders sent as PMs (Agenda plugin).
+- Today, Upcoming and Overdue filters on /agenda (Agenda plugin).
+- Two-column tribe-style layout on /profile and /author with module-driven Public Content (Core plugin).
+- Avatar Content toggles in /profile/edit to pick which modules appear on the avatar (Core plugin).
+
+### Changed
+
+- Unified card aesthetic across all modules: border, rounded corners, background, padding (Core plugin).
+- Stats dashboard restructured with tables sorted by count and consistent color scheme (Core plugin).
+- Market list simplified to shop-style cards with photo, kind, title and price (Market plugin).
+- Banking ECOin chart sorted and hidden when data is outdated (Banking plugin).
+- Logs Export button shown only when there are logs to export (Logs plugin).
+- Project detail and list views trimmed of empty rows and redundant summaries (Projects plugin).
+- Publishing posts immediately invalidates the activity feed cache (Core plugin).
+- First-contact flag file (Core plugin).
+- LAN router now stages discovered peers and lets the replication scheduler decide (Core plugin).
+- `oasis.sh --help` clarifies GUI options and forwarded flags (Core plugin).
+
+### Fixed
+
+- Agenda subscribe button used GET on a POST-only route (Jobs plugin).
+- LAN-discovered peers now appear in /peers Discovered and Connections (Core plugin).
+- /peers staged-peers list drained from the pull-stream source instead of being treated as an array (Core plugin).
+- Avatar Content pills now toggle off visually when unchecked (Core plugin).
+- Smart Contract PDF route no longer shadowed by the `/transfers/:id` wildcard (Transfers plugin).
+- Slow profile load and `NS_ERROR_NOT_AVAILABLE` caused by long blob waits (Core plugin).
+
 ## v0.7.6 - 2026-05-09
 
 ### Added

+ 77 - 11
src/AI/routes_index.js

@@ -6,6 +6,14 @@ const ROUTES = [
   { 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: '/inhabitants?filter=SUGGESTED', mod: 'inhabitantsMod', description: 'suggested inhabitants, recommendations, who to follow, similar people, people you might know, friend suggestions' },
+  { path: '/inhabitants?filter=MATCHSKILLS', mod: 'inhabitantsMod', description: 'match skills, people with same skills, common skills, professional matches, find collaborators, who shares my expertise, skill overlap' },
+  { path: '/inhabitants?filter=CVs', mod: 'inhabitantsMod', description: 'curriculums, CVs, resumes, professional profiles, people with experience, find expertise' },
+  { path: '/inhabitants?filter=TOP%20KARMA', mod: 'inhabitantsMod', description: 'top karma, most active inhabitants, highest reputation, leaderboard' },
+  { path: '/inhabitants?filter=TOP%20ECO', mod: 'inhabitantsMod', description: 'top eco, most ecological, least carbon footprint, sustainable users, efficient inhabitants' },
+  { path: '/inhabitants?filter=TOP%20ACTIVITY', mod: 'inhabitantsMod', description: 'top activity, most recently active inhabitants, fresh users, recently online' },
+  { path: '/inhabitants?filter=contacts', mod: 'inhabitantsMod', description: 'my contacts, who I follow, my network, friends list, mutuals' },
+  { path: '/inhabitants?filter=GALLERY', mod: 'inhabitantsMod', description: 'gallery of inhabitants, all avatars, visual list, photos' },
   { 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' },
@@ -18,8 +26,8 @@ const ROUTES = [
   { 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: '/banking',       mod: 'bankingMod', description: 'banking, wallet, ECO balance, send money, transfers, payments, UBI claim, karma score, eco tax penalty, ECOin value' },
+  { path: '/transfers',     mod: 'transferMod', description: 'transfers, payments, money movements, ECO transactions, history, smart contracts, contract PDF, export contract' },
   { 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' },
@@ -46,13 +54,31 @@ const ROUTES = [
   { 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: '/peers',         mod: 'peersMod',   description: 'peers, connections, network, nodes, who am I connected to, LAN, refresh discovery, export peer list, import peer list, remove idle' },
+  { path: '/invites',       mod: 'invitesMod', description: 'invites, pub invitations, join code, follow PUB, federations, federated networks, import pubs, export pubs, unreachable pubs' },
   { 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' }
+  { path: '/logs',          mod: 'logsMod',    description: 'logs, life log, personal records, journal, experiences' },
+  { path: '/melody',        mod: 'melodyMod',  description: 'melody, sound of my blockchain, music, generate sound, audio of blocks, sonification' },
+  { path: '/profile',       mod: null,         description: 'my profile, my avatar, my page, my identity, my data' },
+  { path: '/profile/edit',  mod: null,         description: 'edit profile, edit avatar, change name, change description, visibility prefs, sensors, eco tax toggle' },
+  { path: '/blockexplorer', mod: 'blockchainMod', description: 'blockexplorer, blockchain explorer, blocks, ledger, carbon footprint per block, chain history' },
+  { path: '/stats?filter=ALL',  mod: 'statsMod', description: 'global stats, network kpis, total carbon footprint, total inhabitants, network size' },
+  { path: '/stats?filter=MINE', mod: 'statsMod', description: 'my stats, my carbon footprint, my activity numbers, personal kpis' },
+  { path: '/tribes/new',    mod: 'tribeMod',   description: 'create tribe, new tribe, new group, start community, create private room' },
+  { path: '/chats/new',     mod: 'chatMod',    description: 'create chat, new chat, start conversation, new encrypted room' },
+  { path: '/pads/new',      mod: 'padMod',     description: 'create pad, new pad, new collaborative document, start shared note' },
+  { path: '/calendars/new', mod: 'calendarMod', description: 'create calendar, new calendar, start schedule' },
+  { path: '/maps/new',      mod: 'mapMod',     description: 'create map, new map, new offline map' },
+  { path: '/events/new',    mod: 'eventMod',   description: 'create event, new event, schedule meetup' },
+  { path: '/projects/new',  mod: 'projectMod', description: 'create project, new project, start crowdfunding' },
+  { path: '/jobs/new',      mod: 'jobMod',     description: 'create job, post job offer, new vacancy, hire' },
+  { path: '/market/new',    mod: 'marketMod',  description: 'create market item, sell something, new auction, list for sale' },
+  { path: '/shops/new',     mod: 'shopMod',    description: 'create shop, open store, new vendor, list products' },
+  { path: '/tasks/new',     mod: 'taskMod',    description: 'create task, new todo, new assignment' },
+  { path: '/reports/new',   mod: 'reportsMod', description: 'create report, file bug, report issue, report abuse' }
 ]
 
 const CACHE_FILE = path.join(__dirname, 'embeddings', 'routes_cache.json')
@@ -94,21 +120,61 @@ const ensureIndex = async ({ embed }) => {
   return cache
 }
 
+const dot = (a, b) => {
+  let s = 0
+  const n = Math.min(a.length, b.length)
+  for (let i = 0; i < n; i++) s += a[i] * b[i]
+  return s
+}
+
+const descriptionByPath = (p) => {
+  const r = ROUTES.find(x => x.path === p)
+  return r ? r.description : ''
+}
+
 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
-    })()
+    const score = dot(queryVector, entry.vector)
     if (!best || score > best.score) best = { path: entry.path, score }
   }
   if (!best || best.score < threshold) return null
   return best
 }
 
-module.exports = { ROUTES, ensureIndex, resolveBest }
+const resolveTopK = async (queryVector, { isModuleEnabled, threshold = 0.35, embed } = {}, k = 5) => {
+  const idx = await ensureIndex({ embed })
+  if (!idx) return []
+  const all = []
+  for (const entry of idx) {
+    if (entry.mod && typeof isModuleEnabled === 'function' && !isModuleEnabled(entry.mod)) continue
+    const score = dot(queryVector, entry.vector)
+    if (score < threshold) continue
+    all.push({ path: entry.path, mod: entry.mod, score, description: descriptionByPath(entry.path) })
+  }
+  all.sort((a, b) => b.score - a.score)
+  return all.slice(0, Math.max(1, k|0))
+}
+
+const resolveKeywordTopK = ({ isModuleEnabled } = {}, query, k = 8) => {
+  const tokens = String(query || '').toLowerCase().split(/[^a-z0-9À-ſ]+/).filter(t => t && t.length >= 2)
+  if (!tokens.length) return []
+  const all = []
+  for (const entry of ROUTES) {
+    if (entry.mod && typeof isModuleEnabled === 'function' && !isModuleEnabled(entry.mod)) continue
+    const haystack = (entry.description || '').toLowerCase() + ' ' + entry.path.toLowerCase()
+    let hits = 0
+    for (const t of tokens) {
+      if (haystack.includes(t)) hits += 1
+    }
+    if (hits === 0) continue
+    all.push({ path: entry.path, mod: entry.mod, score: hits / tokens.length, description: entry.description })
+  }
+  all.sort((a, b) => b.score - a.score)
+  return all.slice(0, Math.max(1, k|0))
+}
+
+module.exports = { ROUTES, ensureIndex, resolveBest, resolveTopK, resolveKeywordTopK }

Разлика између датотеке није приказан због своје велике величине
+ 1618 - 112
src/backend/backend.js


+ 2 - 2
src/backend/blobHandler.js

@@ -126,7 +126,7 @@ const handleBlobUpload = async function (ctx, fileFieldName) {
   return `\n[${blob.name}](${blob.id})`;
 };
 
-function waitForBlob(ssbClient, blobId, timeoutMs = 60000) {
+function waitForBlob(ssbClient, blobId, timeoutMs = 8000) {
   return new Promise((resolve, reject) => {
     let done = false;
 
@@ -183,7 +183,7 @@ const serveBlob = async function (ctx) {
   const ssbClient = await cooler.open();
 
   try {
-    await waitForBlob(ssbClient, blobId, 60000);
+    await waitForBlob(ssbClient, blobId, 8000);
   } catch (err) {
     ctx.status = 504;
     ctx.body = 'Blob not available';

+ 228 - 0
src/backend/smartContractPdf.js

@@ -0,0 +1,228 @@
+const fs = require('fs');
+const path = require('path');
+
+const LOGO_PATH = path.join(__dirname, '..', 'client', 'assets', 'images', 'snh-oasis.jpg');
+
+const escapePdf = s => String(s || '').replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
+
+const wrap = (txt, max = 82) => {
+  const out = [];
+  for (const raw of String(txt || '').split('\n')) {
+    let line = raw;
+    while (line.length > max) {
+      let cut = line.lastIndexOf(' ', max);
+      if (cut <= 0) cut = max;
+      out.push(line.slice(0, cut));
+      line = line.slice(cut).replace(/^\s+/, '');
+    }
+    out.push(line);
+  }
+  return out;
+};
+
+const readJpegDims = (buf) => {
+  let i = 2;
+  while (i < buf.length) {
+    if (buf[i] !== 0xFF) return null;
+    const marker = buf[i + 1];
+    if (marker === 0xD8 || marker === 0xD9) { i += 2; continue; }
+    const len = buf.readUInt16BE(i + 2);
+    if (marker >= 0xC0 && marker <= 0xCF && marker !== 0xC4 && marker !== 0xC8 && marker !== 0xCC) {
+      const h = buf.readUInt16BE(i + 5);
+      const w = buf.readUInt16BE(i + 7);
+      const c = buf[i + 9];
+      return { w, h, c };
+    }
+    i += 2 + len;
+  }
+  return null;
+};
+
+function buildSmartContractPdf({ transfer, block, viewerId }) {
+  const pageW = 612;
+  const pageH = 792;
+  const marginX = 50;
+  const headerH = 90;
+  const footerH = 40;
+  const bodyTop = pageH - headerH - 24;
+  const bodyBottom = footerH + 10;
+  const lineH = 14;
+
+  let logoBuf = null;
+  let logoDims = null;
+  try {
+    logoBuf = fs.readFileSync(LOGO_PATH);
+    logoDims = readJpegDims(logoBuf);
+  } catch (_) {}
+
+  const t = transfer || {};
+  const b = block || {};
+  const fmt = v => (v === undefined || v === null) ? '' : String(v);
+  const fmtAmount = () => {
+    const cat = String(t.category || 'ECONOMIC').toUpperCase();
+    const unit = cat === 'TIME' ? 'h' : cat === 'TRUST' ? 'trust' : 'ECO';
+    return `${Number(t.amount || 0).toFixed(6)} ${unit}`;
+  };
+  const fmtDate = v => v ? new Date(v).toISOString().replace('T', ' ').slice(0, 19) + ' UTC' : '';
+  const confirmedBy = Array.isArray(t.confirmedBy) ? t.confirmedBy : [];
+  const required = t.from === t.to ? 1 : 2;
+  const confirmedCount = confirmedBy.length;
+  const tags = Array.isArray(t.tags) ? t.tags.join(', ') : '';
+
+  const sections = [];
+  sections.push({ kind: 'title', text: `Concept: ${t.concept || '-'}` });
+  sections.push({ kind: 'blank' });
+
+  sections.push({ kind: 'section', text: 'OASIS IDs' });
+  sections.push({ kind: 'kv', label: 'From',   value: fmt(t.from) });
+  sections.push({ kind: 'kv', label: 'To',     value: fmt(t.to) });
+  sections.push({ kind: 'blank' });
+
+  sections.push({ kind: 'section', text: 'TERMS' });
+  sections.push({ kind: 'kv', label: 'Category', value: String(t.category || 'ECONOMIC').toUpperCase() });
+  if (String(t.category || '').toUpperCase() !== 'TRUST') sections.push({ kind: 'kv', label: 'Amount', value: fmtAmount() });
+  sections.push({ kind: 'kv', label: 'Status', value: fmt(t.status) });
+  if (t.deadline) sections.push({ kind: 'kv', label: 'Deadline', value: fmtDate(t.deadline) });
+  if (tags) sections.push({ kind: 'kv', label: 'Tags', value: tags });
+  sections.push({ kind: 'blank' });
+
+  sections.push({ kind: 'section', text: 'CONFIRMATIONS' });
+  sections.push({ kind: 'kv', label: 'Required', value: String(required) });
+  sections.push({ kind: 'kv', label: 'Confirmed', value: String(confirmedCount) });
+  if (confirmedBy.length) {
+    for (const f of confirmedBy) sections.push({ kind: 'kv', label: 'Signed by', value: fmt(f) });
+  }
+  sections.push({ kind: 'blank' });
+
+  sections.push({ kind: 'section', text: 'BLOCK VERIFICATION' });
+  if (b && b.id) {
+    sections.push({ kind: 'kv', label: 'Block ID', value: fmt(b.id) });
+    if (b.ts) sections.push({ kind: 'kv', label: 'Block Timestamp', value: fmtDate(b.ts) });
+    if (b.type) sections.push({ kind: 'kv', label: 'Block Type', value: String(b.type).toUpperCase() });
+    if (b.author) sections.push({ kind: 'kv', label: 'Block Author', value: fmt(b.author) });
+    if (b.size) sections.push({ kind: 'kv', label: 'Block Size', value: `${b.size} bytes` });
+  } else {
+    sections.push({ kind: 'kv', label: 'Block ID', value: fmt(t.id) });
+  }
+  sections.push({ kind: 'blank' });
+
+  sections.push({ kind: 'section', text: 'METADATA' });
+  sections.push({ kind: 'kv', label: 'Transfer ID', value: fmt(t.id) });
+  if (t.createdAt) sections.push({ kind: 'kv', label: 'Created At', value: fmtDate(t.createdAt) });
+  if (t.updatedAt) sections.push({ kind: 'kv', label: 'Updated At', value: fmtDate(t.updatedAt) });
+
+  const lines = [];
+  for (const s of sections) {
+    if (s.kind === 'kv') {
+      const txt = `${s.label}: ${s.value}`;
+      for (const w of wrap(txt, 82)) lines.push({ kind: 'kv', text: w });
+    } else {
+      lines.push(s);
+    }
+  }
+
+  const maxBodyLines = Math.floor((bodyTop - bodyBottom) / lineH);
+  const pages = [];
+  for (let i = 0; i < lines.length; i += maxBodyLines) pages.push(lines.slice(i, i + maxBodyLines));
+  if (!pages.length) pages.push([]);
+
+  const objects = [];
+  const addObj = body => { objects.push(body); return objects.length; };
+
+  const catalogId = addObj(null);
+  const pagesId = addObj(null);
+  const fontId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>');
+  const fontBoldId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold >>');
+  let logoXObjId = null;
+  if (logoBuf && logoDims) {
+    const cs = logoDims.c === 1 ? '/DeviceGray' : '/DeviceRGB';
+    const dict = `<< /Type /XObject /Subtype /Image /Width ${logoDims.w} /Height ${logoDims.h} /ColorSpace ${cs} /BitsPerComponent 8 /Filter /DCTDecode /Length ${logoBuf.length} >>`;
+    logoXObjId = addObj({ dict, stream: logoBuf });
+  }
+
+  const exportDate = new Date().toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
+  const footerLeft = `Generated: ${exportDate}`;
+
+  const pageIds = [];
+  const contentIds = [];
+
+  pages.forEach((pg, pgIdx) => {
+    const parts = [];
+    if (logoXObjId) {
+      const logoH = 60;
+      const logoW = Math.round((logoDims.w / logoDims.h) * logoH);
+      parts.push(`q\n${logoW} 0 0 ${logoH} ${marginX} ${pageH - headerH + 15} cm\n/Logo Do\nQ`);
+    }
+    const titleX = (logoXObjId ? marginX + 80 : marginX);
+    const titleY = pageH - 45;
+    parts.push(`BT\n/F2 16 Tf\n${titleX} ${titleY} Td\n(${escapePdf('OASIS - Smart Contract')}) Tj\nET`);
+    const prefix = 'Issued to: ';
+    const prefixW = prefix.length * 5.4;
+    parts.push(`BT\n/F1 9 Tf\n${titleX} ${titleY - 16} Td\n(${escapePdf(prefix)}) Tj\nET`);
+    parts.push(`BT\n/F2 9 Tf\n${titleX + prefixW} ${titleY - 16} Td\n(${escapePdf(String(viewerId || ''))}) Tj\nET`);
+
+    parts.push(`q\n0.6 0.6 0.6 RG\n0.5 w\n${marginX} ${pageH - headerH} m\n${pageW - marginX} ${pageH - headerH} l\nS\nQ`);
+
+    let y = bodyTop;
+    for (const ln of pg) {
+      if (ln.kind === 'title') {
+        parts.push(`BT\n/F2 14 Tf\n0 0 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
+      } else if (ln.kind === 'subtitle') {
+        parts.push(`BT\n/F1 11 Tf\n0.2 0.2 0.2 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
+      } else if (ln.kind === 'section') {
+        parts.push(`BT\n/F2 11 Tf\n0 0 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
+        parts.push(`q\n0 0 0 RG\n0.5 w\n${marginX} ${y - 3} m\n${pageW - marginX} ${y - 3} l\nS\nQ`);
+      } else if (ln.kind === 'kv') {
+        parts.push(`BT\n/F1 10 Tf\n0 0 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
+      }
+      y -= lineH;
+    }
+
+    parts.push(`q\n0.6 0.6 0.6 RG\n0.5 w\n${marginX} ${footerH + 5} m\n${pageW - marginX} ${footerH + 5} l\nS\nQ`);
+    parts.push(`BT\n/F1 8 Tf\n${marginX} ${footerH - 10} Td\n(${escapePdf(footerLeft)}) Tj\nET`);
+    const pageLabel = `Page ${pgIdx + 1} of ${pages.length}`;
+    const pageLabelW = pageLabel.length * 4.8;
+    parts.push(`BT\n/F1 8 Tf\n${pageW - marginX - pageLabelW} ${footerH - 10} Td\n(${escapePdf(pageLabel)}) Tj\nET`);
+
+    const content = parts.join('\n');
+    const stream = `<< /Length ${Buffer.byteLength(content)} >>\nstream\n${content}\nendstream`;
+    const cid = addObj(stream);
+    contentIds.push(cid);
+    const pid = addObj(null);
+    pageIds.push(pid);
+  });
+
+  for (let i = 0; i < pageIds.length; i++) {
+    const resources = logoXObjId
+      ? `<< /Font << /F1 ${fontId} 0 R /F2 ${fontBoldId} 0 R >> /XObject << /Logo ${logoXObjId} 0 R >> >>`
+      : `<< /Font << /F1 ${fontId} 0 R /F2 ${fontBoldId} 0 R >> >>`;
+    objects[pageIds[i] - 1] = `<< /Type /Page /Parent ${pagesId} 0 R /MediaBox [0 0 ${pageW} ${pageH}] /Contents ${contentIds[i]} 0 R /Resources ${resources} >>`;
+  }
+  objects[catalogId - 1] = `<< /Type /Catalog /Pages ${pagesId} 0 R >>`;
+  objects[pagesId - 1] = `<< /Type /Pages /Kids [${pageIds.map(id => `${id} 0 R`).join(' ')}] /Count ${pageIds.length} >>`;
+
+  const chunks = [];
+  const offsets = [0];
+  let byteLen = 0;
+  const push = (buf) => { chunks.push(buf); byteLen += buf.length; };
+  push(Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n', 'binary'));
+  for (let i = 0; i < objects.length; i++) {
+    offsets.push(byteLen);
+    const obj = objects[i];
+    if (obj && typeof obj === 'object' && obj.dict && obj.stream) {
+      push(Buffer.from(`${i + 1} 0 obj\n${obj.dict}\nstream\n`, 'binary'));
+      push(obj.stream);
+      push(Buffer.from('\nendstream\nendobj\n', 'binary'));
+    } else {
+      push(Buffer.from(`${i + 1} 0 obj\n${obj}\nendobj\n`, 'binary'));
+    }
+  }
+  const xrefStart = byteLen;
+  let xref = `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
+  for (let i = 1; i <= objects.length; i++) xref += `${String(offsets[i]).padStart(10, '0')} 00000 n \n`;
+  xref += `trailer\n<< /Size ${objects.length + 1} /Root ${catalogId} 0 R >>\nstartxref\n${xrefStart}\n%%EOF`;
+  push(Buffer.from(xref, 'binary'));
+  return Buffer.concat(chunks);
+}
+
+module.exports = { buildSmartContractPdf };

+ 40 - 37
src/client/assets/styles/mobile.css

@@ -242,55 +242,58 @@ h3 { font-size: 1em !important; }
   gap: 4px !important;
 }
 
-.mode-buttons {
+.mode-buttons,
+.mode-buttons-cols,
+.mode-buttons-row,
+.filter-group,
+.activity-filter-grid,
+.activity-sub-filter,
+.filters .ui-toolbar {
   display: flex !important;
-  flex-direction: column !important;
-  width: 100% !important;
-  gap: 8px !important;
-  grid-template-columns: 1fr !important;
-}
-
-.mode-buttons-cols {
-  display: flex !important;
-  flex-direction: column !important;
-  width: 100% !important;
-  gap: 8px !important;
-}
-
-.mode-buttons-row {
-  display: flex !important;
-  flex-direction: column !important;
+  flex-direction: row !important;
+  flex-wrap: nowrap !important;
   width: 100% !important;
-  gap: 8px !important;
+  gap: 6px !important;
+  overflow-x: auto !important;
+  overflow-y: hidden !important;
+  padding-bottom: 6px !important;
+  scroll-snap-type: x proximity !important;
+  -webkit-overflow-scrolling: touch !important;
+  grid-template-columns: none !important;
 }
 
 .mode-buttons .column,
-.mode-buttons > div {
+.mode-buttons > div,
+.activity-filter-col {
   display: flex !important;
-  flex-direction: column !important;
-  width: 100% !important;
+  flex-direction: row !important;
+  flex-wrap: nowrap !important;
+  width: auto !important;
   gap: 6px !important;
-  grid-template-columns: 1fr !important;
+  flex: 0 0 auto !important;
 }
 
-.mode-buttons form {
-  width: 100% !important;
+.mode-buttons form,
+.filter-group form,
+.activity-filter-col form {
+  width: auto !important;
+  flex: 0 0 auto !important;
+  scroll-snap-align: start !important;
 }
 
 .mode-buttons .filter-btn,
-.mode-buttons button {
-  width: 100% !important;
-}
-
-.filter-group {
-  display: flex !important;
-  flex-direction: column !important;
-  width: 100% !important;
-  gap: 6px !important;
-}
-
-.filter-group form {
-  width: 100% !important;
+.mode-buttons button,
+.filter-group .filter-btn,
+.filter-group button,
+.activity-filter-grid .filter-btn,
+.activity-filter-grid button,
+.activity-sub-filter .filter-btn,
+.filters .filter-btn,
+.filters button {
+  width: auto !important;
+  flex: 0 0 auto !important;
+  white-space: nowrap !important;
+  min-width: 0 !important;
 }
 
 .inhabitant-card {

+ 466 - 8
src/client/assets/styles/style.css

@@ -1647,7 +1647,6 @@ display:flex; gap:8px; margin-top:16px;
 
 .tribe-details {
   display: flex;
-  background: #2c2f33;
   border-radius: 12px;
   padding: 24px;
   gap: 24px;
@@ -2231,6 +2230,11 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
   justify-content: center;
 }
 
+.market-tribe-card .market-card-kind{display:inline-block;font-size:10px;letter-spacing:2px;color:#888;text-transform:uppercase;font-weight:600;margin-bottom:4px}
+.market-tribe-card .market-card-price{font-size:16px;color:#FFD700;font-weight:600;margin-top:6px}
+.market-tribe-card .market-card-seller{font-size:13px;color:#bbb;margin-top:4px;word-break:break-all;border:none;background:transparent;padding:0}
+.market-tribe-card .market-card-seller-label{color:#ff9800;font-weight:600}
+
 .market-card {
   background-color: #2c2c2c;
   border-radius: 16px;
@@ -2312,9 +2316,86 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 .relationship-status {
   display: flex;
   flex-direction: row;
+  flex-wrap: wrap;
+  align-items: center;
   gap: 0.5rem;
-  justify-content: center;
+  justify-content: flex-start;
   margin: 0.8rem 0 0.5rem 0;
+  background: transparent !important;
+  border: 0 !important;
+  padding: 0 !important;
+}
+
+.relationship-status .relationship-actions {
+  display: flex;
+  flex-basis: 100%;
+  gap: 0.5rem;
+  margin-top: 0.4rem;
+  background: transparent !important;
+  border: 0 !important;
+  padding: 0 !important;
+}
+
+.relationship-status .relationship-actions form {
+  background: transparent !important;
+  border: 0 !important;
+  padding: 0 !important;
+}
+
+.inhabitant-left .cv-actions,
+.inhabitant-details .cv-actions {
+  background: transparent !important;
+  border: 0 !important;
+  padding: 0 !important;
+  width: 100%;
+}
+
+.inhabitant-left .cv-actions form,
+.inhabitant-details .cv-actions form {
+  background: transparent !important;
+  border: 0 !important;
+  padding: 0 !important;
+  width: 100%;
+}
+
+.inhabitant-left .cv-actions button.btn {
+  width: 100%;
+}
+
+.inhabitant-left .cv-actions p {
+  text-align: center;
+  margin: 0;
+}
+
+.profile-side-relationship .relationship-status {
+  justify-content: center;
+}
+
+.profile-side-relationship .relationship-actions {
+  justify-content: center;
+}
+
+.profile-side-relationship,
+.profile-side-actions {
+  background: transparent !important;
+  border: 0 !important;
+  padding: 0 !important;
+}
+
+.profile-side-relationship form,
+.profile-side-actions form {
+  background: transparent !important;
+  border: 0 !important;
+  padding: 0 !important;
+  display: inline-block;
+}
+
+.profile-side-actions {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  gap: 0.5rem;
+  margin-top: 0.5rem;
 }
 
 .status {
@@ -3315,6 +3396,39 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 .bank-claim-card p{margin:4px 0}
 .bank-claim-btn{font-size:16px;padding:10px 24px;margin-top:8px}
 
+.bank-eco-chart-block{margin:24px 0;border:1px solid #2a2a2a;border-radius:8px;padding:16px;background:#111}
+.bank-eco-chart-title{font-size:16px;margin:0 0 12px 0;color:#FFA500}
+.bank-eco-chart-canvas{width:100%;overflow:hidden}
+.bank-eco-chart-svg{width:100%;height:auto;display:block}
+.bank-eco-chart-bg{fill:#0e0e0e}
+.bank-eco-chart-grid{stroke:#1f1f1f;stroke-width:1}
+.bank-eco-chart-axis{fill:#FFD700;font-size:10px;font-family:monospace}
+.bank-eco-chart-empty{fill:#888;font-size:14px;font-family:monospace}
+.bank-eco-chart-line-value{fill:none;stroke:#FFA500;stroke-width:2}
+.bank-eco-chart-line-supply{fill:none;stroke:#4aa3ff;stroke-width:1.5;stroke-dasharray:4,3}
+.bank-eco-chart-line-inflation{fill:none;stroke:#e74c3c;stroke-width:1.5;stroke-dasharray:2,3}
+.bank-eco-chart-line-value-legend{fill:#FFA500}
+.bank-eco-chart-line-supply-legend{fill:#4aa3ff}
+.bank-eco-chart-line-inflation-legend{fill:#e74c3c}
+.bank-eco-chart-legend-text{fill:#bbb;font-size:11px;font-family:monospace}
+
+.transfer-block-card{margin-top:16px;border-top:2px solid #2a2a2a}
+.transfer-block-card h2{font-size:16px;color:#FFA500;margin:0 0 8px 0}
+
+.melody-player-card{border:1px solid #2a2a2a;border-radius:8px;padding:16px;background:#111;margin:16px 0}
+.melody-meta{margin-bottom:12px;display:flex;flex-wrap:wrap;align-items:center;gap:6px}
+.melody-meta-sep{color:#555}
+.melody-audio{width:100%;max-width:520px}
+.melody-section-title{font-size:16px;color:#FFA500;margin:16px 0 8px 0}
+.melody-help{color:#888;font-size:13px;margin:0 0 12px 0}
+.melody-notes-grid{display:flex;flex-wrap:wrap;gap:6px}
+.melody-note-chip{display:inline-flex;flex-direction:column;align-items:center;border:1px solid #333;border-radius:6px;padding:6px 10px;background:#161616;min-width:54px}
+.melody-note-name{color:#FFA500;font-family:monospace;font-size:13px;font-weight:600}
+.melody-note-type{color:#888;font-size:10px;text-transform:uppercase;letter-spacing:.5px}
+.melody-composition{margin:16px 0}
+.melody-stats{margin:16px 0}
+.melody-regen-form{margin-top:10px}
+
 .pub-item{border:1;border-radius:10px;background:none}
 .snh-invite-box{border:1px solid currentColor;border-radius:8px;padding:16px;margin:12px 0;font-family:monospace}
 .snh-invite-name{margin:0 0 8px 0}
@@ -3452,8 +3566,9 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 
 .oasis-footer {
   display: flex;
-  justify-content: center;
+  flex-direction: column;
   align-items: center;
+  gap: 12px;
   padding: 2px 10px;
   border-top: 1px solid rgba(255,255,255,0.08);
   line-height: 1;
@@ -3468,6 +3583,9 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
   box-shadow: 0 2px 20px 0 #FFD60024;
 }
 
+.oasis-footer-pulse-row{display:inline-block}
+.oasis-footer-pulse-label{color:inherit}
+
 .oasis-footer-logo-link {
   display: block;
   margin: 0 auto;
@@ -3512,7 +3630,7 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 }
 
 .oasis-footer-carbon {
-  font-size: 12px;
+  font-size: inherit;
 }
 
 .inbox-badge {
@@ -5167,7 +5285,7 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 .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 th,.stats-block table.stats-table-mt8 th{background:#272727;color:#FFDD44;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}
@@ -5176,9 +5294,19 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 .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}
+.stats-toplist .stats-toplist-name{flex:0 0 auto;color:#FFDD44;font-size:13px;min-width:120px}
+.stats-toplist .stats-toplist-num{flex:0 0 auto;color:#FFDD44;font-size:13px;min-width:36px;text-align:right}
 .peer-key{word-break:break-all;font-size:12px}
+.peers-technical-block{margin-top:16px}
+.peers-conn-actions{margin-bottom:12px}
+.invites-pubs-actions{margin:12px 0}
+.suggested-meta{margin-top:8px;display:flex;flex-direction:column;gap:4px}
+.inbox-exposition{margin:12px 0 4px 0}
+.inbox-filters-label{color:#FFDD44;font-size:13px;font-weight:600;margin-right:8px;align-self:center}
+.suggested-badge{display:inline-block;background:#FFA500;color:#000;padding:2px 8px;border-radius:4px;font-size:12px;font-weight:600;width:fit-content}
+.peers-import-form{margin-top:16px;display:flex;flex-direction:column;gap:8px;align-items:flex-start}
+.peers-import-form textarea{width:100%;max-width:720px;font-family:monospace;font-size:12px}
+.peers-import-label{color:#FFDD44;font-size:13px;font-weight:600}
 .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}
@@ -5206,6 +5334,336 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 .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{display:flex;gap:24px;flex-wrap:wrap;color:#ffa500;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%}
+.profile-qr{display:flex;justify-content:center;align-items:center;margin:16px 0}
+.profile-qr-img{display:block;max-width:240px;height:auto}
+.inhabitant-qr-link{display:inline-block;margin:6px 0 8px;line-height:0}
+.inhabitant-qr-small{display:block;width:96px;height:96px;background:#fff;padding:4px;border-radius:4px;image-rendering:pixelated}
+.job-candidates{margin-top:24px;padding-top:16px;border-top:1px solid #333}
+.job-candidates>h2{color:#ffa500;margin-bottom:4px}
+.job-exchange-hint{color:#ffa500;font-style:italic;margin:4px 0 12px;font-size:13px}
+.profile-visibility-table{margin:8px 0;border-collapse:collapse;width:auto;max-width:320px}
+.profile-visibility-table td{padding:6px 8px;text-align:left;vertical-align:middle;border:none;background:transparent}
+.profile-visibility-table tr,
+.profile-visibility-table tr:nth-child(even),
+.profile-visibility-table tr:nth-child(odd){background:transparent}
+.profile-visibility-table label{cursor:pointer;background:transparent;color:inherit;font-size:inherit;font-weight:inherit;font-family:inherit}
+
+.prefs-card{border:1px solid #2a2a2a;padding:14px 16px;margin:12px 0;background:#111}
+.prefs-card .tags-header{margin:0 0 10px 0}
+.prefs-card .tags-header h2{font-size:16px;color:#FFA500;margin:0 0 4px 0}
+.prefs-help{color:#FFD700;font-size:12px;margin:0}
+.pref-pill-row{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px}
+.pref-pill{display:inline-flex;align-items:center;border:1px solid #333;padding:6px 12px;cursor:pointer;background:#161616;color:#888;font-size:13px;user-select:none}
+.pref-pill:hover{border-color:#555;color:#bbb}
+.pref-pill-input{position:absolute;opacity:0;width:0;height:0;pointer-events:none}
+.pref-pill-label{display:inline-block}
+.pref-pill:has(.pref-pill-input:checked){background:#FFA500;border-color:#FFA500;color:#000;font-weight:600}
+.pref-pill:has(.pref-pill-input:checked):hover{background:#FFD700;border-color:#FFD700;color:#000}
+
+.profile-layout{align-items:flex-start}
+.profile-layout .tribe-side,
+.profile-layout .tribe-main{background:transparent}
+.profile-layout-single{display:flex;justify-content:center;padding:24px 0;gap:24px}
+.profile-layout-single .profile-side{width:340px;max-width:100%;flex:0 0 auto}
+.profile-side{align-items:center;text-align:center}
+.profile-side-header{display:flex;flex-direction:column;align-items:center;gap:6px;width:100%}
+.profile-side .inhabitant-photo-details{width:160px;height:160px;object-fit:cover;border:2px solid #ff9800;background:transparent}
+.profile-side-name{font-size:20px;margin:0;color:#FFD700;word-break:break-word}
+.profile-side-mention{font-family:monospace;font-size:12px;color:#FFD700;word-break:break-all;margin:6px 0 8px 0;line-height:1.5}
+.profile-side-mention a{color:#FFD700;text-decoration:none}
+.profile-side-mention strong{color:#FFD700;font-weight:700}
+.profile-side-description{color:#ddd;font-size:13px;line-height:1.5;margin:0 0 10px 0;word-break:break-word;background:transparent;padding:0;border:none}
+.profile-side-description p{margin:0 0 6px 0}
+.profile-side-qr{width:160px;height:160px;background:#fff;padding:6px;display:block;margin:0 auto}
+.profile-side-relationship{margin:8px 0 0 0}
+.profile-side-relationship .status{display:inline-block;margin:0 2px}
+.profile-side-actions,
+.profile-sensors-box{
+  display:flex;
+  flex-direction:column;
+  gap:6px;
+  align-items:stretch;
+  margin:12px 0;
+  background:transparent;
+  border:none;
+  padding:0;
+  width:100%;
+}
+.profile-side-actions .btn{
+  background-color:#FFA500;
+  color:#000;
+  padding:8px 16px;
+  text-align:center;
+  text-decoration:none;
+  border:none;
+  cursor:pointer;
+  font-family:inherit;
+  font-size:inherit;
+  display:inline-block;
+}
+.profile-side-actions .btn:hover{background-color:#FFD700;color:#000;text-decoration:none}
+.profile-sensors-box .inhabitant-activity-group,
+.inhabitant-left .inhabitant-activity-group,
+.inhabitant-left .inhabitant-karma-ubi{display:flex;flex-direction:column;gap:6px;margin-top:0;align-items:stretch;border:none;background:transparent;padding:0}
+.inhabitant-left>a:first-child{margin-bottom:14px}
+.profile-sensors-box .inhabitant-last-activity,
+.profile-sensors-box .karma-line,
+.profile-sensors-box .ubi-line,
+.inhabitant-left .inhabitant-last-activity,
+.inhabitant-left .karma-line,
+.inhabitant-left .ubi-line{
+  display:flex;
+  gap:6px;
+  align-items:center;
+  justify-content:center;
+  padding:0.2rem 0.6rem;
+  border:1px solid #555;
+  border-radius:4px;
+  background-color:#1A1A1A;
+  font-size:0.9rem;
+  color:#FFDD44;
+  margin:0;
+}
+.profile-sensors-box .inhabitant-last-activity strong,
+.profile-sensors-box .karma-line strong,
+.profile-sensors-box .ubi-line strong,
+.inhabitant-left .inhabitant-last-activity strong,
+.inhabitant-left .karma-line strong,
+.inhabitant-left .ubi-line strong{color:#FFDD44}
+.profile-sensors-box .inhabitant-last-activity a,
+.profile-sensors-box .karma-line a,
+.profile-sensors-box .ubi-line a,
+.inhabitant-left .inhabitant-last-activity a,
+.inhabitant-left .karma-line a,
+.inhabitant-left .ubi-line a{color:#FFDD44;text-decoration:none}
+.profile-sensors-box .inhabitant-last-activity a:hover,
+.profile-sensors-box .karma-line a:hover,
+.profile-sensors-box .ubi-line a:hover,
+.inhabitant-left .inhabitant-last-activity a:hover,
+.inhabitant-left .karma-line a:hover,
+.inhabitant-left .ubi-line a:hover{color:#ffa500;text-decoration:underline}
+.profile-main{gap:12px}
+.profile-module-section{display:flex;flex-direction:column;gap:12px;margin:0 0 12px 0}
+.tribe-invite-code-input{width:100%;max-width:680px;padding:10px 12px;font-family:monospace;font-size:14px;letter-spacing:1px;background:#1a1a1a;color:#ffd700;border:1px solid #555;border-radius:4px;margin:12px 0}
+.pad-author-cell,.tribe-author-cell{width:100%}
+.pad-author-cell a.user-link,.tribe-author-cell a.user-link{display:block;width:100%;box-sizing:border-box;text-align:center;padding:8px 12px}
+
+.search-filters-table{margin:12px 0;border-collapse:collapse;width:auto;max-width:680px}
+.search-filters-table tr,
+.search-filters-table tr:nth-child(even),
+.search-filters-table tr:nth-child(odd){background:transparent}
+.search-filters-table td{padding:6px 10px;border:none;vertical-align:middle;background:transparent}
+.search-filters-table td.card-label{white-space:nowrap;font-weight:600;color:#ffa500}
+.search-filters-table input[type="datetime-local"],
+.search-filters-table input.search-oasis-id{min-width:280px}
+.transfer-category-select{width:auto;min-width:160px;max-width:200px;padding:6px 10px}
+.spread-row{margin:8px 0;display:flex;justify-content:flex-start}
+.spread-form{display:inline-block}
+.spread-btn{cursor:pointer;font:inherit;padding:4px 10px;border:1px solid #555;border-radius:4px;background:#1a1a1a;color:#ffa500;font-size:14px}
+.spread-btn:hover{background:#2a2a2a;border-color:#ffa500}
+.spread-btn-on{background:#ffa500;color:#1a1a1a;border-color:#ff7300}
+.spread-btn-on:hover{background:#ffb733;color:#000}
+.welcome-header{margin-bottom:18px}
+.welcome-chat{max-width:760px;margin:0 auto;padding:8px}
+.welcome-ai-header{display:flex;align-items:center;gap:10px;margin-bottom:10px;color:#ffa500;font-weight:600}
+.welcome-ai-avatar{font-size:24px}
+.welcome-ai-name{font-size:16px}
+.welcome-ai-step{margin-left:auto;font-size:12px;color:#888}
+.welcome-bubble{background:#1a1a1a;border:1px solid #333;border-radius:12px;padding:14px 18px;margin-bottom:14px;line-height:1.5}
+.welcome-bubble-ai{border-left:3px solid #ffa500}
+.welcome-bubble-title{margin:0 0 8px;color:#ffd700;font-size:18px}
+.welcome-bubble p{margin:6px 0;color:#ddd}
+.welcome-actions{display:flex;flex-wrap:wrap;gap:8px;margin:14px 0;justify-content:flex-end}
+.welcome-action-primary{background:#ff6600;color:#fff;border-color:#ff3300}
+.welcome-progress{display:flex;justify-content:center;gap:10px;margin:18px 0 6px}
+.welcome-dot{font-size:14px;color:#444;text-decoration:none}
+.welcome-dot-active{color:#ffa500}
+.welcome-skip{text-align:center;margin-top:6px}
+.welcome-skip-link{color:#666;font-size:12px}
+.indexing-progress-block{max-width:640px;margin:24px auto;text-align:center}
+.indexing-progress{width:100%;height:18px}
+.indexing-percent{margin:8px 0 4px;font-size:22px;color:#ffd700}
+.indexing-note{color:#888;font-size:12px;margin:0}
+.indexing-detail{max-width:720px;margin:24px auto;color:#ddd;line-height:1.55}
+.indexing-detail p{margin:10px 0}
+.indexing-actions{display:flex;flex-wrap:wrap;gap:8px;justify-content:center;margin:24px 0}
+.search-filters-row{display:flex;gap:32px;flex-wrap:wrap;align-items:flex-start}
+.search-filters-row .search-filters-table{flex:1 1 320px}
+.search-submit-cell{padding-top:8px}
+.search-submit-cell button{padding:6px 16px}
+.search-submit-row{margin-top:12px;display:flex;justify-content:flex-start}
+.search-submit-row button{padding:8px 18px}
+.pm-exposition{display:flex;border:0;}
+.pm-exposition-label{color:#ffa500;font-weight:600;font-size:13px;text-transform:uppercase;letter-spacing:0.5px}
+.pm-exposition-chip{display:inline-flex;border-radius:14px;font-size:14px;font-weight:600;border:0}
+.pm-exposition-whole{background:#3a2300;color:#ffd700}
+.pm-exposition-mutuals{background:#1a3a1a;color:#7fff7f}
+.pm-exposition-icon{font-size:14px;line-height:1}
+.pm-exposition-text{line-height:1;}
+.shop-title-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:8px}
+.shop-title-row h2{margin:0}
+.profile-reach{display:flex;flex-direction:column;align-items:center;gap:8px;margin:12px 0;text-align:center}
+.profile-reach .shop-clearnet-url{margin:0;text-align:center}
+.profile-reach-toggle{margin:0}
+.profile-reach-toggle .btn{
+  background-color:#FFA500;
+  color:#000;
+  padding:8px 16px;
+  border-radius:5px;
+  text-align:center;
+  text-decoration:none;
+  border:none;
+  cursor:pointer;
+  font-family:inherit;
+  font-size:inherit;
+}
+.profile-reach-toggle .btn:hover{background-color:#FFD700}
+
+/* Unified item card aesthetic — applied to per-module containers that previously had no card styling. Existing rules (.market-card, .feed-card, .tribe-card, etc.) keep their own look via cascade. */
+.card,
+.card-section,
+.event-card,
+.job-card,
+.task-card,
+.transfer-card,
+.project-card,
+.report-card,
+.vote-card,
+.ai-nav-result-card,
+.forum-card,
+.calendar-card,
+.game-card,
+.game-score-card,
+.bookmark-card,
+.audio-card,
+.video-card,
+.image-card,
+.document-card,
+.torrent-card,
+.chat-card,
+.opinions-card,
+.post-card,
+.map-card,
+.mention-card,
+.trending-card,
+.bank-claim-card,
+.addr-list-card,
+.shop-product-card,
+.cv-item,
+.transfer-item,
+.bounty-item,
+.calendar-date-item,
+.evidence-item,
+.settlement-item,
+.thread-reply-item {
+  background: #1c1c1c;
+  border: 1px solid #444;
+  border-radius: 12px;
+  padding: 16px 18px;
+  margin-bottom: 16px;
+  box-shadow: 0 1px 3px rgba(0,0,0,0.35);
+}
+.card-section .card-section,
+.event-card .card-section,
+.job-card .card-section,
+.transfer-card .card-section,
+.project-card .card-section {
+  margin-bottom: 8px;
+  padding: 10px 12px;
+  background: #232323;
+  border-color: #3a3a3a;
+}
+.card>.card-footer,
+.card-section>.card-footer,
+.event-card>.card-footer,
+.job-card>.card-footer,
+.task-card>.card-footer,
+.transfer-card>.card-footer,
+.project-card>.card-footer,
+.vote-card>.card-footer {
+  margin-top: 14px;
+  padding-top: 10px;
+  border-top: 1px solid #333;
+  font-size: 13px;
+  color: #aaa;
+}
+.card-fields-container { display:flex; flex-direction:column; gap:0; }
+
+/* Table-like layout for label/value pairs inside item cards. Two-column grid: label fixed, value flexible, subtle row separator. */
+.card .card-field,
+.card-section .card-field,
+.card-fields-container .card-field,
+.event-card .card-field,
+.job-card .card-field,
+.task-card .card-field,
+.transfer-card .card-field,
+.project-card .card-field,
+.report-card .card-field,
+.vote-card .card-field,
+.forum-card .card-field,
+.tribe-card .card-field,
+.tribe-content-card .card-field,
+.calendar-card .card-field,
+.game-card .card-field,
+.game-score-card .card-field,
+.bookmark-card .card-field,
+.audio-card .card-field,
+.video-card .card-field,
+.image-card .card-field,
+.document-card .card-field,
+.torrent-card .card-field,
+.chat-card .card-field,
+.opinions-card .card-field,
+.post-card .card-field,
+.map-card .card-field,
+.mention-card .card-field,
+.trending-card .card-field,
+.bank-claim-card .card-field,
+.shop-product-card .card-field,
+.cv-item .card-field,
+.transfer-item .card-field {
+  display: grid;
+  grid-template-columns: max-content 1fr;
+  gap: 10px;
+  padding: 4px 0;
+  margin: 0;
+  border-bottom: 1px solid #2a2a2a;
+  align-items: baseline;
+}
+.card .card-field:last-child,
+.card-section .card-field:last-child,
+.card-fields-container .card-field:last-child,
+.event-card .card-field:last-child,
+.job-card .card-field:last-child,
+.task-card .card-field:last-child,
+.transfer-card .card-field:last-child,
+.project-card .card-field:last-child,
+.report-card .card-field:last-child,
+.vote-card .card-field:last-child,
+.forum-card .card-field:last-child,
+.tribe-card .card-field:last-child,
+.calendar-card .card-field:last-child,
+.bookmark-card .card-field:last-child,
+.audio-card .card-field:last-child,
+.video-card .card-field:last-child,
+.image-card .card-field:last-child,
+.document-card .card-field:last-child,
+.torrent-card .card-field:last-child,
+.chat-card .card-field:last-child,
+.cv-item .card-field:last-child,
+.transfer-item .card-field:last-child {
+  border-bottom: none;
+}
+.card .card-field-stacked,
+.card-section .card-field-stacked,
+.event-card .card-field-stacked,
+.job-card .card-field-stacked,
+.project-card .card-field-stacked,
+.report-card .card-field-stacked,
+.tribe-card .card-field-stacked {
+  grid-template-columns: 1fr;
+  gap: 4px;
+}

+ 40 - 37
src/client/assets/themes/OasisMobile.css

@@ -532,55 +532,58 @@ h3 { font-size: 1em !important; }
   gap: 4px !important;
 }
 
-.mode-buttons {
+.mode-buttons,
+.mode-buttons-cols,
+.mode-buttons-row,
+.filter-group,
+.activity-filter-grid,
+.activity-sub-filter,
+.filters .ui-toolbar {
   display: flex !important;
-  flex-direction: column !important;
-  width: 100% !important;
-  gap: 8px !important;
-  grid-template-columns: 1fr !important;
-}
-
-.mode-buttons-cols {
-  display: flex !important;
-  flex-direction: column !important;
-  width: 100% !important;
-  gap: 8px !important;
-}
-
-.mode-buttons-row {
-  display: flex !important;
-  flex-direction: column !important;
+  flex-direction: row !important;
+  flex-wrap: nowrap !important;
   width: 100% !important;
-  gap: 8px !important;
+  gap: 6px !important;
+  overflow-x: auto !important;
+  overflow-y: hidden !important;
+  padding-bottom: 6px !important;
+  scroll-snap-type: x proximity !important;
+  -webkit-overflow-scrolling: touch !important;
+  grid-template-columns: none !important;
 }
 
 .mode-buttons .column,
-.mode-buttons > div {
+.mode-buttons > div,
+.activity-filter-col {
   display: flex !important;
-  flex-direction: column !important;
-  width: 100% !important;
+  flex-direction: row !important;
+  flex-wrap: nowrap !important;
+  width: auto !important;
   gap: 6px !important;
-  grid-template-columns: 1fr !important;
+  flex: 0 0 auto !important;
 }
 
-.mode-buttons form {
-  width: 100% !important;
+.mode-buttons form,
+.filter-group form,
+.activity-filter-col form {
+  width: auto !important;
+  flex: 0 0 auto !important;
+  scroll-snap-align: start !important;
 }
 
 .mode-buttons .filter-btn,
-.mode-buttons button {
-  width: 100% !important;
-}
-
-.filter-group {
-  display: flex !important;
-  flex-direction: column !important;
-  width: 100% !important;
-  gap: 6px !important;
-}
-
-.filter-group form {
-  width: 100% !important;
+.mode-buttons button,
+.filter-group .filter-btn,
+.filter-group button,
+.activity-filter-grid .filter-btn,
+.activity-filter-grid button,
+.activity-sub-filter .filter-btn,
+.filters .filter-btn,
+.filters button {
+  width: auto !important;
+  flex: 0 0 auto !important;
+  white-space: nowrap !important;
+  min-width: 0 !important;
 }
 
 .inhabitant-card {

Разлика између датотеке није приказан због своје велике величине
+ 203 - 9
src/client/assets/translations/oasis_ar.js


Разлика између датотеке није приказан због своје велике величине
+ 203 - 9
src/client/assets/translations/oasis_de.js


Разлика између датотеке није приказан због своје велике величине
+ 221 - 8
src/client/assets/translations/oasis_en.js


Разлика између датотеке није приказан због своје велике величине
+ 204 - 10
src/client/assets/translations/oasis_es.js


Разлика између датотеке није приказан због своје велике величине
+ 202 - 8
src/client/assets/translations/oasis_eu.js


Разлика између датотеке није приказан због своје велике величине
+ 204 - 10
src/client/assets/translations/oasis_fr.js


Разлика између датотеке није приказан због своје велике величине
+ 203 - 9
src/client/assets/translations/oasis_hi.js


Разлика између датотеке није приказан због своје велике величине
+ 202 - 8
src/client/assets/translations/oasis_it.js


Разлика између датотеке није приказан због своје велике величине
+ 202 - 8
src/client/assets/translations/oasis_pt.js


Разлика између датотеке није приказан због своје велике величине
+ 203 - 9
src/client/assets/translations/oasis_ru.js


Разлика између датотеке није приказан због своје велике величине
+ 203 - 9
src/client/assets/translations/oasis_zh.js


+ 89 - 14
src/client/middleware.js

@@ -1,17 +1,59 @@
 const path = require("path");
+const os = require("os");
 const Koa = require(path.join(__dirname, "../server/node_modules/koa"));
 const koaStatic = require(path.join(__dirname, "../server/node_modules/koa-static"));
 const { join } = require("path");
 const mount = require(path.join(__dirname, "../server/node_modules/koa-mount"));
 
+function obfuscateClearnetHtml(html) {
+  if (typeof html !== 'string' || html.length === 0) return html;
+  const preserve = [];
+  const stash = (re) => {
+    html = html.replace(re, (m) => {
+      preserve.push(m);
+      return `${preserve.length - 1}`;
+    });
+  };
+  stash(/<pre[\s\S]*?<\/pre>/gi);
+  stash(/<textarea[\s\S]*?<\/textarea>/gi);
+  stash(/<style[\s\S]*?<\/style>/gi);
+  html = html.replace(/<!--[\s\S]*?-->/g, '');
+  html = html.replace(/>[\s\n\r\t]+</g, '><');
+  html = html.replace(/[ \t]{2,}/g, ' ');
+  html = html.replace(/[\r\n]+/g, '');
+  html = html.replace(/(\d+)/g, (_, i) => preserve[Number(i)] || '');
+  return html;
+}
+
+const collectLocalIPs = () => {
+  const out = [];
+  try {
+    const ifaces = os.networkInterfaces();
+    for (const name of Object.keys(ifaces)) {
+      for (const info of (ifaces[name] || [])) {
+        if (info && !info.internal && (info.family === 'IPv4' || info.family === 4)) {
+          out.push(info.address);
+        }
+      }
+    }
+  } catch (_) {}
+  return out;
+};
+
 module.exports = ({ host, port, middleware, allowHost }) => {
   const assets = new Koa()
   assets.use(koaStatic(join(__dirname, "..", "client", "assets")));
-  
+
   const app = new Koa();
   const validHosts = [];
 
+  const isClearnetPath = (request) => {
+    const url = String(request.url || '');
+    return url === '/c' || url.startsWith('/c/') || url.startsWith('/c?');
+  };
+
   const isValidRequest = (request) => {
+    if (isClearnetPath(request)) return request.method === 'GET';
     if (validHosts.includes(request.hostname) !== true) {
       return false;
     }
@@ -41,6 +83,10 @@ module.exports = ({ host, port, middleware, allowHost }) => {
     if (err && (err.code === 'ECONNRESET' || err.code === 'EPIPE')) {
       return;
     }
+    if (err && (err.name === 'BadRequestError' || err.status === 400)) {
+      console.error(`[400] ${err.message}`);
+      return null;
+    }
     console.error(err);
     if (ctx && isValidRequest(ctx.request)) {
       err.message = err.message || 'Internal server error';
@@ -71,19 +117,33 @@ module.exports = ({ host, port, middleware, allowHost }) => {
   
     //console.log("Requesting:", ctx.path); // uncomment to check for HTTP requests
     
-    const csp = [
-      "default-src 'self'",
-      "script-src 'self' http://localhost:3000/js",
-      "style-src 'self'",
-      "img-src 'self'",
-      "media-src 'self' blob:",
-      "worker-src 'self' blob:",
-      "frame-src 'self'",
-      "form-action 'self'",
-      "object-src 'none'",
-      "base-uri 'none'",
-      "frame-ancestors 'none'"
-    ].join("; ");
+    const isClearnet = isClearnetPath(ctx.request);
+    const csp = isClearnet
+      ? [
+          "default-src 'self'",
+          "script-src 'none'",
+          "style-src 'self' 'unsafe-inline'",
+          "img-src 'self' data:",
+          "media-src 'self' blob:",
+          "connect-src 'self'",
+          "form-action 'self'",
+          "object-src 'none'",
+          "base-uri 'none'",
+          "frame-ancestors 'none'"
+        ].join("; ")
+      : [
+          "default-src 'self'",
+          "script-src 'self' http://localhost:3000/js",
+          "style-src 'self'",
+          "img-src 'self'",
+          "media-src 'self' blob:",
+          "worker-src 'self' blob:",
+          "frame-src 'self'",
+          "form-action 'self'",
+          "object-src 'none'",
+          "base-uri 'none'",
+          "frame-ancestors 'none'"
+        ].join("; ");
 
     ctx.set("Content-Security-Policy", csp);
     ctx.set("X-Frame-Options", "SAMEORIGIN");
@@ -102,6 +162,13 @@ module.exports = ({ host, port, middleware, allowHost }) => {
     );
 
     await next();
+
+    if (isClearnet && typeof ctx.body === 'string') {
+      const type = String(ctx.response.type || ctx.response.get('Content-Type') || '').toLowerCase();
+      if (type.includes('html')) {
+        ctx.body = obfuscateClearnetHtml(ctx.body);
+      }
+    }
   });
   
   // pdf viewer
@@ -128,6 +195,14 @@ module.exports = ({ host, port, middleware, allowHost }) => {
     if (validHosts.includes(host) === false) {
       validHosts.push(host);
     }
+
+    for (const ip of collectLocalIPs()) {
+      if (validHosts.includes(ip) === false) validHosts.push(ip);
+    }
+
+    for (const loopback of ['localhost', '127.0.0.1']) {
+      if (validHosts.includes(loopback) === false) validHosts.push(loopback);
+    }
   });
 
   return server;

+ 0 - 4
src/configs/blockchain-cycle.json

@@ -1,4 +0,0 @@
-{
-  "cycle": 5,
-  "url": "https://laplaza.solarnethub.com"
-}

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

@@ -38,6 +38,7 @@ if (!fs.existsSync(configFilePath)) {
       "transfersMod": "on",
       "feedMod": "on",
       "pixeliaMod": "on",
+      "melodyMod": "on",
       "agendaMod": "on",
       "aiMod": "on",
       "aiNavMod": "on",
@@ -82,7 +83,7 @@ if (!fs.existsSync(configFilePath)) {
 const getConfig = () => {
   const configData = fs.readFileSync(configFilePath);
   const cfg = JSON.parse(configData);
-  if (cfg.wish !== 'whole' && cfg.wish !== 'mutuals') cfg.wish = 'whole';
+  if (!['whole', 'mutuals', 'only-lan'].includes(cfg.wish)) cfg.wish = 'whole';
   if (cfg.pmVisibility !== 'whole' && cfg.pmVisibility !== 'mutuals') cfg.pmVisibility = 'whole';
   return cfg;
 };

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

@@ -32,6 +32,7 @@
     "transfersMod": "on",
     "feedMod": "on",
     "pixeliaMod": "on",
+    "melodyMod": "on",
     "agendaMod": "on",
     "aiMod": "on",
     "aiNavMod": "on",
@@ -67,6 +68,6 @@
   },
   "homePage": "activity",
   "language": "en",
-  "wish": "whole",
+  "wish": "only-lan",
   "pmVisibility": "whole"
-}
+}

+ 16 - 1
src/configs/shared-state.js

@@ -2,6 +2,11 @@ let _inboxCount = 0;
 let _carbonHcT = 0;
 let _carbonHcH = 0;
 let _lastRefresh = 0;
+let _onlinePeers = null;
+let _inboxUnread = null;
+let _lastSyncTs = null;
+let _ecoValue = null;
+let _lastActivity = null;
 module.exports = {
   getInboxCount: () => _inboxCount,
   setInboxCount: (n) => { _inboxCount = n; },
@@ -10,5 +15,15 @@ module.exports = {
   getCarbonHcH: () => _carbonHcH,
   setCarbonHcH: (n) => { _carbonHcH = n; },
   getLastRefresh: () => _lastRefresh,
-  setLastRefresh: (t) => { _lastRefresh = t; }
+  setLastRefresh: (t) => { _lastRefresh = t; },
+  getOnlinePeerCount: () => _onlinePeers,
+  setOnlinePeerCount: (n) => { _onlinePeers = n; },
+  getInboxUnreadCount: () => _inboxUnread,
+  setInboxUnreadCount: (n) => { _inboxUnread = n; },
+  getLastSyncTs: () => _lastSyncTs,
+  setLastSyncTs: (t) => { _lastSyncTs = t; },
+  getEcoValue: () => _ecoValue,
+  setEcoValue: (v) => { _ecoValue = v; },
+  getLastActivity: () => _lastActivity,
+  setLastActivity: (a) => { _lastActivity = a; }
 };

+ 67 - 5
src/models/activity_model.js

@@ -55,9 +55,36 @@ function inferType(c = {}) {
   return c.type || '';
 }
 
-module.exports = ({ cooler }) => {
+const HIDDEN_ENVELOPE_TYPES = new Set([
+  'tribe-keys-distrib',
+  'tribe-invite-msg',
+  'tribe-invite-tombstone'
+]);
+
+module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
   let ssb;
   const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb };
+
+  let _feedCache = null;
+  let _feedCacheInflight = null;
+  const FEED_CACHE_MS = 15 * 1000;
+
+  const buildAccessibleTribeIds = async () => {
+    const set = new Set();
+    if (!tribesModel) return set;
+    try {
+      const list = await tribesModel.listAll();
+      for (const t of list) {
+        if (!t || !t.id) continue;
+        set.add(t.id);
+        try {
+          const chain = await tribesModel.getChainIds(t.id);
+          for (const cid of chain) set.add(cid);
+        } catch (_) {}
+      }
+    } catch (_) {}
+    return set;
+  };
   const hasBlob = async (ssbClient, url) => new Promise(resolve => ssbClient.blobs.has(url, (err, has) => resolve(!err && has)));
   const getMsg = async (ssbClient, key) => new Promise(resolve => ssbClient.get(key, (err, msg) => resolve(err ? null : msg)));
   const normNL = (s) => String(s || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
@@ -75,7 +102,19 @@ module.exports = ({ cooler }) => {
   };
 
   return {
+    invalidateCache() {
+      _feedCache = null;
+      _feedCacheInflight = null;
+    },
     async listFeed(filter = 'all') {
+      const cacheKey = filter || 'all';
+      const now = Date.now();
+      if (!_feedCache) _feedCache = new Map();
+      const entry = _feedCache.get(cacheKey);
+      if (entry && now - entry.ts < FEED_CACHE_MS) return entry.value;
+      if (!_feedCacheInflight) _feedCacheInflight = new Map();
+      if (_feedCacheInflight.has(cacheKey)) return _feedCacheInflight.get(cacheKey);
+      const promise = (async () => {
       const ssbClient = await openSsb();
       const userId = ssbClient.id;
 
@@ -90,12 +129,26 @@ module.exports = ({ cooler }) => {
       const parentOf = new Map();
       const idToAction = new Map();
       const rawById = new Map();
+      const fpIdx = tribeCrypto ? tribeCrypto.buildFingerprintIndex() : null;
+      const accessibleTribeIds = await buildAccessibleTribeIds();
 
       for (const msg of results) {
         const k = msg.key;
         const v = msg.value;
-        const c = v?.content;
-        if (!c?.type) continue;
+        let c = v?.content;
+        if (!c) continue;
+        if (typeof c === 'string' && c.endsWith('.box')) continue;
+        if (c.type && HIDDEN_ENVELOPE_TYPES.has(c.type)) continue;
+        if (tribeCrypto && tribeCrypto.isTribeMsg(c)) {
+          const r = fpIdx ? tribeCrypto.unwrapMsg(c, fpIdx) : null;
+          if (!r || !r.body) continue;
+          const inner = r.body;
+          if (inner.k !== 'tribe' || inner.op !== 'create') continue;
+          c = { ...inner, type: 'tribe', _decrypted: true, _rootId: r.rootId };
+        } else if (c.tribeId && !accessibleTribeIds.has(c.tribeId)) {
+          continue;
+        }
+        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;
@@ -377,15 +430,15 @@ module.exports = ({ cooler }) => {
       deduped = Array.from(byKey.values()).map(x => { delete x.__effTs; delete x.__hasImage; return x });
 
       const tribeInternalTypes = new Set(['tribe-content', 'tribeParliamentCandidature', 'tribeParliamentTerm', 'tribeParliamentProposal', 'tribeParliamentRule', 'tribeParliamentLaw', 'tribeParliamentRevocation']);
-      const hiddenTypes = new Set(['padEntry', 'chatMessage', 'calendarDate', 'calendarNote', 'calendarReminderSent', 'feed-action', 'pubBalance', 'pubAvailability', 'log']);
+      const hiddenTypes = new Set(['padEntry', 'chatMessage', 'calendarDate', 'calendarNote', 'calendarReminderSent', 'taskReminderSent', 'feed-action', 'pubBalance', 'pubAvailability', 'log']);
       const isAllowedTribeActivity = (a) => {
         if (tribeInternalTypes.has(a.type)) return false;
         const c = a.content || {};
         if (c.tribeId) return false;
         if (a.type === 'tribe') {
-          if (c.isAnonymous === true) return false;
           const isInitial = !c.replaces;
           if (!isInitial) return false;
+          if (c.isAnonymous === true && !c._decrypted) return false;
         }
         return true;
       };
@@ -426,6 +479,15 @@ module.exports = ({ cooler }) => {
 
       out.sort((a, b) => (b.ts || 0) - (a.ts || 0));
       return out;
+      })();
+      _feedCacheInflight.set(cacheKey, promise);
+      try {
+        const value = await promise;
+        _feedCache.set(cacheKey, { value, ts: Date.now() });
+        return value;
+      } finally {
+        _feedCacheInflight.delete(cacheKey);
+      }
     }
   };
 };

+ 62 - 9
src/models/agenda_model.js

@@ -18,7 +18,7 @@ function writeAgendaConfig(cfg) {
   fs.writeFileSync(agendaConfigPath, JSON.stringify(cfg, null, 2));
 }
 
-module.exports = ({ cooler }) => {
+module.exports = ({ cooler, calendarsModel }) => {
   let ssb;
   const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
 
@@ -162,9 +162,31 @@ module.exports = ({ cooler }) => {
       const reports = reportsAll.filter(c => c.author === userId || (Array.isArray(c.confirmations) && c.confirmations.includes(userId))).map(r => ({ ...r, type: 'report' }));
       const jobs = jobsAll.filter(c => c.author === userId || (Array.isArray(c.subscribers) && c.subscribers.includes(userId))).map(j => ({ ...j, type: 'job', title: j.title }));
       const projects = projectsAll.map(p => ({ ...p, type: 'project' }));
-      const calendars = calendarsAll
-        .filter(c => c.author === userId || (Array.isArray(c.participants) && c.participants.includes(userId)))
-        .map(c => ({ ...c, type: 'calendar' }));
+      const myCalendars = calendarsAll
+        .filter(c => c.author === userId || (Array.isArray(c.participants) && c.participants.includes(userId)));
+      const calendars = myCalendars.map(c => ({ ...c, type: 'calendar' }));
+      const calendarDates = [];
+      if (calendarsModel && typeof calendarsModel.getDatesForCalendar === 'function') {
+        for (const cal of myCalendars) {
+          try {
+            const dates = await calendarsModel.getDatesForCalendar(cal.id || cal.key);
+            for (const d of (dates || [])) {
+              calendarDates.push({
+                type: 'calendarDate',
+                id: d.key || d.id,
+                title: d.label || cal.title || 'Calendar date',
+                calendarTitle: cal.title || '',
+                calendarId: cal.id || cal.key,
+                date: d.date || d.createdAt,
+                label: d.label || '',
+                author: cal.author,
+                createdAt: d.createdAt || cal.createdAt,
+                status: 'OPEN'
+              });
+            }
+          } catch (_) {}
+        }
+      }
 
       let combined = [
         ...tasks,
@@ -175,7 +197,8 @@ module.exports = ({ cooler }) => {
         ...reports,
         ...jobs,
         ...projects,
-        ...calendars
+        ...calendars,
+        ...calendarDates
       ];
 
       let filtered;
@@ -193,18 +216,45 @@ module.exports = ({ cooler }) => {
         else if (filter === 'closed') filtered = filtered.filter(i => String(i.status).toUpperCase() === 'CLOSED');
         else if (filter === 'jobs') filtered = filtered.filter(i => i.type === 'job');
         else if (filter === 'projects') filtered = filtered.filter(i => i.type === 'project');
-        else if (filter === 'calendars') filtered = filtered.filter(i => i.type === 'calendar');
+        else if (filter === 'calendars') filtered = filtered.filter(i => i.type === 'calendar' || i.type === 'calendarDate');
+        else if (filter === 'today') {
+          const startOfDay = new Date(); startOfDay.setHours(0, 0, 0, 0);
+          const endOfDay = new Date(); endOfDay.setHours(23, 59, 59, 999);
+          filtered = filtered.filter(i => {
+            const d = new Date(i.date || i.startTime || i.deadline || 0).getTime();
+            return d >= startOfDay.getTime() && d <= endOfDay.getTime();
+          });
+        }
+        else if (filter === 'upcoming') {
+          const now = Date.now();
+          filtered = filtered.filter(i => {
+            const d = new Date(i.date || i.startTime || i.deadline || 0).getTime();
+            return d > now;
+          });
+        }
+        else if (filter === 'overdue') {
+          const startOfDay = new Date(); startOfDay.setHours(0, 0, 0, 0);
+          filtered = filtered.filter(i => {
+            const d = new Date(i.date || i.startTime || i.deadline || 0).getTime();
+            const open = String(i.status || 'OPEN').toUpperCase() === 'OPEN';
+            return d > 0 && d < startOfDay.getTime() && open;
+          });
+        }
       }
 
       filtered.sort((a, b) => {
-        const dateA = a.startTime || a.date || a.deadline || a.createdAt || 0;
-        const dateB = b.startTime || b.date || b.deadline || b.createdAt || 0;
+        const dateA = a.date || a.startTime || a.deadline || a.createdAt || 0;
+        const dateB = b.date || b.startTime || b.deadline || b.createdAt || 0;
         return new Date(dateA) - new Date(dateB);
       });
 
       const mainItems = combined.filter(i => !discardedItems.includes(i.id));
       const discarded = combined.filter(i => discardedItems.includes(i.id));
 
+      const startOfDay = new Date(); startOfDay.setHours(0, 0, 0, 0);
+      const endOfDay = new Date(); endOfDay.setHours(23, 59, 59, 999);
+      const now = Date.now();
+      const itemTs = (i) => new Date(i.date || i.startTime || i.deadline || 0).getTime();
       return {
         items: filtered,
         counts: {
@@ -219,7 +269,10 @@ module.exports = ({ cooler }) => {
           reports: mainItems.filter(i => i.type === 'report').length,
           jobs: mainItems.filter(i => i.type === 'job').length,
           projects: mainItems.filter(i => i.type === 'project').length,
-          calendars: mainItems.filter(i => i.type === 'calendar').length,
+          calendars: mainItems.filter(i => i.type === 'calendar' || i.type === 'calendarDate').length,
+          today: mainItems.filter(i => { const d = itemTs(i); return d >= startOfDay.getTime() && d <= endOfDay.getTime(); }).length,
+          upcoming: mainItems.filter(i => itemTs(i) > now).length,
+          overdue: mainItems.filter(i => { const d = itemTs(i); return d > 0 && d < startOfDay.getTime() && String(i.status || 'OPEN').toUpperCase() === 'OPEN'; }).length,
           discarded: discarded.length
         }
       };

+ 2 - 0
src/models/audios_model.js

@@ -241,6 +241,8 @@ module.exports = ({ cooler }) => {
       else if (filter === "recent") list = list.filter((a) => new Date(a.createdAt).getTime() >= now - 86400000);
       else if (filter === "top") {
         list = list.slice().sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt));
+      } else if (filter === "blockchain") {
+        list = list.filter((a) => safeArr(a.tags).some((t) => String(t).toLowerCase() === "blockchain"));
       }
 
       if (q) {

+ 62 - 4
src/models/banking_model.js

@@ -23,12 +23,33 @@ const STORAGE_DIR = path.join(__dirname, "..", "configs");
 const EPOCHS_PATH = path.join(STORAGE_DIR, "banking-epochs.json");
 const TRANSFERS_PATH = path.join(STORAGE_DIR, "banking-allocations.json");
 const ADDR_PATH = path.join(STORAGE_DIR, "wallet-addresses.json");
+const ECO_HISTORY_PATH = path.join(STORAGE_DIR, "banking-eco-history.json");
+const ECO_HISTORY_MAX = 500;
+const ECO_HISTORY_MIN_GAP_MS = 5 * 60 * 1000;
 
 function ensureStoreFiles() {
   if (!fs.existsSync(STORAGE_DIR)) fs.mkdirSync(STORAGE_DIR, { recursive: true });
   if (!fs.existsSync(EPOCHS_PATH)) fs.writeFileSync(EPOCHS_PATH, "[]");
   if (!fs.existsSync(TRANSFERS_PATH)) fs.writeFileSync(TRANSFERS_PATH, "[]");
   if (!fs.existsSync(ADDR_PATH)) fs.writeFileSync(ADDR_PATH, "{}");
+  if (!fs.existsSync(ECO_HISTORY_PATH)) fs.writeFileSync(ECO_HISTORY_PATH, "[]");
+}
+
+function readEcoHistory() {
+  ensureStoreFiles();
+  try { return JSON.parse(fs.readFileSync(ECO_HISTORY_PATH, "utf8")) || []; } catch (_) { return []; }
+}
+
+function appendEcoHistory(sample) {
+  ensureStoreFiles();
+  let arr = readEcoHistory();
+  if (!Array.isArray(arr)) arr = [];
+  const last = arr[arr.length - 1];
+  if (last && Number(sample.ts) - Number(last.ts) < ECO_HISTORY_MIN_GAP_MS) return arr;
+  arr.push(sample);
+  if (arr.length > ECO_HISTORY_MAX) arr = arr.slice(arr.length - ECO_HISTORY_MAX);
+  try { fs.writeFileSync(ECO_HISTORY_PATH, JSON.stringify(arr)); } catch (_) {}
+  return arr;
 }
 
 function epochIdNow() {
@@ -114,6 +135,8 @@ async function rpcCall(method, params, kind = "user") {
   if (cfg.user || cfg.pass) {
     headers.authorization = "Basic " + Buffer.from(`${cfg.user}:${cfg.pass}`).toString("base64");
   }
+  const controller = new AbortController();
+  const timer = setTimeout(() => controller.abort(), 1500);
   try {
     const res = await fetch(cfg.url, {
       method: "POST",
@@ -124,6 +147,7 @@ async function rpcCall(method, params, kind = "user") {
         method: method,
         params: params,
       }),
+      signal: controller.signal,
     });
     if (!res.ok) {
       return null;
@@ -135,6 +159,8 @@ async function rpcCall(method, params, kind = "user") {
     return data.result;
   } catch (err) {
     return null;
+  } finally {
+    clearTimeout(timer);
   }
 }
 
@@ -543,11 +569,28 @@ function scoreFromActions(actions) {
   return Math.max(0, Math.round(score));
 }
 
+async function getCarbonGramsForUser(userId) {
+  const ssb = await openSsb();
+  if (!ssb || !userId) return 0;
+  return new Promise((resolve) => {
+    let bytes = 0;
+    pull(
+      ssb.createUserStream({ id: userId }),
+      pull.drain(
+        (m) => { try { bytes += Buffer.byteLength(JSON.stringify(m && m.value), 'utf8'); } catch (_) {} },
+        () => resolve((bytes / (1024 * 1024)) * 0.095)
+      )
+    );
+  });
+}
+
 async function getUserEngagementScore(userId) {
   const ssb = await openSsb();
   const uid = resolveUserId(userId);
   const actions = await fetchUserActions(uid);
-  const karmaScore = scoreFromActions(actions);
+  const rawKarma = scoreFromActions(actions);
+  const carbonGrams = await getCarbonGramsForUser(uid).catch(() => 0);
+  const karmaScore = Math.max(0, Math.round(rawKarma - carbonGrams));
 
   const prev = await getLastKarmaScore(uid);
   const lastPublishedTimestamp = await getLastPublishedTimestamp(uid);
@@ -843,7 +886,9 @@ async function getLastPublishedTimestamp(userId) {
     try { computed = await computeEpoch({ epochId, userId: uid, rules: DEFAULT_RULES }); } catch {}
     const pv = computePoolVars(pubBalance, DEFAULT_RULES);
     const actions = await fetchUserActions(uid);
-    const engagementScore = scoreFromActions(actions);
+    const rawScore = scoreFromActions(actions);
+    const carbonGramsForScore = await getCarbonGramsForUser(uid).catch(() => 0);
+    const engagementScore = Math.max(0, Math.round(rawScore - carbonGramsForScore));
     const poolForEpoch = computed?.epoch?.pool || pv.pool || 0;
     const futureUBI = Number(((engagementScore / 100) * poolForEpoch).toFixed(6));
     const addresses = await listAddressesMerged();
@@ -865,7 +910,8 @@ async function getLastPublishedTimestamp(userId) {
       ubiAvailability: ubiAvailable ? "OK" : "NO_FUNDS"
     };
     const exchange = await calculateEcoinValue();
-    return { summary, allocations, epochs, rules: DEFAULT_RULES, addresses, exchange };
+    const exchangeHistory = readEcoHistory();
+    return { summary, allocations, epochs, rules: DEFAULT_RULES, addresses, exchange, exchangeHistory };
   }
 
   async function getAllocationById(id) {
@@ -933,7 +979,7 @@ async function getLastPublishedTimestamp(userId) {
     const annualIssuance = ecoValuePerHour * 24 * 365;
     const inflationFactor = circulatingSupply > 0 ? (annualIssuance / circulatingSupply) * 100 : 0;
     const inflationMonthly = inflationFactor / 12;
-    return {
+    const result = {
       ecoValue: Number(ecoValuePerHour.toFixed(6)),
       ecoTimeMs: Number(ecoTimeMs.toFixed(3)),
       totalSupply,
@@ -942,6 +988,18 @@ async function getLastPublishedTimestamp(userId) {
       currentSupply: circulatingSupply,
       isSynced
     };
+    if (isSynced) {
+      appendEcoHistory({
+        ts: Date.now(),
+        ecoValue: result.ecoValue,
+        currentSupply: result.currentSupply,
+        inflationFactor: result.inflationFactor,
+        blockValueEco: Number(blockValueEco.toFixed(8)),
+        avgBlockSec: Number((avgSec || 0).toFixed(2)),
+        blocks
+      });
+    }
+    return result;
   }
 
   async function getBankingData(userId) {

+ 58 - 6
src/models/blockchain_model.js

@@ -1,9 +1,9 @@
 const pull = require('../server/node_modules/pull-stream');
-const { config } = require('../server/SSB_server.js');
+const config = require('../server/ssb_config');
 const { getConfig } = require('../configs/config-manager.js');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
-module.exports = ({ cooler }) => {
+module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
   let ssb;
 
   const openSsb = async () => {
@@ -11,6 +11,32 @@ module.exports = ({ cooler }) => {
     return ssb;
   };
 
+  const HIDDEN_ENVELOPE_TYPES = new Set([
+    'tribe-keys-distrib',
+    'tribe-invite-msg',
+    'tribe-invite-tombstone'
+  ]);
+
+  const isHiddenBoxedContent = (rawContent) =>
+    typeof rawContent === 'string' && rawContent.endsWith('.box');
+
+  const buildAccessibleTribeIds = async () => {
+    const set = new Set();
+    if (!tribesModel) return set;
+    try {
+      const list = await tribesModel.listAll();
+      for (const t of list) {
+        if (!t || !t.id) continue;
+        set.add(t.id);
+        try {
+          const chain = await tribesModel.getChainIds(t.id);
+          for (const cid of chain) set.add(cid);
+        } catch (_) {}
+      }
+    } catch (_) {}
+    return set;
+  };
+
   const hasBlob = async (ssbClient, url) =>
     new Promise(resolve => ssbClient.blobs.has(url, (err, has) => resolve(!err && has)));
 
@@ -119,10 +145,13 @@ module.exports = ({ cooler }) => {
 
       const showLogs = (filter === 'logs' || filter === 'LOGS');
       const me = userId || config.keys.id;
+      const fpIdx = tribeCrypto ? tribeCrypto.buildFingerprintIndex() : null;
+      const accessibleTribeIds = await buildAccessibleTribeIds();
       for (const msg of results) {
         const k = msg.key;
         let c = msg.value?.content;
         const author = msg.value?.author;
+        if (isHiddenBoxedContent(c)) continue;
         if (showLogs && typeof c === 'string' && author === me) {
           try {
             const dec = ssbClient.private.unbox({ key: k, value: msg.value, timestamp: msg.timestamp || msg.value?.timestamp || 0 });
@@ -130,6 +159,16 @@ module.exports = ({ cooler }) => {
           } catch { c = null; }
         }
         if (!c?.type) continue;
+        if (HIDDEN_ENVELOPE_TYPES.has(c.type)) continue;
+        if (tribeCrypto && tribeCrypto.isTribeMsg(c)) {
+          const r = fpIdx ? tribeCrypto.unwrapMsg(c, fpIdx) : null;
+          if (!r || !r.body) continue;
+          const inner = r.body;
+          const innerType = inner.k === 'tribe' ? 'tribe' : (inner.k === 'tribe-content' ? `tribe-content:${inner.contentType || ''}` : inner.k || 'tribe-msg');
+          c = { type: innerType, _decrypted: true, _rootId: r.rootId, ...inner };
+        } else if (c.tribeId && !accessibleTribeIds.has(c.tribeId)) {
+          continue;
+        }
 
         if (c.type === 'about') {
           const aboutId = String(c.about || author || '').trim();
@@ -139,11 +178,11 @@ module.exports = ({ cooler }) => {
 
         if (c.type === 'tombstone' && c.target) {
           tombstoned.add(c.target);
-          idToBlock.set(k, { id: k, author, ts: msg.value.timestamp, type: c.type, content: c });
+          idToBlock.set(k, { id: k, author, ts: msg.value.timestamp, type: c.type, content: c, size: Buffer.byteLength(JSON.stringify(msg.value), 'utf8') });
           continue;
         }
         if (c.replaces) referencedAsReplaces.add(c.replaces);
-        idToBlock.set(k, { id: k, author, ts: msg.value.timestamp, type: c.type, content: c });
+        idToBlock.set(k, { id: k, author, ts: msg.value.timestamp, type: c.type, content: c, size: Buffer.byteLength(JSON.stringify(msg.value), 'utf8') });
       }
 
       const tipBlocks = [];
@@ -265,11 +304,14 @@ module.exports = ({ cooler }) => {
       const tombstoned = new Set();
       const idToBlock = new Map();
       const referencedAsReplaces = new Set();
+      const fpIdx = tribeCrypto ? tribeCrypto.buildFingerprintIndex() : null;
+      const accessibleTribeIds = await buildAccessibleTribeIds();
 
       for (const msg of results) {
         const k = msg.key;
         let c = msg.value?.content;
         const author = msg.value?.author;
+        if (isHiddenBoxedContent(c)) continue;
         if (typeof c === 'string' && author === me) {
           try {
             const dec = ssbClient.private.unbox({ key: k, value: msg.value, timestamp: msg.timestamp || msg.value?.timestamp || 0 });
@@ -277,13 +319,23 @@ module.exports = ({ cooler }) => {
           } catch { c = null; }
         }
         if (!c?.type) continue;
+        if (HIDDEN_ENVELOPE_TYPES.has(c.type)) continue;
+        if (tribeCrypto && tribeCrypto.isTribeMsg(c)) {
+          const r = fpIdx ? tribeCrypto.unwrapMsg(c, fpIdx) : null;
+          if (!r || !r.body) continue;
+          const inner = r.body;
+          const innerType = inner.k === 'tribe' ? 'tribe' : (inner.k === 'tribe-content' ? `tribe-content:${inner.contentType || ''}` : inner.k || 'tribe-msg');
+          c = { type: innerType, _decrypted: true, _rootId: r.rootId, ...inner };
+        } else if (c.tribeId && !accessibleTribeIds.has(c.tribeId)) {
+          continue;
+        }
         if (c.type === 'tombstone' && c.target) {
           tombstoned.add(c.target);
-          idToBlock.set(k, { id: k, author, ts: msg.value.timestamp, type: c.type, content: c });
+          idToBlock.set(k, { id: k, author, ts: msg.value.timestamp, type: c.type, content: c, size: Buffer.byteLength(JSON.stringify(msg.value), 'utf8') });
           continue;
         }
         if (c.replaces) referencedAsReplaces.add(c.replaces);
-        idToBlock.set(k, { id: k, author, ts: msg.value.timestamp, type: c.type, content: c });
+        idToBlock.set(k, { id: k, author, ts: msg.value.timestamp, type: c.type, content: c, size: Buffer.byteLength(JSON.stringify(msg.value), 'utf8') });
       }
 
       const tipBlocks = [];

+ 30 - 12
src/models/calendars_model.js

@@ -32,10 +32,19 @@ const expandRecurrence = (firstDate, deadline, weekly, monthly, yearly) => {
   return out.sort((a, b) => a.getTime() - b.getTime())
 }
 
-module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
+module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel }) => {
   let ssb
   const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
 
+  const ownCrypto = calendarCrypto || tribeCrypto
+  const lookupKey = (rid) => (ownCrypto && ownCrypto.getKey(rid)) || (tribeCrypto && tribeCrypto.getKey(rid)) || null
+  const lookupKeys = (rid) => {
+    const a = (ownCrypto && ownCrypto.getKeys(rid)) || []
+    if (a.length) return a
+    return (tribeCrypto && tribeCrypto.getKeys(rid)) || []
+  }
+  const lookupGen = (rid) => ((ownCrypto && ownCrypto.getGen(rid)) || (tribeCrypto && tribeCrypto.getGen(rid)) || 0)
+
   const readAll = async (ssbClient) =>
     new Promise((resolve, reject) =>
       pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => err ? reject(err) : resolve(msgs)))
@@ -48,7 +57,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
 
   const encryptStandalone = (content, rootId) => {
     if (!tribeCrypto || !rootId) return content
-    const key = tribeCrypto.getKey(rootId)
+    const key = lookupKey(rootId)
     if (!key) return content
     return tribeCrypto.encryptContent(content, [key], true)
   }
@@ -56,7 +65,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
   const decryptCalendarRoot = (content, rootId) => {
     if (!content || !content.encryptedPayload) return content
     if (!tribeCrypto) return content
-    const keys = tribeCrypto.getKeys(rootId)
+    const keys = lookupKeys(rootId)
     if (!keys || !keys.length) return { ...content, _undecryptable: true }
     return tribeCrypto.decryptContent(content, keys.map(k => [k]))
   }
@@ -204,7 +213,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
       if (tribeId) {
         content = await encryptIfTribe(plainContent)
       } else if (tribeCrypto) {
-        calKey = tribeCrypto.generateTribeKey()
+        calKey = ownCrypto.generateTribeKey()
         content = tribeCrypto.encryptContent(plainContent, [calKey], true)
       }
 
@@ -215,7 +224,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
       const calendarId = calMsg.key
 
       if (calKey && tribeCrypto) {
-        tribeCrypto.setKey(calendarId, calKey, 1)
+        ownCrypto.setKey(calendarId, calKey, 1)
         try {
           const ssbKeys = require("../server/node_modules/ssb-keys")
           const boxedKey = tribeCrypto.boxKeyForMember(calKey, userId, ssbKeys)
@@ -515,10 +524,19 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
         if (!c || c.type !== "calendarDate") continue
         if (c.calendarId !== rootId) continue
         let dec = c
-        if (c.encryptedPayload && tribeCrypto && tribesModel) {
-          const r = await tribeCrypto.decryptFromTribe(c, tribesModel)
-          dec = r && !r._undecryptable ? r : c
-          if (r && r._undecryptable) continue
+        if (c.encryptedPayload && tribeCrypto) {
+          if (c.tribeId && tribesModel) {
+            const r = await tribeCrypto.decryptFromTribe(c, tribesModel)
+            dec = r && !r._undecryptable ? r : c
+            if (r && r._undecryptable) continue
+          } else {
+            const keys = lookupKeys(c.calendarId)
+            if (keys && keys.length) {
+              const r = tribeCrypto.decryptContent(c, keys.map(k => [k]))
+              dec = r && !r._undecryptable ? r : c
+              if (r && r._undecryptable) continue
+            }
+          }
         }
         const baseEntry = {
           key: m.key,
@@ -781,7 +799,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
       let invite = code
       if (tribeCrypto && !cal.tribeId) {
         const ekChain = tribeCrypto.encryptChainForInvite([cal.rootId], code)
-        if (ekChain) invite = { code, ekChain, gen: tribeCrypto.getGen(cal.rootId) }
+        if (ekChain) invite = { code, ekChain, gen: lookupGen(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)))
@@ -843,7 +861,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
           }
         } else if (matchedInvite.ek) {
           calKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code)
-          tribeCrypto.setKey(matched.rootId, calKey, matchedInvite.gen || 1)
+          ownCrypto.setKey(matched.rootId, calKey, matchedInvite.gen || 1)
         }
       }
       const tipId = await this.resolveCurrentId(matched.rootId)
@@ -887,7 +905,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
           }
           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())
+              ssbClient.publish({ type: "tribe-keys", tribeId: matched.rootId, generation: lookupGen(matched.rootId) || 1, memberKeys }, () => resolve())
             })
           }
         } catch (_) {}

+ 21 - 12
src/models/chats_model.js

@@ -14,10 +14,19 @@ const normalizeTags = (raw) => {
 const INVITE_CODE_BYTES = 16
 const VALID_STATUS = ["OPEN", "INVITE-ONLY", "CLOSED"]
 
-module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
+module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
   let ssb
   const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
 
+  const ownCrypto = chatCrypto || tribeCrypto
+  const lookupKey = (rid) => (ownCrypto && ownCrypto.getKey(rid)) || (tribeCrypto && tribeCrypto.getKey(rid)) || null
+  const lookupKeys = (rid) => {
+    const a = (ownCrypto && ownCrypto.getKeys(rid)) || []
+    if (a.length) return a
+    return (tribeCrypto && tribeCrypto.getKeys(rid)) || []
+  }
+  const lookupGen = (rid) => ((ownCrypto && ownCrypto.getGen(rid)) || (tribeCrypto && tribeCrypto.getGen(rid)) || 0)
+
   const getTribeKeysFor = async (tribeId) => {
     if (!tribeCrypto || !tribesModel || !tribeId) return []
     try {
@@ -79,7 +88,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
 
   const resolveKeyChainSets = (chatRootId) => {
     if (!tribeCrypto) return []
-    const keys = tribeCrypto.getKeys(chatRootId)
+    const keys = lookupKeys(chatRootId)
     return keys.map(k => [k])
   }
 
@@ -118,7 +127,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
 
     let text = c.text || ""
     if (tribeCrypto && c.encryptedText) {
-      const candidateKeys = [...tribeKeys, ...tribeCrypto.getKeys(chatRootId)]
+      const candidateKeys = [...tribeKeys, ...lookupKeys(chatRootId)]
       for (const keyHex of candidateKeys) {
         try {
           text = tribeCrypto.decryptWithKey(c.encryptedText, keyHex)
@@ -214,7 +223,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
         })
       }
 
-      const chatKey = tribeCrypto.generateTribeKey()
+      const chatKey = ownCrypto.generateTribeKey()
       if (st === "OPEN") {
         const code = crypto.randomBytes(INVITE_CODE_BYTES).toString("hex")
         const ek = tribeCrypto.encryptForInvite(chatKey, code)
@@ -224,7 +233,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
       const result = await new Promise((resolve, reject) => {
         ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
       })
-      tribeCrypto.setKey(result.key, chatKey, 1)
+      ownCrypto.setKey(result.key, chatKey, 1)
       try {
         const ssbKeys = require("../server/node_modules/ssb-keys")
         const boxedKey = tribeCrypto.boxKeyForMember(chatKey, userId, ssbKeys)
@@ -285,7 +294,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
             if (chain.length) updated = tribeCrypto.encryptContent(updated, chain, true)
           } catch (_) {}
         } else {
-          const chatKey = tribeCrypto.getKey(rootId)
+          const chatKey = lookupKey(rootId)
           if (chatKey) updated = tribeCrypto.encryptContent(updated, [chatKey], true)
         }
       }
@@ -423,12 +432,12 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
       if (tribeCrypto) {
         const ekChain = tribeCrypto.encryptChainForInvite([chat.rootId], code)
         if (ekChain) {
-          invite = { code, ekChain, gen: tribeCrypto.getGen(chat.rootId) }
+          invite = { code, ekChain, gen: lookupGen(chat.rootId) }
         } else {
-          const chatKey = tribeCrypto.getKey(chat.rootId)
+          const chatKey = lookupKey(chat.rootId)
           if (chatKey) {
             const ek = tribeCrypto.encryptForInvite(chatKey, code)
-            invite = { code, ek, gen: tribeCrypto.getGen(chat.rootId) }
+            invite = { code, ek, gen: lookupGen(chat.rootId) }
           }
         }
       }
@@ -484,7 +493,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
           }
         } else if (matchedInvite.ek) {
           chatKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code)
-          tribeCrypto.setKey(matchedChat.rootId, chatKey, matchedInvite.gen || 1)
+          ownCrypto.setKey(matchedChat.rootId, chatKey, matchedInvite.gen || 1)
         }
       }
 
@@ -507,7 +516,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
           }
           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())
+              ssbClient.publish({ type: "tribe-keys", tribeId: matchedChat.rootId, generation: lookupGen(matchedChat.rootId) || 1, memberKeys }, () => resolve())
             })
           }
         } catch (_) {}
@@ -575,7 +584,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
       if (tribeCrypto) {
         let encKey = null
         if (chat.tribeId) encKey = await getTribeFirstKeyFor(chat.tribeId)
-        if (!encKey) encKey = tribeCrypto.getKey(chat.rootId)
+        if (!encKey) encKey = lookupKey(chat.rootId)
         if (encKey) {
           content.encryptedText = tribeCrypto.encryptWithKey(safeText(text), encKey)
           if (chat.tribeId) content.tribeId = chat.tribeId

+ 538 - 0
src/models/crypto.js

@@ -0,0 +1,538 @@
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+
+const SENSITIVE_FIELDS = [
+  'title', 'description', 'location', 'price', 'salary', 'options', 'votes',
+  'category', 'tags', 'image', 'url', 'attendees', 'assignees', 'deadline',
+  'goal', 'funded', 'refeeds', 'refeeds_inhabitants', 'opinions',
+  'opinions_inhabitants', 'status', 'priority', 'date', 'mediaType'
+];
+
+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_LEGACY = 'SolarNET.HuB';
+const INVITE_SCRYPT = { N: 131072, r: 8, p: 1, maxmem: 256 * 1024 * 1024 };
+
+const FP_INFO = Buffer.from('v1-fp', 'utf8');
+const ENVELOPE_TYPE = 'tribe-msg';
+const ENVELOPE_VERSION = 1;
+const KEY_DISTRIB_TYPE = 'tribe-keys-distrib';
+const KEY_DISTRIB_BATCH = 7;
+
+module.exports = (configPath, namespace = 'tribes') => {
+  const keysDir = path.join(configPath, 'keys');
+  try { fs.mkdirSync(keysDir, { recursive: true, mode: 0o700 }); } catch (_) {}
+  const keyringPath = path.join(keysDir, `${namespace}-keys.json`);
+
+  if (namespace === 'tribes') {
+    const legacyPath = path.join(configPath, 'tribe-keys.json');
+    try {
+      if (fs.existsSync(legacyPath) && !fs.existsSync(keyringPath)) {
+        fs.renameSync(legacyPath, keyringPath);
+        try { fs.chmodSync(keyringPath, 0o600); } catch (_) {}
+      }
+    } catch (_) {}
+  }
+
+  let keyring = {};
+
+  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 = {};
+    }
+    return keyring;
+  };
+
+  const saveKeyring = () => {
+    const tmp = keyringPath + '.tmp.' + process.pid + '.' + Date.now();
+    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');
+
+  const getKey = (rid) => {
+    const e = keyring[rid];
+    return e && Array.isArray(e.keys) && e.keys[0] ? e.keys[0] : null;
+  };
+
+  const getKeys = (rid) => {
+    const e = keyring[rid];
+    return e && Array.isArray(e.keys) ? e.keys : [];
+  };
+
+  const getGen = (rid) => {
+    const e = keyring[rid];
+    return e ? e.gen || 1 : 0;
+  };
+
+  const getAllRootIds = () => Object.keys(keyring);
+
+  const setKey = (rid, kHex, gen) => {
+    keyring[rid] = { keys: [kHex], gen: gen || 1 };
+    saveKeyring();
+  };
+
+  const setKeys = (rid, ks, topGen) => {
+    if (!Array.isArray(ks) || !ks.length) return;
+    const seen = new Set();
+    const dedup = [];
+    for (const k of ks) if (k && !seen.has(k)) { seen.add(k); dedup.push(k); }
+    keyring[rid] = { keys: dedup, gen: topGen || dedup.length };
+    saveKeyring();
+  };
+
+  const mergeKeys = (rid, incoming, topGen) => {
+    const e = keyring[rid] || { keys: [], gen: 0 };
+    const seen = new Set(e.keys);
+    const merged = [...e.keys];
+    for (const k of incoming) if (k && !seen.has(k)) { seen.add(k); merged.push(k); }
+    keyring[rid] = { keys: merged, gen: Math.max(e.gen || 0, topGen || merged.length) };
+    saveKeyring();
+    return keyring[rid].gen;
+  };
+
+  const addNewKey = (rid, kHex) => {
+    const e = keyring[rid] || { keys: [], gen: 0 };
+    if (e.keys.includes(kHex)) return e.gen;
+    e.keys.unshift(kHex);
+    e.gen = (e.gen || 0) + 1;
+    keyring[rid] = e;
+    saveKeyring();
+    return e.gen;
+  };
+
+  const dropKey = (rid) => {
+    if (keyring[rid]) {
+      delete keyring[rid];
+      saveKeyring();
+    }
+  };
+
+  const fingerprint = (kHex) =>
+    crypto.createHmac('sha256', Buffer.from(kHex, 'hex')).update(FP_INFO).digest('hex').slice(0, 32);
+
+  const buildFingerprintIndex = () => {
+    const m = new Map();
+    for (const [rid, e] of Object.entries(keyring)) {
+      if (!e || !Array.isArray(e.keys)) continue;
+      for (let i = 0; i < e.keys.length; i++) {
+        const k = e.keys[i];
+        if (!k) continue;
+        m.set(fingerprint(k), { rootId: rid, keyHex: k, isCurrent: i === 0 });
+      }
+    }
+    return m;
+  };
+
+  const buildAad = (fp) => Buffer.from(`${ENVELOPE_TYPE}|v${ENVELOPE_VERSION}|${fp}`, 'utf8');
+
+  const encryptWithKey = (plaintext, kHex, aad) => {
+    const key = Buffer.from(kHex, '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 tag = cipher.getAuthTag();
+    return iv.toString('hex') + tag.toString('hex') + enc.toString('hex');
+  };
+
+  const decryptWithKey = (encrypted, kHex, aad) => {
+    const key = Buffer.from(kHex, 'hex');
+    const iv = Buffer.from(encrypted.slice(0, 24), 'hex');
+    const tag = Buffer.from(encrypted.slice(24, 56), 'hex');
+    const ct = Buffer.from(encrypted.slice(56), 'hex');
+    const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
+    if (aad) decipher.setAAD(aad);
+    decipher.setAuthTag(tag);
+    return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
+  };
+
+  const isTribeMsg = (c) =>
+    !!c && c.type === ENVELOPE_TYPE && c.v === ENVELOPE_VERSION && typeof c.fp === 'string' && typeof c.p === 'string';
+
+  const wrapMsg = (body, kHex) => {
+    if (!kHex) throw new Error('wrapMsg: missing key');
+    const fp = fingerprint(kHex);
+    const aad = buildAad(fp);
+    const p = encryptWithKey(JSON.stringify(body), kHex, aad);
+    return { type: ENVELOPE_TYPE, v: ENVELOPE_VERSION, fp, p };
+  };
+
+  const unwrapMsg = (envelope, fpIdx) => {
+    if (!isTribeMsg(envelope)) return null;
+    const aad = buildAad(envelope.fp);
+    const tryKey = (rootId, keyHex) => {
+      try {
+        const pt = decryptWithKey(envelope.p, keyHex, aad);
+        return { body: JSON.parse(pt), rootId, keyHex };
+      } catch (_) { return null; }
+    };
+    const entry = fpIdx.get(envelope.fp);
+    if (entry) {
+      const r = tryKey(entry.rootId, entry.keyHex);
+      if (r) return r;
+      for (const [, e] of fpIdx) {
+        if (e.rootId !== entry.rootId || e.keyHex === entry.keyHex) continue;
+        const r2 = tryKey(e.rootId, e.keyHex);
+        if (r2) return r2;
+      }
+      return null;
+    }
+    for (const [, e] of fpIdx) {
+      const r = tryKey(e.rootId, e.keyHex);
+      if (r) return r;
+    }
+    return null;
+  };
+
+  const generateInviteSalt = () => crypto.randomBytes(16).toString('hex');
+
+  const deriveInviteKey = (code, salt) => {
+    const s = (salt === undefined || salt === null || salt === '') ? INVITE_SALT_LEGACY : salt;
+    return crypto.scryptSync(code, s, 32, INVITE_SCRYPT);
+  };
+
+  const hashInviteCode = (code, salt) => {
+    const s = (salt === undefined || salt === null || salt === '') ? INVITE_SALT_LEGACY : salt;
+    return crypto.createHmac('sha256', s).update(String(code), 'utf8').digest('hex');
+  };
+
+  const inviteAad = (code, salt) =>
+    Buffer.from(`tribe-invite|v1|${hashInviteCode(code, salt)}`, 'utf8');
+
+  const encryptForInvite = (tribeKeyHex, inviteCode, salt) => {
+    const derived = deriveInviteKey(inviteCode, salt);
+    return encryptWithKey(tribeKeyHex, derived.toString('hex'), inviteAad(inviteCode, salt));
+  };
+
+  const decryptFromInvite = (encryptedKey, inviteCode, salt) => {
+    const derived = deriveInviteKey(inviteCode, salt);
+    return decryptWithKey(encryptedKey, derived.toString('hex'), inviteAad(inviteCode, salt));
+  };
+
+  const encryptChainForInvite = (ancestryRootIds, code, salt) => {
+    const chain = ancestryRootIds.map(rid => ({ rootId: rid, keys: getKeys(rid), gen: getGen(rid) }));
+    if (chain.some(e => !Array.isArray(e.keys) || !e.keys.length)) return null;
+    const k = deriveInviteKey(code, salt);
+    return encryptWithKey(JSON.stringify(chain), k.toString('hex'), inviteAad(code, salt));
+  };
+
+  const decryptChainFromInvite = (encryptedPayload, code, salt) => {
+    const k = deriveInviteKey(code, salt);
+    try {
+      const json = decryptWithKey(encryptedPayload, k.toString('hex'), inviteAad(code, salt));
+      const parsed = JSON.parse(json);
+      if (Array.isArray(parsed) && parsed.every(e => e && e.rootId && Array.isArray(e.keys) && e.keys.length)) {
+        return parsed.map(e => ({
+          rootId: e.rootId,
+          keys: e.keys.slice(),
+          key: e.keys[0],
+          gen: e.gen || e.keys.length
+        }));
+      }
+    } catch (_) {}
+    return null;
+  };
+
+  const inviteMatchesCode = (inv, code) => {
+    if (!inv || typeof inv !== 'object' || !inv.codeHash) return false;
+    return inv.codeHash === hashInviteCode(code, inv.salt);
+  };
+
+  const buildKeyDistribPayload = (rootId, keys, gen) => ({
+    type: KEY_DISTRIB_TYPE,
+    rootId,
+    keys: Array.isArray(keys) ? keys.slice() : [],
+    gen: gen || (Array.isArray(keys) ? keys.length : 1),
+    distributedAt: new Date().toISOString()
+  });
+
+  const isKeyDistribContent = (decoded) =>
+    !!decoded && decoded.type === KEY_DISTRIB_TYPE && typeof decoded.rootId === 'string' &&
+    Array.isArray(decoded.keys) && decoded.keys.length > 0;
+
+  const tryUnboxKeyDistrib = (rawContent, localKeypair, ssbKeys) => {
+    if (typeof rawContent !== 'string' || !rawContent.endsWith('.box')) return null;
+    let decoded;
+    try { decoded = ssbKeys.unbox(rawContent, localKeypair); } catch (_) { return null; }
+    if (!decoded) return null;
+    if (typeof decoded === 'string') {
+      try { decoded = JSON.parse(decoded); } catch (_) { return null; }
+    }
+    return isKeyDistribContent(decoded) ? decoded : null;
+  };
+
+  const boxKeyForMember = (tribeKeyHex, memberFeedId, ssbKeys) =>
+    ssbKeys.box(tribeKeyHex, [memberFeedId]);
+
+  const unboxKeyFromMember = (boxed, localKeypair, ssbKeys) =>
+    ssbKeys.unbox(boxed, localKeypair);
+
+  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 encryptChain = (plaintext, keyChain, aad) => {
+    let data = plaintext;
+    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, aad) => {
+    const reversed = [...keyChain].reverse();
+    let data = encrypted;
+    for (let i = 0; i < reversed.length; i++) {
+      data = decryptWithKey(data, reversed[i], i === 0 ? aad : undefined);
+    }
+    return data;
+  };
+
+  const encryptContent = (content, keyChain, customFields) => {
+    const payload = {};
+    if (customFields) {
+      for (const [k, v] of Object.entries(content)) {
+        if (ENVELOPE_PRESERVE.has(k)) continue;
+        payload[k] = v;
+      }
+    } else {
+      for (const field of SENSITIVE_FIELDS) {
+        if (content[field] !== undefined) payload[field] = content[field];
+      }
+    }
+    const plaintext = JSON.stringify(payload);
+    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 || !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 (_) {}
+    }
+    return { ...content, _undecryptable: true };
+  };
+
+  const buildKeyChainSets = (ancestryRootIds) => {
+    if (!Array.isArray(ancestryRootIds) || ancestryRootIds.length === 0) return [];
+    if (ancestryRootIds.length === 1) {
+      const keys = getKeys(ancestryRootIds[0]);
+      return keys.map(k => [k]);
+    }
+    const ownKeys = getKeys(ancestryRootIds[0]);
+    const parentSets = buildKeyChainSets(ancestryRootIds.slice(1));
+    const sets = [];
+    for (const ownKey of ownKeys) {
+      for (const parentChain of parentSets) {
+        sets.push([ownKey, ...parentChain]);
+      }
+    }
+    return sets;
+  };
+
+  const resolveKeyChain = async (tribeId, tribesModel) => {
+    if (!tribeId || !tribesModel) return null;
+    let ancestryIds;
+    try { ancestryIds = await tribesModel.getAncestryChain(tribeId); } catch (_) { return null; }
+    if (!Array.isArray(ancestryIds) || !ancestryIds.length) return null;
+    const chain = [];
+    for (const rid of ancestryIds) {
+      const k = getKey(rid);
+      if (!k) return null;
+      chain.push(k);
+    }
+    return chain.length ? chain : null;
+  };
+
+  const resolveKeyChainSets = async (tribeId, tribesModel) => {
+    if (!tribeId || !tribesModel) return null;
+    let ancestryIds;
+    try { ancestryIds = await tribesModel.getAncestryChain(tribeId); } catch (_) { return null; }
+    if (!Array.isArray(ancestryIds) || !ancestryIds.length) return null;
+    return buildKeyChainSets(ancestryIds);
+  };
+
+  const encryptForTribe = async (content, tribeId, tribesModel) => {
+    const chain = await resolveKeyChain(tribeId, tribesModel);
+    if (!chain) throw new Error('Missing tribe key chain — cannot encrypt content for this tribe');
+    return encryptContent(content, chain, true);
+  };
+
+  const decryptFromTribe = async (content, tribesModel) => {
+    if (!content) return content;
+    if (isTribeMsg(content)) {
+      const fpIdx = buildFingerprintIndex();
+      const r = unwrapMsg(content, fpIdx);
+      if (!r) return { ...content, _undecryptable: true };
+      return { ...r.body, _decrypted: true };
+    }
+    if (!content.encryptedPayload) return content;
+    const tid = content.tribeId;
+    if (tid && tribesModel) {
+      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) => ({
+    async encryptIfTribe(content) {
+      if (!content || !content.tribeId || !tribesModel) return content;
+      try {
+        const rootId = await tribesModel.getRootId(content.tribeId);
+        const key = getKey(rootId);
+        if (!key) return content;
+        const body = { k: content.type, ...content };
+        return wrapMsg(body, key);
+      } catch (_) {
+        return content;
+      }
+    },
+    async decryptIfTribe(content) {
+      if (!content || !tribesModel) return content;
+      if (isTribeMsg(content)) {
+        const fpIdx = buildFingerprintIndex();
+        const r = unwrapMsg(content, fpIdx);
+        if (!r) return { ...content, _undecryptable: true };
+        const flat = { ...r.body, _decrypted: true, _rootId: r.rootId };
+        if (r.body.k === 'tombstone') flat.type = 'tombstone';
+        else if (r.body.k && !flat.type) flat.type = r.body.k;
+        delete flat.k;
+        return flat;
+      }
+      if (!content.encryptedPayload) return content;
+      return await decryptFromTribe(content, tribesModel);
+    },
+    assertReadable(decrypted, what) {
+      if (decrypted && decrypted._undecryptable) throw new Error(`${what} is tribe-encrypted and cannot be decrypted with available keys`);
+    },
+    async decryptIndexNodes(idx) {
+      if (!tribesModel) return;
+      for (const [k, n] of idx.nodes.entries()) {
+        if (!n.c) continue;
+        if (!n.c.encryptedPayload && !isTribeMsg(n.c)) continue;
+        const dec = await decryptFromTribe(n.c, tribesModel);
+        if (dec && !dec._undecryptable) {
+          idx.nodes.set(k, { ...n, c: { ...dec, _decrypted: true } });
+        } else {
+          idx.nodes.set(k, { ...n, c: { ...n.c, _decrypted: false } });
+        }
+      }
+    },
+    unwrapMessagesForKind(messages, kindOrKinds) {
+      const kinds = Array.isArray(kindOrKinds) ? kindOrKinds : [kindOrKinds];
+      const kSet = new Set(kinds);
+      const fpIdx = buildFingerprintIndex();
+      const out = [];
+      for (const m of messages) {
+        const c = m && m.value && m.value.content;
+        if (!c) continue;
+        if (!isTribeMsg(c)) { out.push(m); continue; }
+        const r = unwrapMsg(c, fpIdx);
+        if (!r || !r.body) continue;
+        const inner = r.body;
+        if (inner.k === 'tombstone' && inner.target) {
+          const flat = { type: 'tombstone', target: inner.target, deletedAt: inner.deletedAt, author: inner.author };
+          out.push({ ...m, value: { ...m.value, content: flat } });
+        } else if (kSet.has(inner.k)) {
+          const flat = { ...inner, type: inner.k, _decrypted: true, _rootId: r.rootId };
+          delete flat.k;
+          out.push({ ...m, value: { ...m.value, content: flat } });
+        }
+      }
+      return out;
+    },
+    async encryptTombstone(target, tribeId, author) {
+      const tombstone = { type: 'tombstone', target, deletedAt: new Date().toISOString(), author };
+      if (!tribeId || !tribesModel) return tombstone;
+      try {
+        const rootId = await tribesModel.getRootId(tribeId);
+        const key = getKey(rootId);
+        if (!key) return tombstone;
+        return wrapMsg({ k: 'tombstone', target, deletedAt: tombstone.deletedAt, author }, key);
+      } catch (_) {
+        return tombstone;
+      }
+    }
+  });
+
+  loadKeyring();
+
+  return {
+    SENSITIVE_FIELDS, ENVELOPE_PRESERVE,
+    ENVELOPE_TYPE, ENVELOPE_VERSION, KEY_DISTRIB_TYPE, KEY_DISTRIB_BATCH,
+    loadKeyring, saveKeyring,
+    generateTribeKey, getKey, getKeys, getGen, getAllRootIds,
+    setKey, setKeys, mergeKeys, addNewKey, dropKey,
+    fingerprint, buildFingerprintIndex,
+    isTribeMsg, wrapMsg, unwrapMsg,
+    encryptWithKey, decryptWithKey,
+    generateInviteSalt, hashInviteCode, deriveInviteKey,
+    encryptForInvite, decryptFromInvite,
+    encryptChainForInvite, decryptChainFromInvite, inviteMatchesCode,
+    buildKeyDistribPayload, isKeyDistribContent, tryUnboxKeyDistrib,
+    boxKeyForMember, unboxKeyFromMember,
+    canonicalAad, encryptChain, decryptChain,
+    encryptContent, decryptContent,
+    buildKeyChainSets, resolveKeyChain, resolveKeyChainSets,
+    encryptForTribe, decryptFromTribe,
+    createHelpers
+  };
+};

+ 8 - 1
src/models/cv_model.js

@@ -45,6 +45,7 @@ module.exports = ({ cooler }) => {
         location: data.location || 'UNKNOWN',
         status: data.status || 'LOOKING FOR WORK',
         preferences: data.preferences || 'REMOTE WORKING',
+        visibility: String(data.visibility || 'PUBLIC').toUpperCase() === 'HIDDEN' ? 'HIDDEN' : 'PUBLIC',
         createdAt: new Date().toISOString()
       };
       return new Promise((resolve, reject) => {
@@ -97,6 +98,9 @@ module.exports = ({ cooler }) => {
         location: data.location || 'UNKNOWN',
         status: data.status || 'LOOKING FOR WORK',
         preferences: data.preferences || 'REMOTE WORKING',
+        visibility: data.visibility !== undefined
+          ? (String(data.visibility).toUpperCase() === 'HIDDEN' ? 'HIDDEN' : 'PUBLIC')
+          : (old.content.visibility || 'PUBLIC'),
         createdAt: old.content.createdAt,
         updatedAt: new Date().toISOString()
       };
@@ -163,7 +167,10 @@ module.exports = ({ cooler }) => {
             }
 
             const latest = cvMsgs[0];
-            resolve({ id: latest.key, ...latest.value.content });
+            const c = latest.value.content;
+            const visibility = String(c.visibility || 'PUBLIC').toUpperCase() === 'HIDDEN' ? 'HIDDEN' : 'PUBLIC';
+            if (visibility === 'HIDDEN' && authorId !== userId) return resolve(null);
+            resolve({ id: latest.key, ...c, visibility });
           })
         );
       });

+ 12 - 6
src/models/events_model.js

@@ -1,14 +1,12 @@
 const pull = require('../server/node_modules/pull-stream');
 const moment = require('../server/node_modules/moment');
-const { config } = require('../server/SSB_server.js');
 const { getConfig } = require('../configs/config-manager.js');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
-const userId = config.keys.id;
-
 module.exports = ({ cooler }) => {
   let ssb;
   const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
+  const me = async () => (await openSsb()).id;
 
   const uniq = (arr) => Array.from(new Set((Array.isArray(arr) ? arr : []).filter(x => typeof x === 'string' && x.trim().length)));
 
@@ -40,8 +38,9 @@ module.exports = ({ cooler }) => {
   return {
     type: 'event',
 
-    async createEvent(title, description, date, location, price = 0, url = "", attendees = [], tagsRaw = [], isPublic, mapUrl = "") {
+    async createEvent(title, description, date, location, price = 0, url = "", attendees = [], tagsRaw = [], isPublic, mapUrl = "", clearnetPublic = false) {
       const ssbClient = await openSsb();
+      const userId = await me();
 
       const formattedDate = normalizeDate(date);
       if (moment(formattedDate).isBefore(moment().startOf('minute'))) throw new Error("Cannot create an event in the past");
@@ -68,7 +67,8 @@ module.exports = ({ cooler }) => {
         organizer: userId,
         status: 'OPEN',
         isPublic: normalizePrivacy(isPublic),
-        mapUrl: String(mapUrl || "").trim()
+        mapUrl: String(mapUrl || "").trim(),
+        clearnetPublic: clearnetPublic === true || clearnetPublic === 'true' || clearnetPublic === 'on'
       };
 
       return new Promise((resolve, reject) => {
@@ -78,6 +78,7 @@ module.exports = ({ cooler }) => {
 
     async toggleAttendee(eventId) {
       const ssbClient = await openSsb();
+      const userId = await me();
       const ev = await new Promise((res, rej) => ssbClient.get(eventId, (err, ev) => err || !ev || !ev.content ? rej(new Error("Error retrieving event")) : res(ev)));
       const c = ev.content;
 
@@ -103,6 +104,7 @@ module.exports = ({ cooler }) => {
 
     async deleteEventById(eventId) {
       const ssbClient = await openSsb();
+      const userId = await me();
       const ev = await new Promise((res, rej) => ssbClient.get(eventId, (err, ev) => err || !ev || !ev.content ? rej(new Error("Error retrieving event")) : res(ev)));
       if (ev.content.organizer !== userId) throw new Error("Only the organizer can delete this event");
       const tombstone = { type: 'tombstone', target: eventId, deletedAt: new Date().toISOString(), author: userId };
@@ -133,12 +135,14 @@ module.exports = ({ cooler }) => {
         organizer: c.organizer || '',
         status,
         isPublic: normalizePrivacy(c.isPublic),
-        mapUrl: c.mapUrl || ""
+        mapUrl: c.mapUrl || "",
+        clearnetPublic: !!c.clearnetPublic
       };
     },
 
     async updateEventById(eventId, updatedData) {
       const ssbClient = await openSsb();
+      const userId = await me();
       const ev = await new Promise((res, rej) => ssbClient.get(eventId, (err, ev) => err || !ev || !ev.content ? rej(new Error("Error retrieving event")) : res(ev)));
       if (ev.content.organizer !== userId) throw new Error("Only the organizer can update this event");
 
@@ -168,6 +172,7 @@ module.exports = ({ cooler }) => {
         url: updatedData.url ?? c.url,
         tags,
         isPublic: updatedData.isPublic !== undefined ? normalizePrivacy(updatedData.isPublic) : normalizePrivacy(c.isPublic),
+        clearnetPublic: updatedData.clearnetPublic !== undefined ? (updatedData.clearnetPublic === true || updatedData.clearnetPublic === 'true' || updatedData.clearnetPublic === 'on') : !!c.clearnetPublic,
         attendees: uniq(Array.isArray(c.attendees) ? c.attendees : []),
         updatedAt: new Date().toISOString(),
         replaces: eventId
@@ -180,6 +185,7 @@ module.exports = ({ cooler }) => {
 
     async listAll(author = null, filter = 'all') {
       const ssbClient = await openSsb();
+      const userId = await me();
       return new Promise((resolve, reject) => {
         pull(
           ssbClient.createLogStream({ limit: logLimit }),

+ 190 - 19
src/models/inhabitants_model.js

@@ -130,7 +130,7 @@ module.exports = ({ cooler }) => {
         return filterInactive(users);
       }
 
-      if (filter === 'all' || filter === 'TOP KARMA' || filter === 'TOP ACTIVITY') {
+      if (filter === 'all' || filter === 'TOP KARMA' || filter === 'TOP ACTIVITY' || filter === 'TOP ECO') {
         let users = await listAllBase(ssbClient);
         if (filter !== 'TOP ACTIVITY') {
           users = filterInactive(users);
@@ -143,12 +143,34 @@ module.exports = ({ cooler }) => {
             (u.id || '').toLowerCase().includes(q)
           );
         }
+        const bytesByAuthor = await new Promise((res) => {
+          pull(
+            ssbClient.createLogStream({ limit: logLimit }),
+            pull.collect((err, msgs) => {
+              if (err || !Array.isArray(msgs)) return res({});
+              const acc = {};
+              for (const m of msgs) {
+                const author = m && m.value && m.value.author;
+                if (!author) continue;
+                try { acc[author] = (acc[author] || 0) + Buffer.byteLength(JSON.stringify(m.value), 'utf8'); } catch (_) {}
+              }
+              res(acc);
+            })
+          );
+        });
         const withMetrics = await Promise.all(users.map(async u => {
           const karmaScore = await getLastKarmaScore(u.id);
-          return { ...u, karmaScore };
+          const bytes = (bytesByAuthor && bytesByAuthor[u.id]) || 0;
+          const carbonGrams = (bytes / (1024 * 1024)) * 0.095;
+          if (filter === 'TOP ECO') {
+            const ecoScore = karmaScore / Math.max(0.01, carbonGrams);
+            return { ...u, karmaScore, carbonGrams, ecoScore };
+          }
+          return { ...u, karmaScore, carbonGrams };
         }));
         if (filter === 'TOP KARMA') return withMetrics.sort((a, b) => (b.karmaScore || 0) - (a.karmaScore || 0));
         if (filter === 'TOP ACTIVITY') return withMetrics.sort((a, b) => (b.lastActivityTs || 0) - (a.lastActivityTs || 0));
+        if (filter === 'TOP ECO') return withMetrics.sort((a, b) => (b.ecoScore || 0) - (a.ecoScore || 0));
         return withMetrics;
       }
 
@@ -176,23 +198,60 @@ module.exports = ({ cooler }) => {
       if (filter === 'SUGGESTED') {
         const base = await listAllBase(ssbClient);
         const active = filterInactive(base);
+        const cvRecords = await new Promise((res) => {
+          pull(
+            ssbClient.createLogStream({ limit: logLimit, reverse: true }),
+            pull.filter(msg => msg && msg.value && msg.value.content && msg.value.content.type === 'curriculum'),
+            pull.collect((err, msgs) => err ? res([]) : res(msgs))
+          );
+        });
+        const cvByAuthor = new Map();
+        for (const r of cvRecords) {
+          const c = r.value && r.value.content;
+          if (c && c.author && !cvByAuthor.has(c.author)) cvByAuthor.set(c.author, c);
+        }
+        const extractSkills = (cv) => cv ? [
+          ...(cv.personalSkills || []),
+          ...(cv.oasisSkills || []),
+          ...(cv.educationalSkills || []),
+          ...(cv.professionalSkills || [])
+        ].map(s => String(s || '').toLowerCase()).filter(Boolean) : [];
+        const mecv = await this.getCVByUserId().catch(() => null);
+        const mySkills = extractSkills(mecv);
         const rels = await Promise.all(
           active.map(async u => {
             if (u.id === userId) return null;
             const rel = await friend.getRelationship(u.id).catch(() => ({}));
             const n = normalizeRel(rel);
+            if (n.iFollow || n.blocking || n.blockedBy) return null;
             const karmaScore = await getLastKarmaScore(u.id);
-            return { user: u, rel: n, karmaScore };
+            const theirSkills = extractSkills(cvByAuthor.get(u.id));
+            const commonSkills = mySkills.length && theirSkills.length
+              ? Array.from(new Set(mySkills.filter(s => theirSkills.includes(s))))
+              : [];
+            const followsMeBonus = n.followsMe ? 20 : 0;
+            const karmaBonus = Math.min(20, Math.log10(1 + Math.max(0, karmaScore)) * 5);
+            const skillBonus = commonSkills.length * 4;
+            const activityBonus = u.lastActivityBucket === 'green' ? 5 : (u.lastActivityBucket === 'orange' ? 2 : 0);
+            const suggestionScore = followsMeBonus + karmaBonus + skillBonus + activityBonus;
+            return { user: u, rel: n, karmaScore, commonSkills, suggestionScore };
           })
         );
-        const candidates = rels.filter(Boolean).filter(x => !x.rel.iFollow && !x.rel.blocking && !x.rel.blockedBy);
+        const candidates = rels.filter(Boolean).filter(x => x.suggestionScore > 0);
         const enriched = candidates.map(x => ({
           ...x.user,
           karmaScore: x.karmaScore,
-          mutualCount: x.rel.followsMe ? 1 : 0
+          followsYou: x.rel.followsMe,
+          commonSkills: x.commonSkills,
+          mutualCount: x.rel.followsMe ? 1 : 0,
+          suggestionScore: x.suggestionScore
         }));
         const unique = Array.from(new Map(enriched.map(u => [u.id, u])).values());
-        return unique.sort((a, b) => (b.karmaScore || 0) - (a.karmaScore || 0) || (b.lastActivityTs || 0) - (a.lastActivityTs || 0));
+        return unique.sort((a, b) =>
+          (b.suggestionScore || 0) - (a.suggestionScore || 0) ||
+          (b.karmaScore || 0) - (a.karmaScore || 0) ||
+          (b.lastActivityTs || 0) - (a.lastActivityTs || 0)
+        );
       }
 
       if (filter === 'CVs' || filter === 'MATCHSKILLS') {
@@ -242,27 +301,40 @@ module.exports = ({ cooler }) => {
             const lastActivityTs = await getLastActivityTimestamp(c.author);
             const { bucket, range } = bucketLastActivity(lastActivityTs);
             const norm = this._normalizeCurriculum(c, photo);
-            return { ...norm, lastActivityTs, lastActivityBucket: bucket, lastActivityRange: range };
+            const karmaScore = await getLastKarmaScore(c.author).catch(() => 0);
+            return { ...norm, lastActivityTs, lastActivityBucket: bucket, lastActivityRange: range, karmaScore };
           }));
           base = filterInactive(base);
           const mecv = await this.getCVByUserId();
-          const userSkills = mecv
-            ? [
-                ...(mecv.personalSkills || []),
-                ...(mecv.oasisSkills || []),
-                ...(mecv.educationalSkills || []),
-                ...(mecv.professionalSkills || [])
-              ].map(s => (s || '').toLowerCase())
-            : [];
+          const userSkills = Array.from(new Set(
+            (mecv
+              ? [
+                  ...(mecv.personalSkills || []),
+                  ...(mecv.oasisSkills || []),
+                  ...(mecv.educationalSkills || []),
+                  ...(mecv.professionalSkills || [])
+                ]
+              : []).map(s => String(s || '').toLowerCase()).filter(Boolean)
+          ));
           if (!userSkills.length) return [];
+          const userSet = new Set(userSkills);
           const matches = base.map(c => {
             if (c.id === userId) return null;
-            const common = c.skills.map(s => (s || '').toLowerCase()).filter(s => userSkills.includes(s));
+            const theirSkillsRaw = (c.skills || []).map(s => String(s || '').toLowerCase()).filter(Boolean);
+            const theirSet = new Set(theirSkillsRaw);
+            const common = Array.from(theirSet).filter(s => userSet.has(s));
             if (!common.length) return null;
-            const matchScore = common.length / userSkills.length;
-            return { ...c, commonSkills: common, matchScore };
+            const unionSize = userSet.size + theirSet.size - common.length;
+            const matchScore = unionSize > 0 ? common.length / unionSize : 0;
+            const matchCoverage = userSet.size > 0 ? common.length / userSet.size : 0;
+            return { ...c, commonSkills: common, matchScore, matchCoverage };
           }).filter(Boolean);
-          return matches.sort((a, b) => b.matchScore - a.matchScore);
+          return matches.sort((a, b) =>
+            (b.matchScore - a.matchScore) ||
+            (b.commonSkills.length - a.commonSkills.length) ||
+            ((b.karmaScore || 0) - (a.karmaScore || 0)) ||
+            ((b.lastActivityTs || 0) - (a.lastActivityTs || 0))
+          );
         }
       }
 
@@ -344,6 +416,105 @@ module.exports = ({ cooler }) => {
       return records.length ? records[records.length - 1].value.content : null;
     },
 
+    async getCandidatesForJob(job, viewerId = null) {
+      if (!job || typeof job !== 'object') return [];
+      const ssbClient = await openSsb();
+      const tokenize = (s) => String(s || '')
+        .toLowerCase()
+        .split(/[^a-z0-9áéíóúñü+#./-]+/i)
+        .map(t => t.trim())
+        .filter(t => t && t.length >= 2);
+      const stop = new Set(['the','a','an','and','or','of','to','in','for','on','with','is','are','be','as','at','by','from','that','this','it','we','you','our','your','un','una','el','la','los','las','de','del','en','con','para','por','y','o','un','una','que','se','su','sus','al','etc']);
+      const keywords = new Set();
+      const addAll = (arr) => arr.forEach(t => { if (!stop.has(t)) keywords.add(t); });
+      if (Array.isArray(job.tags)) job.tags.forEach(t => { const k = String(t || '').toLowerCase().trim(); if (k) keywords.add(k); });
+      addAll(tokenize(job.title));
+      addAll(tokenize(job.description));
+      addAll(tokenize(job.requirements));
+      if (keywords.size === 0) return [];
+
+      const records = await new Promise((res, rej) => {
+        pull(
+          ssbClient.createLogStream({ limit: logLimit, reverse: true }),
+          pull.filter(msg =>
+            msg.value?.content?.type === 'curriculum' &&
+            msg.value?.content?.type !== 'tombstone'
+          ),
+          pull.collect((err, msgs) => err ? rej(err) : res(msgs))
+        );
+      });
+      let cvs = records.map(r => r.value.content);
+      cvs = Array.from(new Map(cvs.map(u => [u.author, u])).values());
+      cvs = cvs.filter(c => String(c.visibility || 'PUBLIC').toUpperCase() !== 'HIDDEN');
+
+      const jobAuthor = job.author || null;
+      const out = await Promise.all(cvs.map(async c => {
+        if (!c.author) return null;
+        if (jobAuthor && c.author === jobAuthor) return null;
+        const cvSkills = [
+          ...(c.personalSkills || []),
+          ...(c.oasisSkills || []),
+          ...(c.educationalSkills || []),
+          ...(c.professionalSkills || [])
+        ].map(s => String(s || '').toLowerCase()).filter(Boolean);
+        const common = Array.from(new Set(cvSkills.filter(s => keywords.has(s))));
+        if (common.length === 0) return null;
+        if (viewerId && c.author !== viewerId) {
+          try {
+            const rel = await friend.getRelationship(c.author);
+            if (rel && (rel.blocking || rel.blockedBy)) return null;
+          } catch (_) {}
+        }
+        const authorTs = await getLastActivityTimestamp(c.author);
+        let interactionTs = null;
+        try {
+          const cvId = c.id || c.key || null;
+          if (cvId) {
+            const ssbClient = await openSsb();
+            interactionTs = await new Promise((resolve) => {
+              try {
+                pull(
+                  ssbClient.backlinks.read({ query: [{ $filter: { dest: cvId } }], index: 'DTA', reverse: true, limit: 1 }),
+                  pull.collect((err, arr) => {
+                    if (err || !arr || !arr.length) return resolve(null);
+                    const m = arr[0];
+                    const raw = (m.value && m.value.timestamp) || m.timestamp;
+                    resolve(raw && raw < 1e12 ? raw * 1000 : raw || null);
+                  })
+                );
+              } catch (_) { resolve(null); }
+            });
+          }
+        } catch (_) {}
+        const lastActivityTs = Math.max(authorTs || 0, interactionTs || 0) || null;
+        const { bucket, range } = bucketLastActivity(lastActivityTs);
+        if (bucket === 'red') return null;
+        const photo = await fetchUserImageUrl(c.author, 256);
+        const matchScore = common.length / keywords.size;
+        return {
+          id: c.author,
+          name: c.name || 'Anonymous',
+          description: c.description || '',
+          photo,
+          location: c.location || '',
+          status: c.status || '',
+          preferences: c.preferences || '',
+          languages: typeof c.languages === 'string'
+            ? c.languages.split(',').map(x => x.trim()).filter(Boolean)
+            : Array.isArray(c.languages) ? c.languages : [],
+          commonSkills: common,
+          matchScore,
+          lastActivityTs,
+          lastActivityBucket: bucket,
+          lastActivityRange: range
+        };
+      }));
+      return out.filter(Boolean).sort((a, b) => {
+        if (b.matchScore !== a.matchScore) return b.matchScore - a.matchScore;
+        return (b.lastActivityTs || 0) - (a.lastActivityTs || 0);
+      }).slice(0, 20);
+    },
+
     async getPhotoUrlByUserId(id, size = 256) {
       return await fetchUserImageUrl(id, size);
     },

+ 52 - 8
src/models/jobs_model.js

@@ -142,6 +142,8 @@ module.exports = ({ cooler, tribeCrypto }) => {
     const salaryN = toNum(c.salary)
     const salary = Number.isFinite(salaryN) ? salaryN.toFixed(6) : "0.000000"
 
+    const hoursOfferedN = toNum(c.hoursOffered)
+    const hoursRequestedN = toNum(c.hoursRequested)
     return {
       id: node.key,
       rootId,
@@ -155,6 +157,9 @@ module.exports = ({ cooler, tribeCrypto }) => {
       location: c.location,
       vacants,
       salary,
+      hoursOffered: Number.isFinite(hoursOfferedN) ? hoursOfferedN : 0,
+      hoursRequested: Number.isFinite(hoursRequestedN) ? hoursRequestedN : 0,
+      exchangeSkill: String(c.exchangeSkill || ""),
       image: blobId,
       author: c.author,
       createdAt: c.createdAt || new Date(node.ts).toISOString(),
@@ -162,7 +167,9 @@ module.exports = ({ cooler, tribeCrypto }) => {
       status: c.status || "OPEN",
       tags: Array.isArray(c.tags) ? c.tags : normalizeTags(c.tags),
       subscribers: Array.isArray(visibleSubs) ? visibleSubs : [],
-      mapUrl: c.mapUrl || ""
+      mapUrl: c.mapUrl || "",
+      visibility: String(c.visibility || "PUBLIC").toUpperCase() === "HIDDEN" ? "HIDDEN" : "PUBLIC",
+      clearnetPublic: !!c.clearnetPublic
     }
   }
 
@@ -173,7 +180,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
       const ssbClient = await openSsb()
 
       const job_type = String(jobData.job_type || "").toLowerCase()
-      if (!["freelancer", "employee"].includes(job_type)) throw new Error("Invalid job type")
+      if (!["freelancer", "employee", "exchange"].includes(job_type)) throw new Error("Invalid job type")
 
       const title = String(jobData.title || "").trim()
       const description = String(jobData.description || "").trim()
@@ -195,6 +202,8 @@ module.exports = ({ cooler, tribeCrypto }) => {
 
       const tags = normalizeTags(jobData.tags)
 
+      const hoursOfferedN = toNum(jobData.hoursOffered)
+      const hoursRequestedN = toNum(jobData.hoursRequested)
       const content = {
         type: "job",
         job_type,
@@ -207,13 +216,18 @@ module.exports = ({ cooler, tribeCrypto }) => {
         location,
         vacants,
         salary,
+        hoursOffered: Number.isFinite(hoursOfferedN) && hoursOfferedN > 0 ? hoursOfferedN : 0,
+        hoursRequested: Number.isFinite(hoursRequestedN) && hoursRequestedN > 0 ? hoursRequestedN : 0,
+        exchangeSkill: String(jobData.exchangeSkill || "").trim(),
         image: blobId,
         tags,
         author: ssbClient.id,
         createdAt: new Date().toISOString(),
         updatedAt: new Date().toISOString(),
         status: "OPEN",
-        mapUrl: String(jobData.mapUrl || "").trim()
+        mapUrl: String(jobData.mapUrl || "").trim(),
+        visibility: String(jobData.visibility || "PUBLIC").toUpperCase() === "HIDDEN" ? "HIDDEN" : "PUBLIC",
+        clearnetPublic: jobData.clearnetPublic === true || jobData.clearnetPublic === 'true' || jobData.clearnetPublic === 'on'
       }
 
       return new Promise((res, rej) => ssbClient.publish(content, (e, m) => {
@@ -268,7 +282,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
 
       if (jobData.job_type !== undefined) {
         const jt = String(jobData.job_type || "").toLowerCase()
-        if (!["freelancer", "employee"].includes(jt)) throw new Error("Invalid job type")
+        if (!["freelancer", "employee", "exchange"].includes(jt)) throw new Error("Invalid job type")
         patch.job_type = jt
       }
 
@@ -311,6 +325,18 @@ module.exports = ({ cooler, tribeCrypto }) => {
         patch.salary = s.toFixed(6)
       }
 
+      if (jobData.hoursOffered !== undefined) {
+        const h = toNum(jobData.hoursOffered)
+        if (!Number.isFinite(h) || h < 0) throw new Error("Invalid hoursOffered")
+        patch.hoursOffered = h
+      }
+      if (jobData.hoursRequested !== undefined) {
+        const h = toNum(jobData.hoursRequested)
+        if (!Number.isFinite(h) || h < 0) throw new Error("Invalid hoursRequested")
+        patch.hoursRequested = h
+      }
+      if (jobData.exchangeSkill !== undefined) patch.exchangeSkill = String(jobData.exchangeSkill || "").trim()
+
       if (jobData.tags !== undefined) patch.tags = normalizeTags(jobData.tags)
 
       if (jobData.image !== undefined) {
@@ -325,6 +351,14 @@ module.exports = ({ cooler, tribeCrypto }) => {
         patch.status = s
       }
 
+      if (jobData.visibility !== undefined) {
+        patch.visibility = String(jobData.visibility || "PUBLIC").toUpperCase() === "HIDDEN" ? "HIDDEN" : "PUBLIC"
+      }
+      if (jobData.clearnetPublic !== undefined) {
+        patch.clearnetPublic = jobData.clearnetPublic === true || jobData.clearnetPublic === 'true' || jobData.clearnetPublic === 'on'
+      }
+      if (jobData.mapUrl !== undefined) patch.mapUrl = String(jobData.mapUrl || "").trim()
+
       const next = {
         ...existingContent,
         ...patch,
@@ -375,6 +409,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
       if (!job) throw new Error("Job not found")
       if (job.author === uid) throw new Error("Cannot subscribe to your own job")
       if (String(job.status || "").toUpperCase() !== "OPEN") throw new Error("Job is closed")
+      if (Array.isArray(job.subscribers) && job.subscribers.includes(uid)) return { alreadySubscribed: true }
 
       const rootId = job.rootId || (await this.resolveRootId(id))
 
@@ -397,6 +432,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
       const job = await this.getJobById(id)
       if (!job) throw new Error("Job not found")
       if (job.author === uid) throw new Error("Cannot unsubscribe from your own job")
+      if (Array.isArray(job.subscribers) && !job.subscribers.includes(uid)) return { notSubscribed: true }
 
       const rootId = job.rootId || (await this.resolveRootId(id))
 
@@ -426,7 +462,9 @@ module.exports = ({ cooler, tribeCrypto }) => {
         if (!node) continue
         const subsSet = idx.subsByJob.get(rootId) || new Set()
         const subs = Array.from(subsSet)
-        jobs.push(buildJobObject(node, rootId, subs))
+        const job = buildJobObject(node, rootId, subs)
+        if (job.visibility === "HIDDEN" && job.author !== viewer) continue
+        jobs.push(job)
       }
 
       const F = String(filter || "ALL").toUpperCase()
@@ -437,6 +475,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
       else if (F === "PRESENCIAL") list = list.filter((j) => String(j.location || "").toUpperCase() === "PRESENCIAL")
       else if (F === "FREELANCER") list = list.filter((j) => String(j.job_type || "").toUpperCase() === "FREELANCER")
       else if (F === "EMPLOYEE") list = list.filter((j) => String(j.job_type || "").toUpperCase() === "EMPLOYEE")
+      else if (F === "EXCHANGE") list = list.filter((j) => String(j.job_type || "").toUpperCase() === "EXCHANGE")
       else if (F === "OPEN") list = list.filter((j) => String(j.status || "").toUpperCase() === "OPEN")
       else if (F === "CLOSED") list = list.filter((j) => String(j.status || "").toUpperCase() === "CLOSED")
       else if (F === "RECENT") list = list.filter((j) => moment(j.createdAt).isAfter(moment().subtract(24, "hours")))
@@ -469,7 +508,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
 
     async getJobById(id, viewerId = null) {
       const ssbClient = await openSsb()
-      void viewerId
+      const viewer = viewerId || ssbClient.id
 
       const messages = await readAll(ssbClient)
       const idx = buildIndex(messages, ssbClient)
@@ -481,6 +520,11 @@ module.exports = ({ cooler, tribeCrypto }) => {
       let rootId = tipId
       while (idx.parent.has(rootId)) rootId = idx.parent.get(rootId)
 
+      const gate = (job) => {
+        if (job.visibility === "HIDDEN" && job.author !== viewer) throw new Error("Job not found")
+        return job
+      }
+
       const node = idx.jobNodes.get(tipId)
       if (!node) {
         const msg = await new Promise((r, j) => ssbClient.get(tipId, (e, m) => e ? j(e) : r(m)))
@@ -488,12 +532,12 @@ module.exports = ({ cooler, tribeCrypto }) => {
         const tmpNode = { key: tipId, ts: msg.timestamp || 0, c: msg.content, author: msg.author }
         const subsSet = idx.subsByJob.get(rootId) || new Set()
         const subs = Array.from(subsSet)
-        return buildJobObject(tmpNode, rootId, subs)
+        return gate(buildJobObject(tmpNode, rootId, subs))
       }
 
       const subsSet = idx.subsByJob.get(rootId) || new Set()
       const subs = Array.from(subsSet)
-      return buildJobObject(node, rootId, subs)
+      return gate(buildJobObject(node, rootId, subs))
     },
 
     async getJobTipId(id) {

+ 1 - 13
src/models/logs_model.js

@@ -1,17 +1,9 @@
 const pull = require('../server/node_modules/pull-stream');
 const util = require('../server/node_modules/util');
 const axios = require('../server/node_modules/axios');
-const fs = require('fs');
-const path = require('path');
 const { getConfig } = require('../configs/config-manager.js');
 
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
-const CYCLE_PATH = path.join(__dirname, '..', 'configs', 'blockchain-cycle.json');
-
-const readCycle = () => {
-  try { return JSON.parse(fs.readFileSync(CYCLE_PATH, 'utf8')).cycle || 0; }
-  catch { return 0; }
-};
 
 const DAY_MS = 24 * 60 * 60 * 1000;
 const WEEK_MS = 7 * DAY_MS;
@@ -174,7 +166,6 @@ module.exports = ({ cooler }) => {
       text: String(text || '').slice(0, 8000),
       label: String(label || '').slice(0, 200),
       mode: mode === 'ai' ? 'ai' : 'manual',
-      cycle: readCycle(),
       createdAt: new Date().toISOString(),
       timestamp: Date.now(),
       private: true
@@ -184,7 +175,7 @@ module.exports = ({ cooler }) => {
     return publishAsync(content, [userId]);
   }
 
-  async function republishLog({ replaces, text, label, mode, cycle, createdAt }) {
+  async function republishLog({ replaces, text, label, mode, createdAt }) {
     const ssbClient = await openSsb();
     const content = {
       type: 'log',
@@ -192,7 +183,6 @@ module.exports = ({ cooler }) => {
       text: String(text || '').slice(0, 8000),
       label: String(label || '').slice(0, 200),
       mode: mode === 'ai' ? 'ai' : 'manual',
-      cycle: cycle || readCycle(),
       createdAt: createdAt || new Date().toISOString(),
       updatedAt: new Date().toISOString(),
       timestamp: Date.now(),
@@ -307,7 +297,6 @@ module.exports = ({ cooler }) => {
         key: dec.key || keyIn,
         author: v.author,
         ts: v.timestamp || tsIn,
-        cycle: c.cycle || 0,
         createdAt: c.createdAt || new Date(v.timestamp || tsIn).toISOString(),
         text: String(c.text || ''),
         label: String(c.label || ''),
@@ -342,7 +331,6 @@ module.exports = ({ cooler }) => {
       text: text !== undefined ? text : current.text,
       label: label !== undefined ? label : current.label,
       mode: mode || current.mode,
-      cycle: current.cycle,
       createdAt: current.createdAt
     });
     return { status: 'ok' };

+ 241 - 13
src/models/main_models.js

@@ -297,6 +297,39 @@ models.about = {
     });
     return result === true;
   },
+  visibilityPrefs: async (feedId) => {
+    const result = await getAbout({ key: "visibilityPrefs", feedId });
+    if (!result || typeof result !== 'object') return null;
+    return {
+      activity: result.activity === true,
+      device:   result.device   === true,
+      karma:    result.karma !== false,
+      ubi:      result.ubi      === true,
+      wallet:   result.wallet   === true,
+      ecoTax:   result.ecoTax   !== false,
+      clearnet: result.clearnet === true,
+      clearnetShops:     result.clearnetShops     === true,
+      clearnetJobs:      result.clearnetJobs      === true,
+      clearnetEvents:    result.clearnetEvents    === true,
+      clearnetProjects:  result.clearnetProjects  === true,
+      clearnetPosts:     result.clearnetPosts     === true,
+      clearnetAudios:    result.clearnetAudios    === true,
+      clearnetVideos:    result.clearnetVideos    === true,
+      clearnetImages:    result.clearnetImages    === true,
+      clearnetDocuments: result.clearnetDocuments === true,
+      clearnetTorrents:  result.clearnetTorrents  === true,
+      profileShops:      result.profileShops      === true,
+      profileJobs:       result.profileJobs       === true,
+      profileEvents:     result.profileEvents     === true,
+      profileProjects:   result.profileProjects   === true,
+      profilePosts:      result.profilePosts      === true,
+      profileAudios:     result.profileAudios     === true,
+      profileVideos:     result.profileVideos     === true,
+      profileImages:     result.profileImages     === true,
+      profileDocuments:  result.profileDocuments  === true,
+      profileTorrents:   result.profileTorrents   === true
+    };
+  },
   name: async (feedId) => {
     if (isPublic && (await models.about.publicWebHosting(feedId)) === false) {
       return "Redacted";
@@ -362,7 +395,7 @@ models.about = {
     const abortable = pullAbortable();
     let intervals = [];
     cooler.open().then((ssb) => {
-      console.time("Warmup-time");
+      const _warmupStart = Date.now();
       pull(
         ssb.query.read({
           live: true,
@@ -382,7 +415,9 @@ models.about = {
         abortable,
         pull.filter((msg) => {
           if (msg.sync && msg.sync === true) {
-            console.timeEnd("Warmup-time");
+            const _elapsed = Date.now() - _warmupStart;
+            const _fmt = _elapsed >= 1000 ? `${(_elapsed/1000).toFixed(2)}s` : `${_elapsed}ms`;
+            console.log(`- Warmup-time: ${_fmt}`);
             transposeLookupTable();
             intervals.push(setInterval(transposeLookupTable, 1000 * 60)); 
             return false;
@@ -714,7 +749,44 @@ models.meta = {
           }
         }
       } catch {}
-      const allDbPeers = await enrichEntries(snapshot);
+      let stagedEntries = [];
+      try {
+        if (ssb.conn && typeof ssb.conn.stagedPeers === 'function') {
+          stagedEntries = await new Promise((resolve) => {
+            try {
+              pull(
+                ssb.conn.stagedPeers(),
+                pull.take(1),
+                pull.collect((err, results) => {
+                  if (err || !results || !results[0]) return resolve([]);
+                  resolve(Array.isArray(results[0]) ? results[0] : []);
+                })
+              );
+            } catch (_) { resolve([]); }
+          });
+        }
+      } catch {}
+      const dbKeys = new Set(
+        snapshot
+          .map(([, d]) => d && d.key ? canonicalizePubId(d.key) : null)
+          .filter(Boolean)
+      );
+      const mergedSnapshot = snapshot.slice();
+      for (const entry of stagedEntries) {
+        const data = Array.isArray(entry) ? entry[1] : entry;
+        const addr = Array.isArray(entry) ? entry[0] : null;
+        if (!data || !data.key) continue;
+        const ck = canonicalizePubId(data.key);
+        if (dbKeys.has(ck)) continue;
+        let host = data.host, port = data.port;
+        if ((!host || !port) && addr) {
+          const m = String(addr).match(/^net:([^:]+):(\d+)/);
+          if (m) { host = host || m[1]; port = port || Number(m[2]); }
+        }
+        mergedSnapshot.push([addr, { key: data.key, host, port, source: data.type || 'staged', verified: data.verified }]);
+        dbKeys.add(ck);
+      }
+      const allDbPeers = await enrichEntries(mergedSnapshot);
       for (const [, peerData] of allDbPeers) {
         if ((!peerData.announcers || peerData.announcers === 0) && gossipMap.has(peerData.key)) {
           const gossipEntry = gossipMap.get(peerData.key);
@@ -1795,8 +1867,43 @@ const post = {
         });
       });
     },
-    publishProfileEdit: async ({ name, description, image }) => {
+    publishProfileEdit: async ({ name, description, image, visibilityPrefs }) => {
       const ssb = await cooler.open();
+      const normalizePrefs = (raw) => {
+        const r = raw || {};
+        return {
+          activity: r.activity === true,
+          device:   r.device   === true,
+          karma:    r.karma !== false,
+          ubi:      r.ubi      === true,
+          wallet:   r.wallet   === true,
+          ecoTax:   r.ecoTax   !== false,
+          clearnet: r.clearnet === true,
+          clearnetShops:     r.clearnetShops     === true,
+          clearnetJobs:      r.clearnetJobs      === true,
+          clearnetEvents:    r.clearnetEvents    === true,
+          clearnetProjects:  r.clearnetProjects  === true,
+          clearnetPosts:     r.clearnetPosts     === true,
+          clearnetAudios:    r.clearnetAudios    === true,
+          clearnetVideos:    r.clearnetVideos    === true,
+          clearnetImages:    r.clearnetImages    === true,
+          clearnetDocuments: r.clearnetDocuments === true,
+          clearnetTorrents:  r.clearnetTorrents  === true,
+          profileShops:      r.profileShops      === true,
+          profileJobs:       r.profileJobs       === true,
+          profileEvents:     r.profileEvents     === true,
+          profileProjects:   r.profileProjects   === true,
+          profilePosts:      r.profilePosts      === true,
+          profileAudios:     r.profileAudios     === true,
+          profileVideos:     r.profileVideos     === true,
+          profileImages:     r.profileImages     === true,
+          profileDocuments:  r.profileDocuments  === true,
+          profileTorrents:   r.profileTorrents   === true
+        };
+      };
+      const prefs = visibilityPrefs ? normalizePrefs(visibilityPrefs) : undefined;
+      const baseFields = { type: "about", about: ssb.id, name, description };
+      if (prefs) baseFields.visibilityPrefs = prefs;
       if (image && image.length > 0) {
         const megabyte = Math.pow(2, 20);
         const maxSize = 50 * megabyte;
@@ -1810,13 +1917,7 @@ const post = {
               if (err) {
                 reject(err);
               } else {
-                const content = {
-                  type: "about",
-                  about: ssb.id,
-                  name,
-                  description,
-                  image: blobId,
-                };
+                const content = { ...baseFields, image: blobId };
                 ssb.publish(content, (err, msg) => {
                   if (err) reject(err);
                   else resolve(msg);
@@ -1826,9 +1927,8 @@ const post = {
           );
         });
       } else {
-        const body = { type: "about", about: ssb.id, name, description };
         return new Promise((resolve, reject) => {
-          ssb.publish(body, (err, msg) => {
+          ssb.publish(baseFields, (err, msg) => {
             if (err) reject(err);
             else resolve(msg);
           });
@@ -2025,5 +2125,133 @@ models.vote = {
       });
   },
 };
+
+models.lifetime = (() => {
+  const FRESH_GREEN_DAYS = 14;
+  const FRESH_ORANGE_DAYS = 182.5;
+  const norm = (t) => (t && t < 1e12 ? t * 1000 : t || 0);
+  const bucketOf = (ts) => {
+    if (!ts) return { bucket: 'red', range: '≥6m' };
+    const days = Math.max(0, Date.now() - ts) / 86400000;
+    if (days < FRESH_GREEN_DAYS) return { bucket: 'green', range: '<2w' };
+    if (days < FRESH_ORANGE_DAYS) return { bucket: 'orange', range: '2w–6m' };
+    return { bucket: 'red', range: '≥6m' };
+  };
+  const lastAuthorTs = async (feedId) => {
+    if (!feedId) return null;
+    const ssbClient = await cooler.open();
+    return new Promise((resolve) => {
+      try {
+        pull(
+          ssbClient.createUserStream({ id: feedId, reverse: true }),
+          pull.filter(m => m && m.value && m.value.content && m.value.content.type !== 'tombstone'),
+          pull.take(1),
+          pull.collect((err, arr) => {
+            if (err || !arr || !arr.length) return resolve(null);
+            const m = arr[0];
+            resolve(norm((m.value && m.value.timestamp) || m.timestamp) || null);
+          })
+        );
+      } catch (_) { resolve(null); }
+    });
+  };
+  const lastBacklinkTs = async (msgKey) => {
+    if (!msgKey) return null;
+    const ssbClient = await cooler.open();
+    return new Promise((resolve) => {
+      try {
+        pull(
+          ssbClient.backlinks.read({ query: [{ $filter: { dest: msgKey } }], index: 'DTA', reverse: true, limit: 1 }),
+          pull.collect((err, arr) => {
+            if (err || !arr || !arr.length) return resolve(null);
+            const m = arr[0];
+            resolve(norm((m.value && m.value.timestamp) || m.timestamp) || null);
+          })
+        );
+      } catch (_) { resolve(null); }
+    });
+  };
+  return {
+    bucket: bucketOf,
+    lastAuthorTs,
+    lastBacklinkTs,
+    async forContent({ key, author, createdAt } = {}) {
+      const [authorTs, interactionTs] = await Promise.all([
+        author ? lastAuthorTs(author) : null,
+        key ? lastBacklinkTs(key) : null
+      ]);
+      const createdTs = createdAt ? new Date(createdAt).getTime() : null;
+      const candidates = [authorTs, interactionTs, createdTs].filter(x => typeof x === 'number' && x > 0);
+      const lastTs = candidates.length ? Math.max(...candidates) : null;
+      const { bucket, range } = bucketOf(lastTs);
+      return { bucket, range, lastTs, authorTs, interactionTs, createdTs };
+    },
+    async enrichAndFilter(items, opts = {}) {
+      const { includeDead = false, getKey = (x) => x.id || x.key, getAuthor = (x) => x.author, getCreatedAt = (x) => x.createdAt } = opts;
+      const authorCache = new Map();
+      const out = [];
+      for (const item of items) {
+        const author = getAuthor(item);
+        let authorTs;
+        if (author && authorCache.has(author)) {
+          authorTs = authorCache.get(author);
+        } else {
+          authorTs = author ? await lastAuthorTs(author) : null;
+          if (author) authorCache.set(author, authorTs);
+        }
+        const key = getKey(item);
+        const interactionTs = key ? await lastBacklinkTs(key) : null;
+        const createdAt = getCreatedAt(item);
+        const createdTs = createdAt ? new Date(createdAt).getTime() : null;
+        const candidates = [authorTs, interactionTs, createdTs].filter(x => typeof x === 'number' && x > 0);
+        const lastTs = candidates.length ? Math.max(...candidates) : null;
+        const { bucket, range } = bucketOf(lastTs);
+        if (!includeDead && bucket === 'red') continue;
+        out.push({ ...item, lifetime: { bucket, range, lastTs, authorTs, interactionTs, createdTs } });
+      }
+      return out;
+    }
+  };
+})();
+
+models.spreads = {
+  /**
+   * Returns { count, voters: [{ key, name }], alreadySpread } for a given msgKey.
+   * A "spread" is a vote with value=1 referencing msgKey AND with msgKey in branch.
+   */
+  forMessage: async (msgKey) => {
+    if (!msgKey || typeof msgKey !== 'string') return { count: 0, voters: [], alreadySpread: false };
+    const ssb = await cooler.open();
+    const myId = ssb.id;
+    return new Promise((resolve) => {
+      pull(
+        ssb.backlinks.read({
+          query: [{ $filter: { dest: msgKey, value: { content: { type: 'vote' } } } }],
+          index: 'DTA',
+          meta: true
+        }),
+        pull.filter(ref => {
+          if (!ref || !ref.value || !ref.value.content) return false;
+          const c = ref.value.content;
+          if (!c.vote || c.vote.link !== msgKey || Number(c.vote.value) !== 1) return false;
+          const br = Array.isArray(c.branch) ? c.branch : (typeof c.branch === 'string' ? [c.branch] : []);
+          return br.includes(msgKey);
+        }),
+        pull.collect(async (err, refs) => {
+          if (err) return resolve({ count: 0, voters: [], alreadySpread: false });
+          const byAuthor = new Map();
+          for (const r of refs) byAuthor.set(r.value.author, true);
+          const authors = Array.from(byAuthor.keys());
+          const voters = await Promise.all(authors.map(async (k) => ({
+            key: k,
+            name: await models.about.name(k).catch(() => k.slice(1, 9))
+          })));
+          resolve({ count: voters.length, voters, alreadySpread: authors.includes(myId) });
+        })
+      );
+    });
+  }
+};
+
 return models;
 };

+ 51 - 29
src/models/maps_model.js

@@ -15,7 +15,7 @@ const normalizeTags = (raw) => {
 
 const ALLOWED_MAP_TYPES = new Set(["OPEN", "CLOSED", "SINGLE"]);
 
-module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
+module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
   let ssb;
 
   const openSsb = async () => {
@@ -23,14 +23,25 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
     return ssb;
   };
 
+  const ownCrypto = mapCrypto || tribeCrypto;
+  const lookupKey = (rid) => (ownCrypto && ownCrypto.getKey(rid)) || (tribeCrypto && tribeCrypto.getKey(rid)) || null;
+  const lookupKeys = (rid) => {
+    const a = (ownCrypto && ownCrypto.getKeys(rid)) || [];
+    if (a.length) return a;
+    return (tribeCrypto && tribeCrypto.getKeys(rid)) || [];
+  };
+  const lookupGen = (rid) => ((ownCrypto && ownCrypto.getGen(rid)) || (tribeCrypto && tribeCrypto.getGen(rid)) || 0);
+
   const tribeHelpers = tribeCrypto ? tribeCrypto.createHelpers(tribesModel) : null;
   const encryptIfTribe = tribeHelpers ? tribeHelpers.encryptIfTribe : async (c) => c;
   const decryptIfTribe = tribeHelpers ? tribeHelpers.decryptIfTribe : async (c) => c;
   const assertReadable = tribeHelpers ? tribeHelpers.assertReadable : () => {};
+  const unwrapForIndex = (msgs) => tribeHelpers ? tribeHelpers.unwrapMessagesForKind(msgs, ['map', 'mapMarker']) : msgs;
+  const tombFor = async (target, tribeId, author) => tribeHelpers ? tribeHelpers.encryptTombstone(target, tribeId, author) : { type: 'tombstone', target, deletedAt: new Date().toISOString(), author };
 
   const encryptStandalone = (content, rootId) => {
     if (!tribeCrypto || !rootId) return content;
-    const key = tribeCrypto.getKey(rootId);
+    const key = lookupKey(rootId);
     if (!key) return content;
     return tribeCrypto.encryptContent(content, [key], true);
   };
@@ -38,7 +49,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
   const decryptMapRoot = (content, rootId) => {
     if (!content || !content.encryptedPayload) return content;
     if (!tribeCrypto) return content;
-    const keys = tribeCrypto.getKeys(rootId);
+    const keys = lookupKeys(rootId);
     if (!keys || !keys.length) return { ...content, _undecryptable: true };
     return tribeCrypto.decryptContent(content, keys.map(k => [k]));
   };
@@ -215,7 +226,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
     async resolveCurrentId(id) {
       const ssbClient = await openSsb();
       const messages = await getAllMessages(ssbClient);
-      const idx = buildIndex(messages);
+      const idx = buildIndex(unwrapForIndex(messages));
 
       let tip = id;
       while (idx.forward.has(tip)) tip = idx.forward.get(tip);
@@ -226,7 +237,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
     async resolveRootId(id) {
       const ssbClient = await openSsb();
       const messages = await getAllMessages(ssbClient);
-      const idx = buildIndex(messages);
+      const idx = buildIndex(unwrapForIndex(messages));
 
       let tip = id;
       while (idx.forward.has(tip)) tip = idx.forward.get(tip);
@@ -268,7 +279,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
       if (tribeId) {
         content = await encryptIfTribe(plainContent);
       } else if (shouldEncryptStandalone) {
-        mapKey = tribeCrypto.generateTribeKey();
+        mapKey = ownCrypto.generateTribeKey();
         content = tribeCrypto.encryptContent(plainContent, [mapKey], true);
       }
 
@@ -277,7 +288,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
       });
 
       if (mapKey) {
-        tribeCrypto.setKey(result.key, mapKey, 1);
+        ownCrypto.setKey(result.key, mapKey, 1);
         try {
           const ssbKeys = require("../server/node_modules/ssb-keys");
           const boxedKey = tribeCrypto.boxKeyForMember(mapKey, userId, ssbKeys);
@@ -323,10 +334,14 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
       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 = oldMsg.content.tribeId
+      if (!oldMsg) throw new Error("Map not found");
+      const isWrapped = tribeCrypto && tribeCrypto.isTribeMsg(oldMsg.content);
+      const tribeIdHint = isWrapped ? null : (oldMsg.content && oldMsg.content.tribeId);
+      const oldDecrypted = (isWrapped || tribeIdHint)
         ? await decryptIfTribe(oldMsg.content)
         : decryptMapRoot(oldMsg.content, rootId);
+      const effectiveTribeId = oldDecrypted && oldDecrypted.tribeId;
+      if (!oldDecrypted || (oldDecrypted.type && oldDecrypted.type !== "map" && !isWrapped && oldMsg.content?.type !== "map")) throw new Error("Map not found");
       assertReadable(oldDecrypted, "Map");
       if ((oldDecrypted.author || oldMsg.content.author) !== userId) throw new Error("Not the author");
 
@@ -347,13 +362,13 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
         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 } : {}),
+        ...(effectiveTribeId ? { tribeId: effectiveTribeId } : {}),
         ...(image ? { image } : (oldDecrypted.image ? { image: oldDecrypted.image } : {})),
         createdAt: oldDecrypted.createdAt,
         updatedAt: now
       };
 
-      if (oldMsg.content.tribeId) {
+      if (effectiveTribeId) {
         updated = await encryptIfTribe(updated);
       } else if (mType !== "SINGLE") {
         updated = encryptStandalone(updated, rootId);
@@ -362,7 +377,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
       const result = await new Promise((resolve, reject) => {
         ssbClient.publish(updated, (err, res) => (err ? reject(err) : resolve(res)));
       });
-      const tombstone = { type: "tombstone", target: tipId, deletedAt: now, author: userId };
+      const tombstone = await tombFor(tipId, effectiveTribeId, userId);
       await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res())));
       return result;
     },
@@ -373,11 +388,12 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
       const tipId = await this.resolveCurrentId(id);
       const msg = await getMsg(ssbClient, tipId);
 
-      if (!msg || msg.content?.type !== "map") throw new Error("Map not found");
+      if (!msg) throw new Error("Map not found");
       const decrypted = await decryptIfTribe(msg.content);
+      if (!decrypted) throw new Error("Map not found");
       if ((decrypted.author || msg.content.author) !== userId) throw new Error("Not the author");
 
-      const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId };
+      const tombstone = await tombFor(tipId, decrypted.tribeId, userId);
 
       return new Promise((resolve, reject) => {
         ssbClient.publish(tombstone, (err2, res) => (err2 ? reject(err2) : resolve(res)));
@@ -389,7 +405,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
       const userId = ssbClient.id;
 
       const messages = await getAllMessages(ssbClient);
-      const idx = buildIndex(messages);
+      const idx = buildIndex(unwrapForIndex(messages));
 
       let tipId = mapId;
       while (idx.forward.has(tipId)) tipId = idx.forward.get(tipId);
@@ -423,7 +439,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
       if (node.c.tribeId) {
         content = await encryptIfTribe(content);
       } else if (tribeCrypto) {
-        const mapKey = tribeCrypto.getKey(rootId);
+        const mapKey = lookupKey(rootId);
         if (mapKey) content = tribeCrypto.encryptContent(content, [mapKey], true);
       }
 
@@ -441,7 +457,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
       const viewerId = opts.viewerId || ssbClient.id;
 
       const messages = await getAllMessages(ssbClient);
-      const idx = buildIndex(messages);
+      const idx = buildIndex(unwrapForIndex(messages));
       await decryptIndexNodes(idx);
       await expandMarkers(idx);
 
@@ -479,7 +495,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
       const viewer = viewerId || ssbClient.id;
 
       const messages = await getAllMessages(ssbClient);
-      const idx = buildIndex(messages);
+      const idx = buildIndex(unwrapForIndex(messages));
       await decryptIndexNodes(idx);
       await expandMarkers(idx);
 
@@ -517,14 +533,16 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
       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 };
+        if (ekChain) invite = { code, ekChain, gen: lookupGen(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
+      const isWrapped = tribeCrypto && tribeCrypto.isTribeMsg(oldMsg.content);
+      const oldDecrypted = (isWrapped || (oldMsg.content && oldMsg.content.tribeId))
         ? await decryptIfTribe(oldMsg.content)
         : decryptMapRoot(oldMsg.content, rootId);
+      const effectiveTribeId = oldDecrypted && oldDecrypted.tribeId;
       const invites = [...(Array.isArray(oldDecrypted.invites) ? oldDecrypted.invites : []), invite];
       let updated = {
         type: "map",
@@ -539,15 +557,16 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
         author: oldDecrypted.author,
         members: Array.isArray(oldDecrypted.members) ? oldDecrypted.members : [userId],
         invites,
-        ...(oldMsg.content.tribeId ? { tribeId: oldMsg.content.tribeId } : {}),
+        ...(effectiveTribeId ? { tribeId: effectiveTribeId } : {}),
         ...(oldDecrypted.image ? { image: oldDecrypted.image } : {}),
         createdAt: oldDecrypted.createdAt,
         updatedAt: new Date().toISOString()
       };
-      if (oldMsg.content.tribeId) updated = await encryptIfTribe(updated);
+      if (effectiveTribeId) 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()));
+      const tomb1 = await tombFor(tipId, effectiveTribeId, userId);
+      await new Promise((resolve, reject) => ssbClient.publish(tomb1, e => e ? reject(e) : resolve()));
       return code;
     },
 
@@ -583,15 +602,17 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
           }
         } else if (matchedInvite.ek) {
           mapKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code);
-          tribeCrypto.setKey(matched.rootId || matched.key, mapKey, matchedInvite.gen || 1);
+          ownCrypto.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
+      const isWrapped2 = tribeCrypto && tribeCrypto.isTribeMsg(oldMsg.content);
+      const oldDecrypted = (isWrapped2 || (oldMsg.content && oldMsg.content.tribeId))
         ? await decryptIfTribe(oldMsg.content)
         : decryptMapRoot(oldMsg.content, rootId);
+      const effectiveTribeId2 = oldDecrypted && oldDecrypted.tribeId;
       const isPublicInvite = typeof matchedInvite === "object" && matchedInvite.public === true;
       const invites = isPublicInvite
         ? (Array.isArray(oldDecrypted.invites) ? oldDecrypted.invites : [])
@@ -612,15 +633,16 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
         author: oldDecrypted.author,
         members: [...(Array.isArray(oldDecrypted.members) ? oldDecrypted.members : []), userId],
         invites,
-        ...(oldMsg.content.tribeId ? { tribeId: oldMsg.content.tribeId } : {}),
+        ...(effectiveTribeId2 ? { tribeId: effectiveTribeId2 } : {}),
         ...(oldDecrypted.image ? { image: oldDecrypted.image } : {}),
         createdAt: oldDecrypted.createdAt,
         updatedAt: new Date().toISOString()
       };
-      if (oldMsg.content.tribeId) updated = await encryptIfTribe(updated);
+      if (effectiveTribeId2) 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()));
+      const tomb2 = await tombFor(tipId, effectiveTribeId2, userId);
+      await new Promise((resolve, reject) => ssbClient.publish(tomb2, e => e ? reject(e) : resolve()));
       if (tribeCrypto && mapKey) {
         try {
           const ssbKeys = require("../server/node_modules/ssb-keys");
@@ -631,7 +653,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
           }
           if (Object.keys(memberKeys).length) {
             await new Promise((resolve) => {
-              ssbClient.publish({ type: "tribe-keys", tribeId: rootId, generation: tribeCrypto.getGen(rootId) || 1, memberKeys }, () => resolve());
+              ssbClient.publish({ type: "tribe-keys", tribeId: rootId, generation: lookupGen(rootId) || 1, memberKeys }, () => resolve());
             });
           }
         } catch (_) {}

+ 16 - 2
src/models/market_model.js

@@ -94,7 +94,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
   return {
     type: "market",
 
-    async createItem(item_type, title, description, image, price, tagsRaw = [], item_status, deadline, includesShipping = false, stock = 0, mapUrl = "", shopOpts = {}) {
+    async createItem(item_type, title, description, image, price, tagsRaw = [], item_status, deadline, includesShipping = false, stock = 0, mapUrl = "", shopOpts = {}, visibility = "PUBLIC") {
       const ssbClient = await openSsb()
 
       const formattedDeadline = deadline ? moment(deadline, moment.ISO_8601, true) : null
@@ -134,7 +134,8 @@ module.exports = ({ cooler, tribeCrypto }) => {
         mapUrl: String(mapUrl || "").trim(),
         shopProductId: shopOpts.shopProductId || "",
         shopId: shopOpts.shopId || "",
-        shopTitle: shopOpts.shopTitle || ""
+        shopTitle: shopOpts.shopTitle || "",
+        visibility: String(visibility || "PUBLIC").toUpperCase() === "HIDDEN" ? "HIDDEN" : "PUBLIC"
       }
 
       return new Promise((resolve, reject) => {
@@ -195,6 +196,10 @@ module.exports = ({ cooler, tribeCrypto }) => {
         normalized.includesShipping = !!normalized.includesShipping
       }
 
+      if (normalized.visibility !== undefined) {
+        normalized.visibility = String(normalized.visibility || "PUBLIC").toUpperCase() === "HIDDEN" ? "HIDDEN" : "PUBLIC"
+      }
+
       return new Promise((resolve, reject) => {
         ssbClient.get(tipId, (err, item) => {
           if (err || !item || !item.content) return reject(new Error("Item not found"))
@@ -316,6 +321,9 @@ module.exports = ({ cooler, tribeCrypto }) => {
 
         if (status === "FOR SALE" && (Number(c.stock) || 0) === 0) continue
 
+        const visibility = String(c.visibility || "PUBLIC").toUpperCase() === "HIDDEN" ? "HIDDEN" : "PUBLIC"
+        if (visibility === "HIDDEN" && c.seller !== userId) continue
+
         items.push({
           id: leaf,
           rootId,
@@ -327,6 +335,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
           item_type: c.item_type,
           item_status: c.item_status || "NEW",
           status,
+          visibility,
           createdAt: c.createdAt || new Date(best.ts).toISOString(),
           updatedAt: c.updatedAt,
           seller: c.seller,
@@ -385,6 +394,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
 
     async getItemById(itemId) {
       const ssbClient = await openSsb()
+      const userId = ssbClient.id
       const messages = await readAll(ssbClient)
 
       const tomb = new Set()
@@ -440,6 +450,9 @@ module.exports = ({ cooler, tribeCrypto }) => {
       const c = best.c
       let status = D(bestS)
 
+      const visibility = String(c.visibility || "PUBLIC").toUpperCase() === "HIDDEN" ? "HIDDEN" : "PUBLIC"
+      if (visibility === "HIDDEN" && c.seller !== userId) return null
+
       const now = moment()
       if (c.deadline) {
         const dl = moment(c.deadline)
@@ -465,6 +478,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
         item_type: c.item_type,
         item_status: c.item_status,
         status,
+        visibility,
         createdAt: c.createdAt || new Date(best.ts).toISOString(),
         updatedAt: c.updatedAt,
         seller: c.seller,

+ 117 - 0
src/models/melody_model.js

@@ -0,0 +1,117 @@
+"use strict";
+
+const pull = require('../server/node_modules/pull-stream');
+
+const NOTE_NAMES = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];
+const OCTAVES = [3, 4, 5];
+
+const TYPE_TO_DEGREE = {
+  post: 0,
+  vote: 2,
+  about: 4,
+  contact: 5,
+  forum: 7,
+  tribe: 9,
+  transfer: 11,
+  shop: 1,
+  shopProduct: 1,
+  job: 3,
+  project: 6,
+  event: 8,
+  task: 10,
+  bookmark: 4,
+  feed: 5,
+  pad: 6,
+  chat: 7,
+  audio: 8,
+  video: 9,
+  image: 10,
+  document: 11,
+  torrent: 0,
+  map: 2,
+  pixelia: 3,
+  gameScore: 5,
+  votes: 7,
+  calendar: 8,
+  curriculum: 9,
+  report: 10,
+  parliament: 11,
+  courts: 0,
+  market: 1,
+  aiExchange: 6,
+  tombstone: 4
+};
+
+const NOTE_FREQS = (() => {
+  const a4 = 440;
+  const map = {};
+  for (const oct of [2, 3, 4, 5, 6]) {
+    for (let i = 0; i < NOTE_NAMES.length; i++) {
+      const name = NOTE_NAMES[i] + oct;
+      const semis = (oct - 4) * 12 + (i - 9);
+      map[name] = a4 * Math.pow(2, semis / 12);
+    }
+  }
+  return map;
+})();
+
+function blockToNote(msg) {
+  const c = msg && msg.value && msg.value.content;
+  if (!c || typeof c !== 'object') return null;
+  const type = String(c.type || '').trim() || 'unknown';
+  const degree = TYPE_TO_DEGREE[type] != null ? TYPE_TO_DEGREE[type] : (Math.abs(hashStr(type)) % 12);
+  const size = Buffer.byteLength(JSON.stringify(msg.value), 'utf8');
+  const octIdx = size < 256 ? 0 : (size < 1024 ? 1 : 2);
+  const octave = OCTAVES[octIdx];
+  const name = NOTE_NAMES[degree] + octave;
+  const freq = NOTE_FREQS[name] || 440;
+  const durMs = Math.min(600, 200 + Math.round(size / 64));
+  return {
+    id: msg.key,
+    ts: msg.value.timestamp,
+    type,
+    size,
+    name,
+    freq,
+    durMs
+  };
+}
+
+function hashStr(s) {
+  let h = 0;
+  for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0;
+  return h;
+}
+
+module.exports = ({ cooler }) => {
+  let ssb = null;
+  const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
+
+  async function getUserMelodyInternal(userId, limit = 200) {
+    const client = await openSsb();
+    const me = userId || client.id;
+    const msgs = await new Promise((resolve, reject) => {
+      pull(
+        client.createUserStream({ id: me, reverse: true, limit }),
+        pull.collect((err, rows) => err ? reject(err) : resolve(rows))
+      );
+    });
+    const seq = msgs
+      .filter(m => m && m.value && m.value.content && typeof m.value.content === 'object')
+      .map(blockToNote)
+      .filter(Boolean)
+      .reverse();
+    const stats = {};
+    for (const n of seq) {
+      stats[n.type] = (stats[n.type] || 0) + 1;
+    }
+    return { feedId: me, total: seq.length, sequence: seq, stats };
+  }
+
+  return {
+    NOTE_NAMES,
+    OCTAVES,
+    TYPE_TO_DEGREE,
+    getUserMelody: getUserMelodyInternal
+  };
+};

+ 15 - 6
src/models/pads_model.js

@@ -15,10 +15,19 @@ const INVITE_SALT = "SolarNET.HuB-pads"
 const INVITE_BYTES = 16
 const MEMBER_COLORS = ["#e74c3c","#3498db","#2ecc71","#f39c12","#9b59b6","#1abc9c","#e67e22","#e91e63","#00bcd4","#8bc34a"]
 
-module.exports = ({ cooler, cipherModel, tribeCrypto, tribesModel }) => {
+module.exports = ({ cooler, cipherModel, tribeCrypto, padCrypto, tribesModel }) => {
   let ssb
   const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
 
+  const ownCrypto = padCrypto || tribeCrypto
+  const lookupKey = (rid) => (ownCrypto && ownCrypto.getKey(rid)) || (tribeCrypto && tribeCrypto.getKey(rid)) || null
+  const lookupKeys = (rid) => {
+    const a = (ownCrypto && ownCrypto.getKeys(rid)) || []
+    if (a.length) return a
+    return (tribeCrypto && tribeCrypto.getKeys(rid)) || []
+  }
+  const lookupGen = (rid) => ((ownCrypto && ownCrypto.getGen(rid)) || (tribeCrypto && tribeCrypto.getGen(rid)) || 0)
+
   let keyringPath = null
   let migratedToTribeCrypto = false
   const getLegacyKeyringPath = () => {
@@ -29,27 +38,27 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, tribesModel }) => {
     return keyringPath
   }
   const migrateLegacyKeyring = () => {
-    if (migratedToTribeCrypto || !tribeCrypto) { migratedToTribeCrypto = true; return }
+    if (migratedToTribeCrypto || !ownCrypto) { 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)
+        if (rootId && keyHex && !ownCrypto.getKey(rootId)) {
+          ownCrypto.setKey(rootId, keyHex, 1)
         }
       }
     } catch (_) {}
   }
   const getPadKey = (rootId) => {
     migrateLegacyKeyring()
-    if (tribeCrypto) return tribeCrypto.getKey(rootId) || null
+    if (ownCrypto) return lookupKey(rootId)
     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 }
+    if (ownCrypto) { ownCrypto.setKey(rootId, keyHex, 1); return }
     let kr = {}
     try { kr = JSON.parse(fs.readFileSync(getLegacyKeyringPath(), "utf8")) } catch (_) {}
     kr[rootId] = keyHex

+ 4 - 2
src/models/projects_model.js

@@ -181,7 +181,8 @@ module.exports = ({ cooler }) => {
         author: ssbClient.id,
         createdAt: new Date().toISOString(),
         updatedAt: null,
-        mapUrl: String(data.mapUrl || "").trim()
+        mapUrl: String(data.mapUrl || "").trim(),
+        clearnetPublic: data.clearnetPublic === true || data.clearnetPublic === 'true' || data.clearnetPublic === 'on'
       }
 
       return new Promise((res, rej) => ssbClient.publish(content, (e, m) => (e ? rej(e) : res(m))))
@@ -242,7 +243,8 @@ module.exports = ({ cooler }) => {
         bounties,
         deadline,
         progress: patch.progress === undefined ? current.progress : clampPercent(patch.progress),
-        status: patch.status === undefined ? current.status : String(patch.status || "").toUpperCase()
+        status: patch.status === undefined ? current.status : String(patch.status || "").toUpperCase(),
+        clearnetPublic: patch.clearnetPublic === undefined ? !!current.clearnetPublic : (patch.clearnetPublic === true || patch.clearnetPublic === 'true' || patch.clearnetPublic === 'on')
       }
 
       return publishReplace(ssbClient, current.id, updated)

+ 83 - 1
src/models/shops_model.js

@@ -72,6 +72,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
       location: c.location || "",
       tags: safeArr(c.tags),
       visibility: c.visibility || "OPEN",
+      clearnetPublic: !!c.clearnetPublic,
       author: c.author || node.author,
       createdAt: c.createdAt || new Date(node.ts).toISOString(),
       updatedAt: c.updatedAt || null,
@@ -139,7 +140,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
       return tip
     },
 
-    async createShop(title, shortDescription, description, image, url, location, tagsRaw, visibility, mapUrl) {
+    async createShop(title, shortDescription, description, image, url, location, tagsRaw, visibility, mapUrl, clearnetPublic) {
       const ssbClient = await openSsb()
       const blobId = image ? String(image).trim() || null : null
       const tags = normalizeTags(tagsRaw)
@@ -156,6 +157,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
         location: safeText(location),
         tags,
         visibility: vis,
+        clearnetPublic: clearnetPublic === true || clearnetPublic === 'true' || clearnetPublic === 'on',
         mapUrl: safeText(mapUrl),
         author: ssbClient.id,
         createdAt: now,
@@ -190,6 +192,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
             location: data.location !== undefined ? safeText(data.location) : c.location,
             tags: data.tags !== undefined ? normalizeTags(data.tags) : c.tags,
             visibility: data.visibility !== undefined ? (String(data.visibility).toUpperCase() === "CLOSED" ? "CLOSED" : "OPEN") : c.visibility,
+            clearnetPublic: data.clearnetPublic !== undefined ? (data.clearnetPublic === true || data.clearnetPublic === 'true' || data.clearnetPublic === 'on') : !!c.clearnetPublic,
             updatedAt: new Date().toISOString(),
             replaces: tipId
           }
@@ -481,6 +484,85 @@ module.exports = ({ cooler, tribeCrypto }) => {
       return new Promise((res, rej) => ssbClient.publish(updated, (e, m) => e ? rej(e) : res(m)))
     },
 
+    async createPurchaseOrder(productId, deliveryDetails = {}) {
+      const ssbClient = await openSsb()
+      const userId = ssbClient.id
+      const messages = await readAll(ssbClient)
+      const idx = buildIndex(messages)
+
+      let tip = productId
+      while (idx.child.has(tip)) tip = idx.child.get(tip)
+      if (idx.tomb.has(tip)) throw new Error("Product not found")
+      const tipId = tip
+
+      let rootId = tipId
+      while (idx.parent.has(rootId)) rootId = idx.parent.get(rootId)
+
+      const node = idx.nodes.get(tipId)
+      if (!node) throw new Error("Product not found")
+      const c = node.c
+      const shopOwner = c.author
+      if (shopOwner === userId) throw new Error("Cannot buy your own product")
+
+      const content = {
+        type: "shop-purchase",
+        productId: rootId,
+        productTipId: tipId,
+        shopId: c.shopId || "",
+        title: String(c.title || ""),
+        price: c.price || "",
+        deliveryAddress: String(deliveryDetails.deliveryAddress || ""),
+        contact: String(deliveryDetails.contact || ""),
+        notes: String(deliveryDetails.notes || ""),
+        createdAt: new Date().toISOString()
+      }
+
+      const recps = [userId, shopOwner]
+      return new Promise((res, rej) => ssbClient.private.publish(content, recps, (e, m) => e ? rej(e) : res(m)))
+    },
+
+    async listMyPurchases() {
+      const ssbClient = await openSsb()
+      const me = ssbClient.id
+      const messages = await readAll(ssbClient)
+      const out = []
+      for (const m of messages) {
+        if (typeof m.value?.content !== "string") continue
+        try {
+          const dec = ssbClient.private.unbox({ key: m.key, value: m.value, timestamp: m.value?.timestamp || m.timestamp || 0 })
+          if (!dec?.value?.content) continue
+          const dc = dec.value.content
+          if (dc.type !== "shop-purchase") continue
+          if (dec.value.author !== me) continue
+          out.push({ id: m.key, ...dc, buyer: dec.value.author, ts: dec.value.timestamp || m.timestamp || 0 })
+        } catch (_) {}
+      }
+      return out.sort((a, b) => b.ts - a.ts)
+    },
+
+    async listShopOrders(shopRootId) {
+      const ssbClient = await openSsb()
+      const me = ssbClient.id
+      const shop = await this.getShopById(shopRootId).catch(() => null)
+      if (!shop) throw new Error("Shop not found")
+      if (shop.author !== me) throw new Error("Not the shop owner")
+
+      const messages = await readAll(ssbClient)
+      const out = []
+      for (const m of messages) {
+        if (typeof m.value?.content !== "string") continue
+        try {
+          const dec = ssbClient.private.unbox({ key: m.key, value: m.value, timestamp: m.value?.timestamp || m.timestamp || 0 })
+          if (!dec?.value?.content) continue
+          const dc = dec.value.content
+          if (dc.type !== "shop-purchase") continue
+          if (dc.shopId !== shopRootId) continue
+          out.push({ id: m.key, ...dc, buyer: dec.value.author, ts: dec.value.timestamp || m.timestamp || 0 })
+        } catch (_) {}
+      }
+      return out.sort((a, b) => b.ts - a.ts)
+    },
+
     async createOpinion(id, category) {
       if (!categories.includes(category)) throw new Error("Invalid category")
       const ssbClient = await openSsb()

+ 56 - 8
src/models/stats_model.js

@@ -29,10 +29,33 @@ const listPubsFromEbt = () => {
   }
 };
 
-module.exports = ({ cooler }) => {
+const HIDDEN_ENVELOPE_TYPES = new Set([
+  'tribe-keys-distrib',
+  'tribe-invite-msg',
+  'tribe-invite-tombstone'
+]);
+
+module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
   let ssb;
   const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
 
+  const buildAccessibleTribeIds = async () => {
+    const set = new Set();
+    if (!tribesModel) return set;
+    try {
+      const list = await tribesModel.listAll();
+      for (const t of list) {
+        if (!t || !t.id) continue;
+        set.add(t.id);
+        try {
+          const chain = await tribesModel.getChainIds(t.id);
+          for (const cid of chain) set.add(cid);
+        } catch (_) {}
+      }
+    } catch (_) {}
+    return set;
+  };
+
   const types = [
     'bookmark','event','task','votes','report','feed','project',
     'image','torrent','audio','video','document','transfer','post','tribe',
@@ -43,12 +66,15 @@ module.exports = ({ cooler }) => {
   ];
 
   const getFolderSize = (folderPath) => {
-    const files = fs.readdirSync(folderPath);
+    let files;
+    try { files = fs.readdirSync(folderPath); } catch (_) { return 0; }
     let totalSize = 0;
     for (const file of files) {
       const filePath = `${folderPath}/${file}`;
-      const st = fs.statSync(filePath);
-      totalSize += st.isDirectory() ? getFolderSize(filePath) : st.size;
+      try {
+        const st = fs.statSync(filePath);
+        totalSize += st.isDirectory() ? getFolderSize(filePath) : st.size;
+      } catch (_) {}
     }
     return totalSize;
   };
@@ -258,10 +284,16 @@ module.exports = ({ cooler }) => {
       );
     });
 
-    const allMsgs = messages.filter(m => m.value?.content);
+    const allMsgs = messages.filter(m => {
+      const c = m.value && m.value.content;
+      if (!c) return false;
+      if (typeof c === 'string' && c.endsWith('.box')) return false;
+      if (c.type && HIDDEN_ENVELOPE_TYPES.has(c.type)) return false;
+      return true;
+    });
     const tombTargets = new Set(
       allMsgs
-        .filter(m => m.value.content.type === 'tombstone' && m.value.content.target)
+        .filter(m => m.value.content && m.value.content.type === 'tombstone' && m.value.content.target)
         .map(m => m.value.content.target)
     );
 
@@ -274,10 +306,26 @@ module.exports = ({ cooler }) => {
       parentOf[t] = new Map();
     }
 
+    const fpIdx = tribeCrypto ? tribeCrypto.buildFingerprintIndex() : null;
+    const accessibleTribeIds = await buildAccessibleTribeIds();
     for (const m of scopedMsgs) {
       const k = m.key;
-      const c = m.value.content;
-      theType = c.type;
+      let c = m.value.content;
+      if (tribeCrypto && tribeCrypto.isTribeMsg(c)) {
+        const r = fpIdx ? tribeCrypto.unwrapMsg(c, fpIdx) : null;
+        if (!r || !r.body) continue;
+        const inner = r.body;
+        if (inner.k === 'tribe') {
+          c = { ...inner, type: 'tribe' };
+        } else if (inner.k === 'tribe-content' && inner.contentType) {
+          c = { ...inner, type: inner.contentType };
+        } else {
+          continue;
+        }
+      } else if (c && c.tribeId && !accessibleTribeIds.has(c.tribeId)) {
+        continue;
+      }
+      let theType = c.type;
       if (!types.includes(theType)) continue;
       byType[theType].set(k, { key: k, ts: m.value.timestamp, content: c, author: m.value.author });
       if (c.replaces) parentOf[theType].set(k, c.replaces);

+ 55 - 1
src/models/tasks_model.js

@@ -3,7 +3,7 @@ const moment = require('../server/node_modules/moment');
 const { getConfig } = require('../configs/config-manager.js');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
-module.exports = ({ cooler }) => {
+module.exports = ({ cooler, pmModel }) => {
   let ssb;
   const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
 
@@ -210,6 +210,60 @@ module.exports = ({ cooler }) => {
           })
         );
       });
+    },
+
+    async checkDueReminders() {
+      if (!pmModel) return;
+      const ssbClient = await openSsb();
+      const messages = await new Promise((resolve, reject) =>
+        pull(ssbClient.createLogStream({ limit: logLimit }),
+          pull.collect((err, rows) => err ? reject(err) : resolve(rows)))
+      );
+      const now = Date.now();
+      const sent = new Set();
+      const tombstoned = new Set();
+      const replaced = new Set();
+      const tasks = new Map();
+      for (const m of messages) {
+        const c = m.value && m.value.content;
+        if (!c) continue;
+        if (c.type === 'tombstone' && c.target) { tombstoned.add(c.target); continue; }
+        if (c.type === 'taskReminderSent' && c.target) { sent.add(c.target); continue; }
+        if (c.type === 'task') {
+          if (c.replaces) replaced.add(c.replaces);
+          tasks.set(m.key, { id: m.key, ...c });
+        }
+      }
+      tombstoned.forEach(id => tasks.delete(id));
+      replaced.forEach(id => tasks.delete(id));
+      const publishMarker = (target) => new Promise((resolve, reject) => {
+        ssbClient.publish({ type: 'taskReminderSent', target, sentAt: new Date().toISOString() }, err => err ? reject(err) : resolve());
+      });
+      for (const t of tasks.values()) {
+        if (sent.has(t.id)) continue;
+        const status = String(t.status || '').toUpperCase();
+        if (status !== 'OPEN') continue;
+        const endTime = t.endTime || t.deadline;
+        if (!endTime) continue;
+        const endTs = new Date(endTime).getTime();
+        if (!endTs || endTs > now) continue;
+        const assignees = Array.isArray(t.assignees) ? t.assignees.filter(a => typeof a === 'string' && a.length > 0) : [];
+        if (assignees.length === 0) continue;
+        const subject = `Task Reminder: ${t.title || 'Task'}`;
+        const text =
+          `Task: ${t.title || ''}\n` +
+          (t.description ? `Description: ${t.description}\n` : '') +
+          `Deadline: ${endTime}\n` +
+          (t.priority ? `Priority: ${t.priority}\n` : '') +
+          `\nVisit Task: /tasks/${t.id}`;
+        try {
+          const chunkSize = 6;
+          for (let i = 0; i < assignees.length; i += chunkSize) {
+            await pmModel.sendMessage(assignees.slice(i, i + chunkSize), subject, text);
+          }
+          await publishMarker(t.id);
+        } catch (_) {}
+      }
     }
   };
 };

+ 20 - 14
src/models/torrents_model.js

@@ -36,6 +36,8 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
   const decryptIfTribe = tribeHelpers ? tribeHelpers.decryptIfTribe : async (c) => c;
   const assertReadable = tribeHelpers ? tribeHelpers.assertReadable : () => {};
   const decryptIndexNodes = tribeHelpers ? tribeHelpers.decryptIndexNodes : async () => {};
+  const unwrapForIndex = (msgs) => tribeHelpers ? tribeHelpers.unwrapMessagesForKind(msgs, 'torrent') : msgs;
+  const tombFor = async (target, tribeId, author) => tribeHelpers ? tribeHelpers.encryptTombstone(target, tribeId, author) : { type: 'tombstone', target, deletedAt: new Date().toISOString(), author };
 
   const getAllMessages = async (ssbClient) =>
     new Promise((resolve, reject) => {
@@ -137,7 +139,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
     async resolveCurrentId(id) {
       const ssbClient = await openSsb();
       const messages = await getAllMessages(ssbClient);
-      const idx = buildIndex(messages);
+      const idx = buildIndex(unwrapForIndex(messages));
 
       let tip = id;
       while (idx.forward.has(tip)) tip = idx.forward.get(tip);
@@ -148,7 +150,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
     async resolveRootId(id) {
       const ssbClient = await openSsb();
       const messages = await getAllMessages(ssbClient);
-      const idx = buildIndex(messages);
+      const idx = buildIndex(unwrapForIndex(messages));
 
       let tip = id;
       while (idx.forward.has(tip)) tip = idx.forward.get(tip);
@@ -193,10 +195,11 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
       const tipId = await this.resolveCurrentId(id);
       const oldMsg = await getMsg(ssbClient, tipId);
 
-      if (!oldMsg || oldMsg.content?.type !== "torrent") throw new Error("Torrent not found");
+      if (!oldMsg) throw new Error("Torrent not found");
       const oldDec = await decryptIfTribe(oldMsg.content);
+      if (!oldDec || oldDec.type !== "torrent") throw new Error("Torrent not found");
       assertReadable(oldDec, "Torrent");
-      if (Object.keys(oldDec.opinions || oldMsg.content.opinions || {}).length > 0) throw new Error("Cannot edit torrent after it has received opinions.");
+      if (Object.keys(oldDec.opinions || {}).length > 0) throw new Error("Cannot edit torrent after it has received opinions.");
       if ((oldDec.author || oldMsg.content.author) !== userId) throw new Error("Not the author");
 
       const tags = tagsRaw !== undefined ? normalizeTags(tagsRaw) || [] : safeArr(oldDec.tags);
@@ -224,7 +227,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
       const result = await new Promise((resolve, reject) => {
         ssbClient.publish(updated, (err, res) => (err ? reject(err) : resolve(res)));
       });
-      const tombstone = { type: "tombstone", target: tipId, deletedAt: now, author: userId };
+      const tombstone = await tombFor(tipId, oldDec.tribeId || oldMsg.content.tribeId, userId);
       await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res())));
       return result;
     },
@@ -235,11 +238,12 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
       const tipId = await this.resolveCurrentId(id);
       const msg = await getMsg(ssbClient, tipId);
 
-      if (!msg || msg.content?.type !== "torrent") throw new Error("Torrent not found");
+      if (!msg) throw new Error("Torrent not found");
       const dec = await decryptIfTribe(msg.content);
+      if (!dec || dec.type !== "torrent") throw new Error("Torrent not found");
       if ((dec.author || msg.content.author) !== userId) throw new Error("Not the author");
 
-      const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId };
+      const tombstone = await tombFor(tipId, dec.tribeId || msg.content.tribeId, userId);
 
       return new Promise((resolve, reject) => {
         ssbClient.publish(tombstone, (err, res) => (err ? reject(err) : resolve(res)));
@@ -256,7 +260,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
       const viewerId = opts.viewerId || ssbClient.id;
 
       const messages = await getAllMessages(ssbClient);
-      const idx = buildIndex(messages);
+      const idx = buildIndex(unwrapForIndex(messages));
       await decryptIndexNodes(idx);
 
       const items = [];
@@ -301,7 +305,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
       const ssbClient = await openSsb();
       const viewer = viewerId || ssbClient.id;
       const messages = await getAllMessages(ssbClient);
-      const idx = buildIndex(messages);
+      const idx = buildIndex(unwrapForIndex(messages));
       await decryptIndexNodes(idx);
 
       let tip = id;
@@ -315,12 +319,13 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
       if (node) return buildTorrent(node, root, viewer);
 
       const msg = await getMsg(ssbClient, tip);
-      if (!msg || msg.content?.type !== "torrent") throw new Error("Torrent not found");
+      if (!msg) throw new Error("Torrent not found");
       let c = msg.content;
-      if (c.encryptedPayload && tribeCrypto && tribesModel) {
+      if (tribeCrypto && (c?.encryptedPayload || tribeCrypto.isTribeMsg(c)) && tribesModel) {
         const dec = await tribeCrypto.decryptFromTribe(c, tribesModel);
         c = dec && !dec._undecryptable ? { ...dec, _decrypted: true } : { ...c, _decrypted: false };
       }
+      if (!c || c.type !== "torrent") throw new Error("Torrent not found");
       return buildTorrent({ key: tip, ts: msg.timestamp || 0, c }, root, viewer);
     },
 
@@ -333,11 +338,12 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
       const tipId = await this.resolveCurrentId(id);
       const msg = await getMsg(ssbClient, tipId);
 
-      if (!msg || msg.content?.type !== "torrent") throw new Error("Torrent not found");
+      if (!msg) throw new Error("Torrent not found");
 
       const oldDec = await decryptIfTribe(msg.content);
+      if (!oldDec || oldDec.type !== "torrent") throw new Error("Torrent not found");
       assertReadable(oldDec, "Torrent");
-      const voters = safeArr(oldDec.opinions_inhabitants || msg.content.opinions_inhabitants);
+      const voters = safeArr(oldDec.opinions_inhabitants);
       if (voters.includes(userId)) throw new Error("Already voted");
 
       const now = new Date().toISOString();
@@ -365,7 +371,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
       const result = await new Promise((resolve, reject) => {
         ssbClient.publish(updated, (err, res) => (err ? reject(err) : resolve(res)));
       });
-      const tombstone = { type: "tombstone", target: tipId, deletedAt: now, author: userId };
+      const tombstone = await tombFor(tipId, oldDec.tribeId || msg.content.tribeId, userId);
       await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res())));
       return result;
     }

+ 13 - 2
src/models/transfers_model.js

@@ -17,6 +17,12 @@ const normalizeTags = (raw) => {
   return String(raw).split(",").map(t => t.trim()).filter(Boolean)
 }
 
+const CATEGORIES = ["ECONOMIC", "TIME", "TRUST"]
+const normalizeCategory = (raw) => {
+  const c = String(raw || "ECONOMIC").trim().toUpperCase()
+  return CATEGORIES.includes(c) ? c : "ECONOMIC"
+}
+
 module.exports = ({ cooler }) => {
   let ssb
   const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
@@ -146,6 +152,7 @@ module.exports = ({ cooler }) => {
       to: c.to,
       concept: c.concept,
       amount: c.amount,
+      category: normalizeCategory(c.category),
       createdAt: c.createdAt || new Date(node.ts).toISOString(),
       updatedAt: c.updatedAt || null,
       deadline: c.deadline,
@@ -171,7 +178,7 @@ module.exports = ({ cooler }) => {
       return tip
     },
 
-    async createTransfer(to, concept, amount, deadline, tagsRaw = []) {
+    async createTransfer(to, concept, amount, deadline, tagsRaw = [], category) {
       const ssbClient = await openSsb()
       const userId = ssbClient.id
 
@@ -184,6 +191,7 @@ module.exports = ({ cooler }) => {
       if (!dl.isValid() || dl.isBefore(moment())) throw new Error("Deadline must be in the future")
 
       const tags = normalizeTags(tagsRaw)
+      const cat = normalizeCategory(category)
       const isSelf = to === userId
       const now = new Date().toISOString()
 
@@ -193,6 +201,7 @@ module.exports = ({ cooler }) => {
         to,
         concept: String(concept || ""),
         amount: num.toFixed(6),
+        category: cat,
         createdAt: now,
         updatedAt: now,
         deadline: dl.toISOString(),
@@ -208,7 +217,7 @@ module.exports = ({ cooler }) => {
       })
     },
 
-    async updateTransferById(id, to, concept, amount, deadline, tagsRaw = []) {
+    async updateTransferById(id, to, concept, amount, deadline, tagsRaw = [], category) {
       const ssbClient = await openSsb()
       const userId = ssbClient.id
       const tipId = await this.resolveCurrentId(id)
@@ -235,6 +244,7 @@ module.exports = ({ cooler }) => {
       if (!dl.isValid() || dl.isBefore(moment())) throw new Error("Deadline must be in the future")
 
       const tags = normalizeTags(tagsRaw)
+      const cat = normalizeCategory(category !== undefined ? category : current.category)
       const isSelf = to === userId
 
       const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
@@ -246,6 +256,7 @@ module.exports = ({ cooler }) => {
         to,
         concept: String(concept || ""),
         amount: num.toFixed(6),
+        category: cat,
         createdAt: current.createdAt,
         deadline: dl.toISOString(),
         confirmedBy: [userId],

+ 1 - 387
src/models/tribe_crypto.js

@@ -1,387 +1 @@
-const crypto = require('crypto');
-const fs = require('fs');
-const path = require('path');
-
-const SENSITIVE_FIELDS = [
-  'title', 'description', 'location', 'price', 'salary', 'options', 'votes',
-  'category', 'tags', 'image', 'url', 'attendees', 'assignees', 'deadline',
-  'goal', 'funded', 'refeeds', 'refeeds_inhabitants', 'opinions',
-  'opinions_inhabitants', 'status', 'priority', 'date', 'mediaType'
-];
-
-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_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');
-  let keyring = {};
-
-  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 = {};
-    }
-    return keyring;
-  };
-
-  const saveKeyring = () => {
-    const tmp = keyringPath + '.tmp.' + process.pid + '.' + Date.now();
-    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');
-
-  const getKey = (tribeRootId) => {
-    const entry = keyring[tribeRootId];
-    return entry && entry.keys && entry.keys[0] ? entry.keys[0] : null;
-  };
-
-  const getKeys = (tribeRootId) => {
-    const entry = keyring[tribeRootId];
-    return entry && entry.keys ? entry.keys : [];
-  };
-
-  const getGen = (tribeRootId) => {
-    const entry = keyring[tribeRootId];
-    return entry ? entry.gen || 1 : 0;
-  };
-
-  const setKey = (tribeRootId, keyHex, gen) => {
-    keyring[tribeRootId] = { keys: [keyHex], gen: gen || 1 };
-    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;
-    saveKeyring();
-    return entry.gen;
-  };
-
-  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, 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 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, salt) => {
-    const derived = deriveInviteKey(inviteCode, salt);
-    return decryptWithKey(encryptedKey, derived.toString('hex'));
-  };
-
-  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, 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 || (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 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;
-    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, aad) => {
-    const reversed = [...keyChain].reverse();
-    let data = encrypted;
-    for (let i = 0; i < reversed.length; i++) {
-      data = decryptWithKey(data, reversed[i], i === 0 ? aad : undefined);
-    }
-    return data;
-  };
-
-  const encryptContent = (content, keyChain, customFields) => {
-    const payload = {};
-    if (customFields) {
-      for (const [k, v] of Object.entries(content)) {
-        if (ENVELOPE_PRESERVE.has(k)) continue;
-        payload[k] = v;
-      }
-    } else {
-      for (const field of SENSITIVE_FIELDS) {
-        if (content[field] !== undefined) payload[field] = content[field];
-      }
-    }
-    const plaintext = JSON.stringify(payload);
-    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);
-        const result = { ...content };
-        delete result.encryptedPayload;
-        Object.assign(result, payload);
-        return result;
-      } catch (e) {
-        continue;
-      }
-    }
-    return { ...content, _undecryptable: true };
-  };
-
-  const boxKeyForMember = (tribeKeyHex, memberFeedId, ssbKeys) => {
-    return ssbKeys.box(tribeKeyHex, [memberFeedId]);
-  };
-
-  const unboxKeyFromMember = (boxed, localKeypair, ssbKeys) => {
-    return ssbKeys.unbox(boxed, localKeypair);
-  };
-
-  const buildKeyChainSets = (ancestryRootIds) => {
-    if (ancestryRootIds.length === 0) return [];
-    if (ancestryRootIds.length === 1) {
-      const keys = getKeys(ancestryRootIds[0]);
-      return keys.map(k => [k]);
-    }
-    const ownKeys = getKeys(ancestryRootIds[0]);
-    const parentSets = buildKeyChainSets(ancestryRootIds.slice(1));
-    const sets = [];
-    for (const ownKey of ownKeys) {
-      for (const parentChain of parentSets) {
-        sets.push([ownKey, ...parentChain]);
-      }
-    }
-    return sets;
-  };
-
-  const resolveKeyChain = async (tribeId, tribesModel) => {
-    if (!tribeId || !tribesModel) return null;
-    let ancestryIds;
-    try { ancestryIds = await tribesModel.getAncestryChain(tribeId); } catch (_) { return null; }
-    if (!Array.isArray(ancestryIds) || !ancestryIds.length) return null;
-    const chain = [];
-    for (const rootId of ancestryIds) {
-      const key = getKey(rootId);
-      if (!key) return null;
-      chain.push(key);
-    }
-    return chain.length ? chain : null;
-  };
-
-  const resolveKeyChainSets = async (tribeId, tribesModel) => {
-    if (!tribeId || !tribesModel) return null;
-    let ancestryIds;
-    try { ancestryIds = await tribesModel.getAncestryChain(tribeId); } catch (_) { return null; }
-    if (!Array.isArray(ancestryIds) || !ancestryIds.length) return null;
-    return buildKeyChainSets(ancestryIds);
-  };
-
-  const encryptForTribe = async (content, tribeId, tribesModel) => {
-    const chain = await resolveKeyChain(tribeId, tribesModel);
-    if (!chain) throw new Error('Missing tribe key chain — cannot encrypt content for this tribe');
-    return encryptContent(content, chain, true);
-  };
-
-  const decryptFromTribe = async (content, tribesModel) => {
-    if (!content || !content.encryptedPayload) return content;
-    const tid = content.tribeId;
-    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) => ({
-    async encryptIfTribe(content) {
-      if (!content.tribeId || !tribesModel) return content;
-      return await encryptForTribe(content, content.tribeId, tribesModel);
-    },
-    async decryptIfTribe(content) {
-      if (!content || !content.encryptedPayload || !tribesModel) return content;
-      return await decryptFromTribe(content, tribesModel);
-    },
-    assertReadable(decrypted, what) {
-      if (decrypted && decrypted._undecryptable) throw new Error(`${what} is tribe-encrypted and cannot be decrypted with available keys`);
-    },
-    async decryptIndexNodes(idx) {
-      if (!tribesModel) return;
-      for (const [k, n] of idx.nodes.entries()) {
-        if (!n.c || !n.c.encryptedPayload) continue;
-        const dec = await decryptFromTribe(n.c, tribesModel);
-        if (dec && !dec._undecryptable) {
-          idx.nodes.set(k, { ...n, c: { ...dec, _decrypted: true } });
-        } else {
-          idx.nodes.set(k, { ...n, c: { ...n.c, _decrypted: false } });
-        }
-      }
-    }
-  });
-
-  loadKeyring();
-
-  return {
-    SENSITIVE_FIELDS,
-    ENVELOPE_PRESERVE,
-    loadKeyring, saveKeyring,
-    generateTribeKey, getKey, getKeys, getGen, setKey, setKeys, mergeKeys, addNewKey,
-    encryptWithKey, decryptWithKey,
-    encryptForInvite, decryptFromInvite,
-    encryptChainForInvite, decryptChainFromInvite,
-    generateInviteSalt, hashInviteCode, inviteMatchesCode,
-    encryptChain, decryptChain,
-    encryptContent, decryptContent,
-    boxKeyForMember, unboxKeyFromMember,
-    buildKeyChainSets,
-    resolveKeyChain, resolveKeyChainSets,
-    encryptForTribe, decryptFromTribe,
-    createHelpers,
-  };
-};
+module.exports = (configPath) => require('./crypto')(configPath, 'tribes');

+ 114 - 145
src/models/tribes_content_model.js

@@ -12,48 +12,64 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
   let ssb;
   const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
 
-  const TYPE = 'tribe-content';
-
-  const resolveKeyChain = async (tribeId) =>
-    (tribeCrypto && tribesModel) ? tribeCrypto.resolveKeyChain(tribeId, tribesModel) : null;
-
-  const resolveKeyChainSets = async (tribeId) =>
-    (tribeCrypto && tribesModel) ? tribeCrypto.resolveKeyChainSets(tribeId, tribesModel) : null;
-
-  const publish = async (content) => {
-    const ssbClient = await openSsb();
-    return new Promise((resolve, reject) =>
-      ssbClient.publish(content, (err, result) => err ? reject(err) : resolve(result))
-    );
-  };
-
   const readLog = async () => {
-    const ssbClient = await openSsb();
+    const client = await openSsb();
     return new Promise((resolve, reject) =>
       pull(
-        ssbClient.createLogStream({ limit: tribeLogLimit }),
+        client.createLogStream({ limit: tribeLogLimit }),
         pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
       )
     );
   };
 
-  const buildIndex = async (msgs, tribeId, contentType) => {
-    const tombstoned = new Set();
-    const replaced = new Map();
+  const fingerprintsForRoot = (rootId) => {
+    const fps = new Set();
+    for (const k of tribeCrypto.getKeys(rootId)) fps.add(tribeCrypto.fingerprint(k));
+    return fps;
+  };
+
+  const wrapAndPublishContent = async (rootId, body) => {
+    const client = await openSsb();
+    const key = tribeCrypto.getKey(rootId);
+    if (!key) throw new Error('Missing tribe key for ' + rootId);
+    const envelope = tribeCrypto.wrapMsg(body, key);
+    return new Promise((resolve, reject) =>
+      client.publish(envelope, (err, r) => err ? reject(err) : resolve(r))
+    );
+  };
+
+  const decodeContentMsgs = async (msgs, opts) => {
+    const fpIdx = tribeCrypto.buildFingerprintIndex();
+    const targetRootId = opts && opts.rootId ? opts.rootId : null;
+    const allowedFps = targetRootId ? fingerprintsForRoot(targetRootId) : null;
+
     const items = new Map();
+    const replacedBy = new Map();
+    const tombstoned = new Set();
     const authorByKey = new Map();
     const tombRequests = [];
 
     for (const m of msgs) {
-      const c = m.value?.content;
+      const c = m.value && m.value.content;
       if (!c) continue;
-      if (c.type === 'tombstone' && c.target) { tombRequests.push({ target: c.target, author: m.value?.author }); continue; }
-      if (c.type !== TYPE) continue;
-      authorByKey.set(m.key, m.value?.author);
-      if (tribeId && c.tribeId !== tribeId) continue;
-      if (contentType && c.contentType !== contentType) continue;
-      if (c.replaces) replaced.set(c.replaces, m.key);
-      items.set(m.key, { id: m.key, ...c, _ts: m.value?.timestamp });
+      if (!tribeCrypto.isTribeMsg(c)) continue;
+      if (allowedFps && !allowedFps.has(c.fp)) continue;
+
+      const r = tribeCrypto.unwrapMsg(c, fpIdx);
+      if (!r || !r.body) continue;
+      const b = r.body;
+
+      if (b.k === 'tombstone') {
+        tombRequests.push({ target: b.target, author: m.value.author, rootId: r.rootId });
+        continue;
+      }
+      if (b.k !== 'tribe-content') continue;
+      if (targetRootId && b.rootId !== targetRootId) continue;
+      if (opts && opts.contentType && b.contentType !== opts.contentType) continue;
+
+      authorByKey.set(m.key, m.value.author);
+      if (b.replaces) replacedBy.set(b.replaces, m.key);
+      items.set(m.key, { id: m.key, ...b, _ts: m.value.timestamp });
     }
 
     for (const t of tombRequests) {
@@ -61,47 +77,23 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
       if (targetAuthor && t.author === targetAuthor) tombstoned.add(t.target);
     }
 
-    for (const id of tombstoned) items.delete(id);
-    for (const oldId of replaced.keys()) items.delete(oldId);
-
-    const result = [...items.values()];
-    if (tribeCrypto && tribesModel) {
-      const keyChainCache = new Map();
-      for (let i = 0; i < result.length; i++) {
-        if (!result[i].encryptedPayload) continue;
-        const tid = result[i].tribeId;
-        if (!keyChainCache.has(tid)) {
-          const sets = await tribeCrypto.resolveKeyChainSets(tid, tribesModel);
-          keyChainCache.set(tid, sets || []);
-        }
-        result[i] = tribeCrypto.decryptContent(result[i], keyChainCache.get(tid));
-      }
-    }
-
-    return result.sort((a, b) => {
-      const ta = Date.parse(a.updatedAt || a.createdAt) || a._ts || 0;
-      const tb = Date.parse(b.updatedAt || b.createdAt) || b._ts || 0;
-      return tb - ta;
-    });
+    return { items, replacedBy, tombstoned };
   };
 
   return {
     async create(tribeId, contentType, data) {
-      if (!VALID_CONTENT_TYPES.includes(contentType)) {
-        throw new Error('Invalid content type');
-      }
-      if (data.status && !VALID_STATUSES.includes(data.status)) {
-        throw new Error('Invalid status. Must be OPEN, CLOSED, or IN-PROGRESS');
-      }
-      if (data.priority && !VALID_PRIORITIES.includes(data.priority)) {
-        throw new Error('Invalid priority. Must be LOW, MEDIUM, HIGH, or CRITICAL');
-      }
-      const ssbClient = await openSsb();
+      if (!VALID_CONTENT_TYPES.includes(contentType)) throw new Error('Invalid content type');
+      if (data.status && !VALID_STATUSES.includes(data.status)) throw new Error('Invalid status. Must be OPEN, CLOSED, or IN-PROGRESS');
+      if (data.priority && !VALID_PRIORITIES.includes(data.priority)) throw new Error('Invalid priority. Must be LOW, MEDIUM, HIGH, or CRITICAL');
+
+      const client = await openSsb();
+      const rootId = await tribesModel.getRootId(tribeId);
       const now = new Date().toISOString();
-      const content = {
-        type: TYPE,
-        tribeId,
+      const body = {
+        k: 'tribe-content',
+        rootId,
         contentType,
+        replaces: null,
         title: data.title || '',
         description: data.description || '',
         status: data.status || 'OPEN',
@@ -127,33 +119,26 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
         refeeds_inhabitants: data.refeeds_inhabitants || [],
         opinions: data.opinions || {},
         opinions_inhabitants: data.opinions_inhabitants || [],
-        author: ssbClient.id,
+        author: client.id,
         createdAt: now,
-        updatedAt: now,
+        updatedAt: now
       };
-      const keyChain = await resolveKeyChain(tribeId);
-      if (keyChain && keyChain.length > 0) {
-        return publish(tribeCrypto.encryptContent(content, keyChain));
-      }
-      return publish(content);
+      return wrapAndPublishContent(rootId, body);
     },
 
     async update(contentId, data, existing) {
       if (!existing) existing = await this.getById(contentId);
       if (!existing) throw new Error('Content not found');
-      if (existing._undecryptable) throw new Error('Content is tribe-encrypted and cannot be decrypted with available keys');
-      if (data.status && !VALID_STATUSES.includes(data.status)) {
-        throw new Error('Invalid status. Must be OPEN, CLOSED, or IN-PROGRESS');
-      }
-      if (data.priority && !VALID_PRIORITIES.includes(data.priority)) {
-        throw new Error('Invalid priority. Must be LOW, MEDIUM, HIGH, or CRITICAL');
-      }
+      if (data.status && !VALID_STATUSES.includes(data.status)) throw new Error('Invalid status. Must be OPEN, CLOSED, or IN-PROGRESS');
+      if (data.priority && !VALID_PRIORITIES.includes(data.priority)) throw new Error('Invalid priority. Must be LOW, MEDIUM, HIGH, or CRITICAL');
+
+      const rootId = existing.rootId || (await tribesModel.getRootId(existing.tribeId || existing.rootId));
       const now = new Date().toISOString();
-      const updated = {
-        type: TYPE,
-        replaces: contentId,
-        tribeId: existing.tribeId,
+      const body = {
+        k: 'tribe-content',
+        rootId,
         contentType: existing.contentType,
+        replaces: contentId,
         title: data.title !== undefined ? data.title : existing.title,
         description: data.description !== undefined ? data.description : existing.description,
         status: data.status !== undefined ? data.status : existing.status,
@@ -181,70 +166,57 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
         opinions_inhabitants: data.opinions_inhabitants !== undefined ? data.opinions_inhabitants : existing.opinions_inhabitants,
         author: existing.author,
         createdAt: existing.createdAt,
-        updatedAt: now,
+        updatedAt: now
       };
-      const keyChain = await resolveKeyChain(existing.tribeId);
-      if (keyChain && keyChain.length > 0) {
-        return publish(tribeCrypto.encryptContent(updated, keyChain));
-      }
-      return publish(updated);
+      return wrapAndPublishContent(rootId, body);
     },
 
     async deleteById(contentId) {
-      const ssbClient = await openSsb();
-      return publish({
-        type: 'tombstone',
+      const existing = await this.getById(contentId);
+      if (!existing) throw new Error('Content not found');
+      const rootId = existing.rootId || (await tribesModel.getRootId(existing.tribeId || existing.rootId));
+      const client = await openSsb();
+      const body = {
+        k: 'tombstone',
+        rootId,
         target: contentId,
-        deletedAt: new Date().toISOString(),
-        author: ssbClient.id,
-      });
+        author: client.id,
+        deletedAt: new Date().toISOString()
+      };
+      return wrapAndPublishContent(rootId, body);
     },
 
     async getById(contentId) {
       const msgs = await readLog();
-      const tombstoned = new Set();
-      const replaced = new Map();
-      const items = new Map();
-      const authorByKey = new Map();
-      const tombRequests = [];
-
-      for (const m of msgs) {
-        const c = m.value?.content;
-        if (!c) continue;
-        if (c.type === 'tombstone' && c.target) { tombRequests.push({ target: c.target, author: m.value?.author }); continue; }
-        if (c.type !== TYPE) continue;
-        authorByKey.set(m.key, m.value?.author);
-        if (c.replaces) replaced.set(c.replaces, m.key);
-        items.set(m.key, { id: m.key, ...c, _ts: m.value?.timestamp });
-      }
-      for (const t of tombRequests) {
-        const targetAuthor = authorByKey.get(t.target);
-        if (targetAuthor && t.author === targetAuthor) tombstoned.add(t.target);
-      }
-
+      const { items, replacedBy, tombstoned } = await decodeContentMsgs(msgs, {});
       let latestId = contentId;
-      while (replaced.has(latestId)) latestId = replaced.get(latestId);
+      while (replacedBy.has(latestId)) latestId = replacedBy.get(latestId);
       if (tombstoned.has(latestId)) return null;
-      const item = items.get(latestId) || null;
-      if (!item || !item.encryptedPayload || !tribeCrypto || !tribesModel) return item;
-      const keyChainSets = await tribeCrypto.resolveKeyChainSets(item.tribeId, tribesModel);
-      return tribeCrypto.decryptContent(item, keyChainSets || []);
+      return items.get(latestId) || null;
     },
 
     async listByTribe(tribeId, contentType, filter) {
+      const rootId = await tribesModel.getRootId(tribeId).catch(() => tribeId);
       const msgs = await readLog();
-      let items = await buildIndex(msgs, tribeId, contentType);
+      const { items, replacedBy, tombstoned } = await decodeContentMsgs(msgs, { rootId, contentType });
+      for (const id of tombstoned) items.delete(id);
+      for (const oldId of replacedBy.keys()) items.delete(oldId);
 
-      if (filter === 'open') items = items.filter(i => i.status === 'OPEN');
-      if (filter === 'closed') items = items.filter(i => i.status === 'CLOSED');
-      if (filter === 'in-progress') items = items.filter(i => i.status === 'IN-PROGRESS');
+      let result = [...items.values()];
+      if (filter === 'open') result = result.filter(i => i.status === 'OPEN');
+      else if (filter === 'closed') result = result.filter(i => i.status === 'CLOSED');
+      else if (filter === 'in-progress') result = result.filter(i => i.status === 'IN-PROGRESS');
 
-      return items;
+      return result.sort((a, b) => {
+        const ta = Date.parse(a.updatedAt || a.createdAt) || a._ts || 0;
+        const tb = Date.parse(b.updatedAt || b.createdAt) || b._ts || 0;
+        return tb - ta;
+      });
     },
 
     async toggleAttendee(contentId) {
-      const ssbClient = await openSsb();
-      const userId = ssbClient.id;
+      const client = await openSsb();
+      const userId = client.id;
       const item = await this.getById(contentId);
       if (!item) throw new Error('Content not found');
       const attendees = Array.isArray(item.attendees) ? [...item.attendees] : [];
@@ -255,8 +227,8 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
     },
 
     async toggleAssignee(contentId) {
-      const ssbClient = await openSsb();
-      const userId = ssbClient.id;
+      const client = await openSsb();
+      const userId = client.id;
       const item = await this.getById(contentId);
       if (!item) throw new Error('Content not found');
       const assignees = Array.isArray(item.assignees) ? [...item.assignees] : [];
@@ -267,15 +239,13 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
     },
 
     async updateStatus(contentId, status) {
-      if (!VALID_STATUSES.includes(status)) {
-        throw new Error('Invalid status. Must be OPEN, CLOSED, or IN-PROGRESS');
-      }
+      if (!VALID_STATUSES.includes(status)) throw new Error('Invalid status. Must be OPEN, CLOSED, or IN-PROGRESS');
       return this.update(contentId, { status });
     },
 
     async castVote(votationId, optionIndex) {
-      const ssbClient = await openSsb();
-      const userId = ssbClient.id;
+      const client = await openSsb();
+      const userId = client.id;
       const item = await this.getById(votationId);
       if (!item) throw new Error('Votation not found');
       if (item.status === 'CLOSED') throw new Error('Votation is closed');
@@ -295,8 +265,8 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
     },
 
     async toggleRefeed(contentId) {
-      const ssbClient = await openSsb();
-      const userId = ssbClient.id;
+      const client = await openSsb();
+      const userId = client.id;
       const item = await this.getById(contentId);
       if (!item) throw new Error('Content not found');
       const inhabitants = Array.isArray(item.refeeds_inhabitants) ? [...item.refeeds_inhabitants] : [];
@@ -307,8 +277,8 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
 
     async castOpinion(contentId, category) {
       if (!categories.includes(category)) throw new Error('Invalid opinion category');
-      const ssbClient = await openSsb();
-      const userId = ssbClient.id;
+      const client = await openSsb();
+      const userId = client.id;
       const item = await this.getById(contentId);
       if (!item) throw new Error('Content not found');
       const inhabitants = Array.isArray(item.opinions_inhabitants) ? [...item.opinions_inhabitants] : [];
@@ -320,17 +290,16 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
 
     async getThread(forumId) {
       const msgs = await readLog();
-      const allItems = buildIndex(msgs, null, null);
-      const parent = allItems.find(i => i.id === forumId);
+      const { items, replacedBy, tombstoned } = await decodeContentMsgs(msgs, {});
+      for (const id of tombstoned) items.delete(id);
+      for (const oldId of replacedBy.keys()) items.delete(oldId);
+      const all = [...items.values()];
+      const parent = all.find(i => i.id === forumId);
       if (!parent) return { parent: null, replies: [] };
-      const replies = allItems
+      const replies = all
         .filter(i => i.parentId === forumId && i.contentType === 'forum-reply')
-        .sort((a, b) => {
-          const ta = Date.parse(a.createdAt) || 0;
-          const tb = Date.parse(b.createdAt) || 0;
-          return ta - tb;
-        });
+        .sort((a, b) => (Date.parse(a.createdAt) || 0) - (Date.parse(b.createdAt) || 0));
       return { parent, replies };
-    },
+    }
   };
 };

Разлика између датотеке није приказан због своје велике величине
+ 676 - 628
src/models/tribes_model.js


+ 33 - 2
src/server/SSB_server.js

@@ -10,6 +10,16 @@ const SSB = require('ssb-db');
 const config = require('./ssb_config');
 const { printMetadata } = require('./ssb_metadata');
 
+(() => {
+  const realErr = console.error;
+  const SHS_NOISE = /shs\.server: client hello invalid|they dailed a wrong number|client hello invalid/i;
+  console.error = function (...args) {
+    if (args.length >= 2 && args[0] === 'server error, from' && typeof args[1] === 'string' && args[1].includes('~shs:')) return;
+    if (args.length >= 1 && args[0] && typeof args[0].message === 'string' && SHS_NOISE.test(args[0].message)) return;
+    return realErr.apply(console, args);
+  };
+})();
+
 require('ssb-plugins').loadUserPlugins(SecretStack({ caps }), config);
 
 const Server = SecretStack({ caps })
@@ -19,7 +29,6 @@ const Server = SecretStack({ caps })
   .use(require('ssb-ebt'))
   .use(require('ssb-friends'))
   .use(require('ssb-blobs'))
-  .use(require('ssb-lan'))
   .use(require('ssb-meme'))
   .use(require('ssb-plugins'))
   .use(require('ssb-conn'))
@@ -39,7 +48,12 @@ const Server = SecretStack({ caps })
   .use(require('ssb-links'))
   .use(require('ssb-tangle'))
   .use(require('ssb-query'));
-  
+
+if (!config.pub) {
+  Server.use(require('ssb-lan'));
+  Server.use(require('./lanRouter'));
+}
+
 if (config.autofollow?.enabled !== false) {
   Server.use(require('ssb-autofollow'));
 }
@@ -116,6 +130,23 @@ if (argv[0] === 'start') {
   const { printMetadata, colors } = require('./ssb_metadata');
   printMetadata('OASIS Server Only', colors.cyan, null);
 
+  setTimeout(() => {
+    try {
+      const pull = require('pull-stream');
+      const stream = server.conn && server.conn.hub && server.conn.hub().listen && server.conn.hub().listen();
+      if (!stream) return;
+      pull(stream, pull.drain((ev) => {
+        if (!ev || !ev.type) return;
+        const ts = new Date().toISOString().replace('T', ' ').slice(0, 19);
+        if (ev.type === 'connected') {
+          console.log(`[${ts}] CONNECTED    ${ev.address || ''}`);
+        } else if (ev.type === 'disconnected') {
+          console.log(`[${ts}] DISCONNECTED ${ev.address || ''}`);
+        }
+      }, () => {}));
+    } catch (_) {}
+  }, 1000);
+
   setTimeout(async () => {
     try {
       const bankingModel = require('../models/banking_model.js')({});

+ 68 - 0
src/server/lanRouter.js

@@ -0,0 +1,68 @@
+const pull = require('./node_modules/pull-stream');
+const Ref = require('./node_modules/ssb-ref');
+
+const staged = new Set();
+
+function stagePeer(ssb, address, key, eagerReplicate) {
+  if (!address || !key || key === ssb.id) return;
+  if (staged.has(address)) return;
+  staged.add(address);
+  let routed = false;
+  try {
+    if (ssb.conn && typeof ssb.conn.stage === 'function') {
+      ssb.conn.stage(address, { type: 'lan', key });
+      routed = true;
+    }
+  } catch (_) {}
+  if (!routed) {
+    try {
+      if (ssb.gossip && typeof ssb.gossip.add === 'function') {
+        ssb.gossip.add(address, 'local');
+      }
+    } catch (_) {}
+  }
+  if (eagerReplicate) {
+    try {
+      if (ssb.ebt && typeof ssb.ebt.request === 'function') {
+        ssb.ebt.request(key, true);
+      }
+    } catch (_) {}
+    try {
+      if (ssb.replicate && typeof ssb.replicate.request === 'function') {
+        ssb.replicate.request(key, true);
+      }
+    } catch (_) {}
+  }
+}
+
+function handleDiscovery(ssb, d, opts) {
+  if (!d || !d.address) return;
+  if (!d.verified && !opts.acceptUnverified) return;
+  let key = null;
+  try { key = Ref.getKeyFromAddress(d.address); } catch (_) {}
+  if (key) stagePeer(ssb, d.address, key, opts.eagerReplicate);
+}
+
+function startRouter(ssb, opts) {
+  if (!ssb.lan || typeof ssb.lan.discoveredPeers !== 'function') return;
+  try { ssb.lan.start(); } catch (_) {}
+  pull(
+    ssb.lan.discoveredPeers(),
+    pull.drain(d => handleDiscovery(ssb, d, opts), () => {})
+  );
+}
+
+module.exports = {
+  name: 'lanRouter',
+  version: '1.1.0',
+  manifest: {},
+  init(ssb, config) {
+    const lanCfg = (config && config.lan) || {};
+    const opts = {
+      acceptUnverified: lanCfg.acceptUnverified === true,
+      eagerReplicate: lanCfg.eagerReplicate === true
+    };
+    setImmediate(() => startRouter(ssb, opts));
+    return {};
+  }
+};

+ 1 - 1
src/server/package-lock.json

@@ -1,6 +1,6 @@
 {
   "name": "@krakenslab/oasis",
-  "version": "0.7.6",
+  "version": "0.7.7",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {

+ 1 - 1
src/server/package.json

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

+ 29 - 2
src/server/ssb_metadata.js

@@ -6,7 +6,14 @@ const config = require('./ssb_config');
 const updater = require('../backend/updater.js');
 
 let printed = false;
-let checkedForUpdate = false; 
+let checkedForUpdate = false;
+let pendingClearnetModules = null;
+let clearnetReady = false;
+
+function setClearnetModules(modules) {
+  pendingClearnetModules = Array.isArray(modules) ? modules : [];
+  clearnetReady = true;
+}
 
 function getModules() {
   const nodeModulesPath = path.resolve(__dirname, 'node_modules');
@@ -37,6 +44,19 @@ async function checkForUpdate() {
   await updater.getRemoteVersion();
 }
 
+function waitForClearnet(timeoutMs = 25000) {
+  return new Promise((resolve) => {
+    if (clearnetReady) return resolve(pendingClearnetModules || []);
+    const started = Date.now();
+    const tick = () => {
+      if (clearnetReady) return resolve(pendingClearnetModules || []);
+      if (Date.now() - started >= timeoutMs) return resolve(null);
+      setTimeout(tick, 250);
+    };
+    tick();
+  });
+}
+
 async function printMetadata(mode, modeColor = colors.cyan, httpPort = 3000, httpHost = 'localhost', offline = false, isPublic = false) {
   if (printed) return;
   printed = true;
@@ -65,7 +85,13 @@ async function printMetadata(mode, modeColor = colors.cyan, httpPort = 3000, htt
     list && list.some(i => !i.internal && i.family === 'IPv4')
   );
   console.log(`- Protocol (port): ${ssbPort}`);
-  console.log(`- LAN broadcasting (UDP): ${localDiscovery ? 'enabled' : 'disabled'}`);
+  console.log(`- LAN Broadcasting (UDP): ${localDiscovery ? 'enabled' : 'disabled'}`);
+  const clearnetModules = await waitForClearnet();
+  if (clearnetModules && clearnetModules.length > 0) {
+    console.log(`- Internet Broadcasting (Clearnet): ${clearnetModules.join(', ')}`);
+  } else {
+    console.log(`- Internet Broadcasting (Clearnet): disabled`);
+  }
   console.log(`- Replication (hops): ${hops}`);
   console.log(`- Mode: ${isOnline ? 'online' : 'offline'}`);
   console.log("");
@@ -80,5 +106,6 @@ async function printMetadata(mode, modeColor = colors.cyan, httpPort = 3000, htt
 
 module.exports = {
   printMetadata,
+  setClearnetModules,
   colors
 };

+ 32 - 0
src/views/activity_view.js

@@ -213,6 +213,7 @@ function buildActivityItemsWithPostThreads(deduped, allActions) {
   return out;
 }
 
+exports.renderActionCards = renderActionCards;
 function renderActionCards(actions, userId, allActions) {
   const all = Array.isArray(allActions) ? allActions : actions;
   const byIdAll = new Map();
@@ -1702,6 +1703,32 @@ exports.activityView = (actions, filter, userId, q = '') => {
     });
   }
 
+  const MODULE_SUB_FILTERS = {
+    audio:   { url: '/audios',     filters: ['all', 'mine', 'recent', 'top', 'favorites'] },
+    video:   { url: '/videos',     filters: ['all', 'mine', 'recent', 'top', 'favorites'] },
+    image:   { url: '/images',     filters: ['all', 'mine', 'recent', 'top', 'favorites'] },
+    document:{ url: '/documents',  filters: ['all', 'mine', 'recent', 'top', 'favorites'] },
+    bookmark:{ url: '/bookmarks',  filters: ['all', 'mine', 'recent', 'top', 'favorites'] },
+    torrent: { url: '/torrents',   filters: ['all', 'mine', 'recent', 'top', 'favorites'] },
+    map:     { url: '/maps',       filters: ['all', 'mine', 'recent'] },
+    forum:   { url: '/forum',      filters: ['all', 'mine', 'recent', 'top'] },
+    event:   { url: '/events',     filters: ['all', 'mine', 'recent', 'top'] },
+    task:    { url: '/tasks',      filters: ['all', 'mine', 'assigned', 'open', 'closed'] },
+    votes:   { url: '/votes',      filters: ['all', 'mine', 'recent', 'top'] },
+    transfer:{ url: '/transfers',  filters: ['all', 'mine', 'pending', 'unconfirmed', 'closed'] },
+    market:  { url: '/market',     filters: ['all', 'mine', 'exchange', 'auctions', 'for sale', 'sold'] },
+    shop:    { url: '/shops',      filters: ['all', 'mine', 'recent'] },
+    job:     { url: '/jobs',       filters: ['ALL', 'MINE', 'REMOTE', 'PRESENCIAL', 'OPEN', 'CLOSED'] },
+    project: { url: '/projects',   filters: ['all', 'mine', 'active', 'completed'] },
+    chat:    { url: '/chats',      filters: ['all', 'mine'] },
+    pad:     { url: '/pads',       filters: ['all', 'mine'] },
+    calendar:{ url: '/calendars',  filters: ['all', 'mine'] },
+    report:  { url: '/reports',    filters: ['all', 'mine', 'recent'] },
+    curriculum:{ url: '/cv',       filters: ['view', 'edit'] }
+  };
+
+  const sub = MODULE_SUB_FILTERS[filter];
+
   let html = template(
     title,
     section(
@@ -1728,6 +1755,11 @@ exports.activityView = (actions, filter, userId, q = '') => {
           )
         )
       ),
+      sub
+        ? div({ class: 'activity-sub-filter' },
+            sub.filters.map(f => a({ href: `${sub.url}?filter=${encodeURIComponent(f)}`, class: 'filter-btn' }, String(f).toUpperCase()))
+          )
+        : null,
     section({ class: 'feed-container' }, renderActionCards(filteredActions, userId, actions))
     )
   );

+ 7 - 1
src/views/agenda_view.js

@@ -180,7 +180,7 @@ const renderAgendaItem = (item, userId, filter) => {
 
     const subscribed = subs.includes(userId);
     if (!subscribed && String(item.status).toUpperCase() !== 'CLOSED' && item.author !== userId) {
-      actionButton = form({ method: 'GET', action: `/jobs/subscribe/${encodeURIComponent(item.id)}` },
+      actionButton = form({ method: 'POST', action: `/jobs/subscribe/${encodeURIComponent(item.id)}` },
         button({ type: 'submit', class: 'subscribe-btn' }, i18n.jobSubscribeButton)
       );
     }
@@ -213,6 +213,12 @@ exports.agendaView = async (data, filter) => {
         form({ method: 'GET', action: '/agenda' },
           button({ type: 'submit', name: 'filter', value: 'all', class: filter === 'all' ? 'filter-btn active' : 'filter-btn' },
             `${i18n.agendaFilterAll} (${counts.all})`),
+          button({ type: 'submit', name: 'filter', value: 'today', class: filter === 'today' ? 'filter-btn active' : 'filter-btn' },
+            `${i18n.agendaFilterToday || 'TODAY'} (${counts.today || 0})`),
+          button({ type: 'submit', name: 'filter', value: 'upcoming', class: filter === 'upcoming' ? 'filter-btn active' : 'filter-btn' },
+            `${i18n.agendaFilterUpcoming || 'UPCOMING'} (${counts.upcoming || 0})`),
+          button({ type: 'submit', name: 'filter', value: 'overdue', class: filter === 'overdue' ? 'filter-btn active' : 'filter-btn' },
+            `${i18n.agendaFilterOverdue || 'OVERDUE'} (${counts.overdue || 0})`),
           button({ type: 'submit', name: 'filter', value: 'open', class: filter === 'open' ? 'filter-btn active' : 'filter-btn' },
             `${i18n.agendaFilterOpen} (${counts.open})`),
           button({ type: 'submit', name: 'filter', value: 'closed', class: filter === 'closed' ? 'filter-btn active' : 'filter-btn' },

+ 19 - 3
src/views/audio_view.js

@@ -16,7 +16,7 @@ const {
   option
 } = require("../server/node_modules/hyperaxe");
 
-const { template, i18n, userLink} = require("./main_views");
+const { template, i18n, userLink, renderSpreadButton } = require("./main_views");
 const moment = require("../server/node_modules/moment");
 const { config } = require("../server/SSB_server.js");
 const { renderUrl } = require("../backend/renderUrl")
@@ -163,7 +163,7 @@ const renderAudioCommentsSection = (audioId, comments = [], returnTo = null) =>
   );
 };
 
-const renderAudioList = (audios, filter, params = {}) => {
+const renderAudioList = exports.renderAudioList = (audios, filter, params = {}) => {
   const returnTo = buildReturnTo(filter, params);
 
   return audios.length
@@ -306,7 +306,15 @@ exports.audioView = async (audios, filter = "all", audioId = null, params = {})
   return template(
     title,
     section(
-      div({ class: "tags-header" }, h2(title), p(i18n.audioDescription)),
+      div({ class: "tags-header" },
+        h2(title),
+        p(i18n.audioDescription),
+        (() => {
+          const { renderReachChip } = require('./clearnet_view');
+          const isClearnet = !!(params.viewerPrefs && params.viewerPrefs.clearnetAudios);
+          return div({ class: "shop-title-row" }, renderReachChip(isClearnet, i18n));
+        })()
+      ),
       div(
         { class: "filters" },
         form(
@@ -317,6 +325,7 @@ exports.audioView = async (audios, filter = "all", audioId = null, params = {})
           button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterMine),
           button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterRecent),
           button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterTop),
+          button({ type: "submit", name: "filter", value: "blockchain", class: filter === "blockchain" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterBlockchain || "BLOCKCHAIN"),
           button(
             { type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" },
             i18n.audioFilterFavorites
@@ -394,6 +403,7 @@ exports.singleAudioView = async (audioObj, filter = "all", comments = [], params
           button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterMine),
           button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterRecent),
           button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterTop),
+          button({ type: "submit", name: "filter", value: "blockchain", class: filter === "blockchain" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterBlockchain || "BLOCKCHAIN"),
           button(
             { type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" },
             i18n.audioFilterFavorites
@@ -407,6 +417,11 @@ exports.singleAudioView = async (audioObj, filter = "all", comments = [], params
         title ? h2(title) : null,
         renderAudioPlayer(audioObj),
         safeText(audioObj.description) ? p(...renderUrl(audioObj.description)) : null,
+        (() => {
+          const { renderReachChip } = require('./clearnet_view');
+          const isClearnet = !!(params.authorPrefs && params.authorPrefs.clearnetAudios);
+          return div({ class: 'shop-title-row' }, renderReachChip(isClearnet, i18n));
+        })(),
         renderTags(audioObj.tags),
         br(),
         renderMapLocationVisitLabel(audioObj.mapUrl),
@@ -428,6 +443,7 @@ exports.singleAudioView = async (audioObj, filter = "all", comments = [], params
               : null
           );
         })(),
+        div({ class: "spread-row" }, renderSpreadButton(audioObj.key, params.spreads)),
         div(
           { class: "voting-buttons" },
           opinionCategories.map((category) =>

+ 92 - 3
src/views/banking_views.js

@@ -55,11 +55,94 @@ const fmtEcoTime = (ms) => {
   return `${(h / 24).toFixed(2)} ${i18n.bankUnitDays || 'days'}`;
 };
 
-const renderExchange = (ex) => {
+const escAttr = (s) => String(s)
+  .replace(/&/g, '&amp;')
+  .replace(/</g, '&lt;')
+  .replace(/>/g, '&gt;')
+  .replace(/"/g, '&quot;')
+  .replace(/'/g, '&#39;');
+
+const buildEcoValueChartSvg = (history, labels) => {
+  const arr = Array.isArray(history) ? history.slice(-120) : [];
+  const W = 720, H = 320;
+  const padL = 56, padR = 16, padT = 16, padB = 70;
+  const plotW = W - padL - padR;
+  const plotH = H - padT - padB;
+  if (arr.length < 2) {
+    return `<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg" class="bank-eco-chart-svg" preserveAspectRatio="xMidYMid meet">`
+      + `<rect x="0" y="0" width="${W}" height="${H}" class="bank-eco-chart-bg" />`
+      + `<text x="${W/2}" y="${H/2}" text-anchor="middle" class="bank-eco-chart-empty">${escAttr(labels.empty || 'Not enough samples yet')}</text>`
+      + `</svg>`;
+  }
+  const values = arr.map(s => Number(s.ecoValue || 0));
+  const supplies = arr.map(s => Number(s.currentSupply || 0));
+  const inflations = arr.map(s => Number(s.inflationFactor || 0));
+  const minV = Math.min(...values);
+  const maxV = Math.max(...values);
+  const rangeV = maxV - minV || 1;
+  const minS = Math.min(...supplies);
+  const maxS = Math.max(...supplies);
+  const rangeS = maxS - minS || 1;
+  const minI = Math.min(...inflations);
+  const maxI = Math.max(...inflations);
+  const rangeI = maxI - minI || 1;
+  const stepX = arr.length > 1 ? plotW / (arr.length - 1) : plotW;
+  const xy = (i, v, minR, rangeR) => {
+    const x = padL + i * stepX;
+    const y = padT + plotH - ((v - minR) / rangeR) * plotH;
+    return `${x.toFixed(2)},${y.toFixed(2)}`;
+  };
+  const pointsValue = values.map((v, i) => xy(i, v, minV, rangeV)).join(' ');
+  const pointsSupply = supplies.map((v, i) => xy(i, v, minS, rangeS)).join(' ');
+  const pointsInfl = inflations.map((v, i) => xy(i, v, minI, rangeI)).join(' ');
+  const tsStart = moment(arr[0].ts).format('YYYY-MM-DD HH:mm');
+  const tsEnd = moment(arr[arr.length - 1].ts).format('YYYY-MM-DD HH:mm');
+  const tsMid = moment(arr[Math.floor(arr.length / 2)].ts).format('YYYY-MM-DD HH:mm');
+  const yTicks = 4;
+  const grid = [];
+  for (let i = 0; i <= yTicks; i++) {
+    const y = padT + (plotH / yTicks) * i;
+    grid.push(`<line x1="${padL}" x2="${W - padR}" y1="${y.toFixed(2)}" y2="${y.toFixed(2)}" class="bank-eco-chart-grid" />`);
+    const val = maxV - (rangeV / yTicks) * i;
+    grid.push(`<text x="${padL - 6}" y="${(y + 4).toFixed(2)}" text-anchor="end" class="bank-eco-chart-axis">${val.toFixed(4)}</text>`);
+  }
+  const xLabelY = padT + plotH + 16;
+  const xLabels = `<text x="${padL}" y="${xLabelY}" text-anchor="start" class="bank-eco-chart-axis">${escAttr(tsStart)}</text>`
+    + `<text x="${(padL + plotW/2).toFixed(2)}" y="${xLabelY}" text-anchor="middle" class="bank-eco-chart-axis">${escAttr(tsMid)}</text>`
+    + `<text x="${W - padR}" y="${xLabelY}" text-anchor="end" class="bank-eco-chart-axis">${escAttr(tsEnd)}</text>`;
+  const legendY = padT + plotH + 44;
+  const legendBaseX = padL;
+  const legend = `<g class="bank-eco-chart-legend">`
+    + `<rect x="${legendBaseX}" y="${(legendY - 7).toFixed(2)}" width="14" height="3" class="bank-eco-chart-line-value-legend" />`
+    + `<text x="${(legendBaseX + 18).toFixed(2)}" y="${legendY}" class="bank-eco-chart-legend-text">${escAttr(labels.value || 'Value')}</text>`
+    + `<rect x="${(legendBaseX + 170).toFixed(2)}" y="${(legendY - 7).toFixed(2)}" width="14" height="3" class="bank-eco-chart-line-supply-legend" />`
+    + `<text x="${(legendBaseX + 188).toFixed(2)}" y="${legendY}" class="bank-eco-chart-legend-text">${escAttr(labels.supply || 'Supply')}</text>`
+    + `<rect x="${(legendBaseX + 320).toFixed(2)}" y="${(legendY - 7).toFixed(2)}" width="14" height="3" class="bank-eco-chart-line-inflation-legend" />`
+    + `<text x="${(legendBaseX + 338).toFixed(2)}" y="${legendY}" class="bank-eco-chart-legend-text">${escAttr(labels.inflation || 'Inflation')}</text>`
+    + `</g>`;
+  return `<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg" class="bank-eco-chart-svg" preserveAspectRatio="xMidYMid meet">`
+    + `<rect x="0" y="0" width="${W}" height="${H}" class="bank-eco-chart-bg" />`
+    + grid.join('')
+    + `<polyline points="${pointsSupply}" class="bank-eco-chart-line-supply" />`
+    + `<polyline points="${pointsInfl}" class="bank-eco-chart-line-inflation" />`
+    + `<polyline points="${pointsValue}" class="bank-eco-chart-line-value" />`
+    + xLabels
+    + legend
+    + `</svg>`;
+};
+
+const renderExchange = (ex, history) => {
   if (!ex) return div(p(i18n.bankExchangeNoData));
   const syncStatus = ex.isSynced ? i18n.bankingSyncStatusSynced : i18n.bankingSyncStatusOutdated;
   const syncStatusClass = ex.isSynced ? 'synced' : 'outdated';
   const ecoTimeLabel = ex.isSynced ? fmtEcoTime(ex.ecoTimeMs) : fmtEcoTime(0);
+  const chartLabels = {
+    value: i18n.bankExchangeChartValue || 'Value (ECO/h)',
+    supply: i18n.bankExchangeChartSupply || 'Supply',
+    inflation: i18n.bankExchangeChartInflation || 'Inflation %',
+    empty: i18n.bankExchangeChartEmpty || 'Not enough samples yet — revisit later'
+  };
+  const hasEnoughSamples = Array.isArray(history) && history.length >= 2 && ex.isSynced;
   return div(
     div({ class: "bank-summary" },
       table({ class: "bank-info-table" },
@@ -75,7 +158,13 @@ const renderExchange = (ex) => {
           kvRow(i18n.bankInflationMonthly, `${Number(ex.inflationMonthly || 0).toFixed(2)}%`)
         )
       )
-    )
+    ),
+    hasEnoughSamples
+      ? div({ class: "bank-eco-chart-block" },
+          h2({ class: "bank-eco-chart-title" }, i18n.bankExchangeChartTitle || 'ECOin value over time'),
+          div({ class: "bank-eco-chart-canvas", innerHTML: buildEcoValueChartSvg(history, chartLabels) })
+        )
+      : null
   );
 };
 
@@ -327,7 +416,7 @@ const renderBankingView = (data, filter, userId, isPub) =>
             allocationsTable((data.allocations || []).sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)), userId)
           )
         : filter === "exchange"
-        ? renderExchange(data.exchange)
+        ? renderExchange(data.exchange, data.exchangeHistory)
         : filter === "epochs"
         ? renderEpochList(data.epochs || [])
         : filter === "rules"

+ 81 - 2
src/views/blockchain_view.js

@@ -25,6 +25,17 @@ const CAT_BLOCK4  = ['forum', 'pad', 'chat', 'bookmark', 'image', 'video', 'audi
 
 const SEARCH_FIELDS = ['author','id','from','to'];
 
+const formatCarbon = (bytes) => {
+  const n = Number(bytes) || 0;
+  if (!n) return '0 µg CO₂';
+  const grams = (n / (1024 * 1024)) * 0.095;
+  if (grams >= 1) return `${grams.toFixed(2)} g CO₂`;
+  const mg = grams * 1000;
+  if (mg >= 1) return `${mg.toFixed(2)} mg CO₂`;
+  const ug = mg * 1000;
+  return `${ug.toFixed(2)} µg CO₂`;
+};
+
 const hiddenSearchInputs = (search) =>
   SEARCH_FIELDS.map(k => {
     const v = String(search?.[k] ?? '').trim();
@@ -68,6 +79,69 @@ const filterBlocks = (blocks, filter, userId) => {
   return blocks.filter(b => b.type === filter);
 };
 
+const computeStats = (blocks) => {
+  const arr = Array.isArray(blocks) ? blocks : [];
+  const byType = new Map();
+  const byAuthor = new Map();
+  for (const b of arr) {
+    if (!b || !b.type) continue;
+    byType.set(b.type, (byType.get(b.type) || 0) + 1);
+    if (b.author) byAuthor.set(b.author, (byAuthor.get(b.author) || 0) + 1);
+  }
+  const typeBreakdown = Array.from(byType.entries())
+    .map(([type, count]) => ({ type, count }))
+    .sort((a, b) => b.count - a.count);
+  const topAuthors = Array.from(byAuthor.entries())
+    .map(([author, count]) => ({ author, count }))
+    .sort((a, b) => b.count - a.count)
+    .slice(0, 5);
+  return { total: arr.length, typeBreakdown, topAuthors };
+};
+
+const renderStatsPanel = (stats, currentFilter, search) => {
+  if (!stats || stats.total === 0) return null;
+  const topTypes = stats.typeBreakdown.slice(0, 10);
+  return div({ class: 'blockchain-stats' },
+    div({ class: 'tags-header' },
+      h3(`${i18n.blockchainStatsTitle || 'Stats'} (${stats.total})`)
+    ),
+    topTypes.length
+      ? div({ class: 'blockchain-stats-types' },
+          h3({ class: 'blockchain-stats-subtitle' }, i18n.blockchainStatsTypeBreakdown || 'Type breakdown'),
+          div({ class: 'mode-buttons-cols' },
+            topTypes.map(({ type, count }) =>
+              form({ method: 'GET', action: '/blockexplorer' },
+                input({ type: 'hidden', name: 'filter', value: type }),
+                ...hiddenSearchInputs(search),
+                button({
+                  type: 'submit',
+                  class: currentFilter === type ? 'filter-btn active' : 'filter-btn'
+                }, `${(FILTER_LABELS[type] || type).toUpperCase()} (${count})`)
+              )
+            )
+          )
+        )
+      : null,
+    stats.topAuthors.length
+      ? div({ class: 'blockchain-stats-authors' },
+          h3({ class: 'blockchain-stats-subtitle' }, i18n.blockchainStatsTopAuthors || 'Top authors'),
+          table({ class: 'block-info-table' },
+            tr(
+              td({ class: 'card-label' }, i18n.blockchainBlockAuthor),
+              td({ class: 'card-label' }, i18n.blockchainStatsCount || 'Blocks')
+            ),
+            ...stats.topAuthors.map(({ author, count }) =>
+              tr(
+                td(a({ href: `/blockexplorer?filter=all&author=${encodeURIComponent(author)}`, class: 'user-link block-author' }, author)),
+                td(String(count))
+              )
+            )
+          )
+        )
+      : null
+  );
+};
+
 const generateFilterButtons = (filters, currentFilter, action, search = {}) =>
   div({ class: 'mode-buttons-cols' },
     filters.map(mode =>
@@ -273,6 +347,10 @@ const renderSingleBlockView = (block, filter = 'recent', userId, search = {}, vi
               span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockType}:`),
               span({ class: 'blockchain-card-value' }, (FILTER_LABELS[block.type]||block.type).toUpperCase())
             ),
+            div({ class: 'block-row block-row--meta' },
+              span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockCarbon || 'Carbon footprint'}:`),
+              span({ class: 'blockchain-card-value' }, formatCarbon(block.size))
+            ),
             div({ class: 'block-row block-row--meta block-row--meta-spaced' },
               a({ href:`/author/${encodeURIComponent(block.author)}`, class:'block-author user-link' }, block.author)
             )
@@ -413,7 +491,8 @@ const renderBlockchainView = (blocks, filter, userId, search = {}) => {
                     tr(td({ class:'card-label' }, i18n.blockchainBlockTimestamp), td({ class:'card-value' }, moment(block.ts).format('YYYY-MM-DDTHH:mm:ss.SSSZ'))),
                     tr(td({ class:'card-label' }, i18n.blockchainBlockID),        td({ class:'card-value' }, block.id)),
                     tr(td({ class:'card-label' }, i18n.blockchainBlockType),      td({ class:'card-value' }, (FILTER_LABELS[block.type]||block.type).toUpperCase())),
-                    tr(td({ class:'card-label' }, i18n.blockchainBlockAuthor),    td({ class:'card-value' }, a({ href:`/author/${encodeURIComponent(block.author)}`, class:'block-author user-link' }, block.author)))
+                    tr(td({ class:'card-label' }, i18n.blockchainBlockAuthor),    td({ class:'card-value' }, a({ href:`/author/${encodeURIComponent(block.author)}`, class:'block-author user-link' }, block.author))),
+                    tr(td({ class:'card-label' }, i18n.blockchainBlockCarbon || 'Carbon footprint'), td({ class:'card-value' }, formatCarbon(block.size)))
                   )
                 )
               )
@@ -422,5 +501,5 @@ const renderBlockchainView = (blocks, filter, userId, search = {}) => {
   );
 };
 
-module.exports = { renderBlockchainView, renderSingleBlockView };
+module.exports = { renderBlockchainView, renderSingleBlockView, computeStats };
 

+ 2 - 1
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, userLink} = require("./main_views");
+const { template, i18n, userLink, renderSpreadButton} = require("./main_views");
 const moment = require("../server/node_modules/moment");
 const { config } = require("../server/SSB_server.js");
 const { renderUrl } = require("../backend/renderUrl");
@@ -465,6 +465,7 @@ exports.singleBookmarkView = async (bookmark, filter = "all", comments = [], par
               : null
           );
         })(),
+        div({ class: "spread-row" }, renderSpreadButton(bookmark.id, params.spreads)),
         div(
           { class: "voting-buttons" },
           opinionCategories.map((category) =>

+ 251 - 0
src/views/clearnet_view.js

@@ -0,0 +1,251 @@
+const { a, br, div, input, span } = require("../server/node_modules/hyperaxe");
+
+const escapeHtml = (s) => String(s || '')
+  .replace(/&/g, '&amp;')
+  .replace(/</g, '&lt;')
+  .replace(/>/g, '&gt;')
+  .replace(/"/g, '&quot;')
+  .replace(/'/g, '&#39;');
+
+const blobIdOf = (v) => {
+  if (!v) return null;
+  const s = String(v).trim();
+  if (!s) return null;
+  if (s.startsWith('&')) return s;
+  const m = s.match(/\((&[^)]+\.sha256)\)/);
+  if (m) return m[1];
+  return null;
+};
+
+const blobUrl = (v) => {
+  const id = blobIdOf(v);
+  return id ? `/c/blob/${encodeURIComponent(id)}` : null;
+};
+
+const renderReachChip = (isClearnet, i18nObj = {}, href = null) => {
+  const icon = isClearnet ? '🌐' : '🏝';
+  const label = isClearnet
+    ? (i18nObj.shopReachClearnet || 'Clearnet')
+    : (i18nObj.shopReachOasis || 'Oasis');
+  const chip = span({ class: `pm-exposition-chip pm-exposition-${isClearnet ? 'whole' : 'mutuals'}` },
+    span({ class: 'pm-exposition-icon' }, icon),
+    span({ class: 'pm-exposition-text' }, label)
+  );
+  if (href && isClearnet) {
+    return a({ href, target: '_blank', rel: 'noopener noreferrer', class: 'pm-exposition-chip-link' }, chip);
+  }
+  return chip;
+};
+
+const INTERNAL_OASIS_PATHS = [
+  'author','thread','hashtag','inbox','pm','profile','settings','banking','wallet',
+  'jobs','events','projects','shops','audios','videos','images','documents','torrents',
+  'tribes','tribe','forum','votes','votations','reports','tasks','maps','chats','pads',
+  'calendars','trending','opinions','feed','pixelia','cv','invites','peers','stats',
+  'blockexplorer','modules','publish','search','tags','mentions','popular','threads',
+  'topics','latest','summaries','multiverse','legacy','cipher','graphos','agenda',
+  'favorites','logs','games','parliament','courts','market','ai','public','spread',
+  'follow','unfollow','block','like','unlike'
+];
+
+const stripInternalAnchors = (html) => {
+  if (typeof html !== 'string' || !html) return html;
+  const list = INTERNAL_OASIS_PATHS.join('|');
+  const hrefedClosed = new RegExp(`<a\\b[^>]*\\bhref=["']\\/(?:${list})\\/[^"']*["'][^>]*>([\\s\\S]*?)<\\/a>`, 'gi');
+  const hrefedBare   = new RegExp(`<a\\b[^>]*\\bhref=["']\\/(?:${list})["'][^>]*>([\\s\\S]*?)<\\/a>`, 'gi');
+  return html.replace(hrefedClosed, '$1').replace(hrefedBare, '$1');
+};
+
+const renderClearnetSearchForm = ({ authorFeedId = '', query = '', placeholder = 'Search…' }) => {
+  if (!authorFeedId) return '';
+  const safeQuery = escapeHtml(query || '');
+  const safePh = escapeHtml(placeholder);
+  return `<form class="cn-search" method="GET" action="/c/inhabitant/${encodeURIComponent(authorFeedId)}"><input type="text" name="q" value="${safeQuery}" placeholder="${safePh}" autocomplete="off"/></form>`;
+};
+
+const renderClearnetUrlBlock = ({ baseUrl = '', path, i18nObj = {} }) => {
+  return div({ class: 'shop-clearnet-url' },
+    a({ href: path, target: '_blank', rel: 'noopener noreferrer', class: 'clearnet-link' }, path)
+  );
+};
+
+const CLEARNET_SEARCH_CSS = `
+.cn-search{margin:0}
+.cn-search input[type=text]{width:240px;max-width:100%;background:var(--bg-sub);color:var(--fg);border:1px solid var(--border);border-radius:6px;padding:8px 12px;font-size:14px;font-family:inherit}
+.cn-search input[type=text]:focus{outline:none;border-color:var(--fg)}
+`;
+
+const THEME_PALETTES = {
+  'Dark-SNH': {
+    bg: '#121212', bgElev: '#1C1C1C', bgSub: '#222',
+    fg: '#FFD700', fgSoft: '#E6C200', fgDim: '#9a8a2e',
+    border: '#333', accent: '#FFDD44',
+    font: "system-ui,-apple-system,sans-serif"
+  },
+  'OasisMobile': {
+    bg: '#121212', bgElev: '#1C1C1C', bgSub: '#222',
+    fg: '#FFD700', fgSoft: '#E6C200', fgDim: '#9a8a2e',
+    border: '#333', accent: '#FFDD44',
+    font: "system-ui,-apple-system,sans-serif"
+  },
+  'Clear-SNH': {
+    bg: '#F9F9F9', bgElev: '#FFFFFF', bgSub: '#F0F0F0',
+    fg: '#2C2C2C', fgSoft: '#555555', fgDim: '#888888',
+    border: '#E0E0E0', accent: '#FF6F00',
+    font: "'Roboto',sans-serif"
+  },
+  'Matrix-SNH': {
+    bg: '#000000', bgElev: '#0a0a0a', bgSub: '#050505',
+    fg: '#00FF00', fgSoft: '#00CC00', fgDim: '#008800',
+    border: '#00FF00', accent: '#66FF66',
+    font: "'Courier New',monospace"
+  },
+  'Purple-SNH': {
+    bg: '#4B0A6D', bgElev: '#39006D', bgSub: '#6A0066',
+    fg: '#E5E5E5', fgSoft: '#C8C8C8', fgDim: '#9B7CAA',
+    border: '#9B1C96', accent: '#9B1C96',
+    font: "'Arial',sans-serif"
+  }
+};
+
+const getCurrentPalette = () => {
+  try {
+    const { getConfig } = require('../configs/config-manager.js');
+    const theme = getConfig()?.themes?.current || 'Dark-SNH';
+    return THEME_PALETTES[theme] || THEME_PALETTES['Dark-SNH'];
+  } catch (_) {
+    return THEME_PALETTES['Dark-SNH'];
+  }
+};
+
+const buildBaseCss = (p) => `
+:root{
+  --bg:${p.bg}; --bg-elev:${p.bgElev}; --bg-sub:${p.bgSub};
+  --fg:${p.fg}; --fg-soft:${p.fgSoft}; --fg-dim:${p.fgDim};
+  --border:${p.border}; --border-strong:${p.border};
+  --accent:${p.accent};
+}
+*{box-sizing:border-box}
+body{background:var(--bg);color:var(--fg);font-family:${p.font};max-width:960px;margin:0 auto;padding:32px 24px;line-height:1.5}
+a{color:var(--fg);text-decoration:none}
+a:hover{color:var(--accent);text-decoration:underline}
+header.cn-header{display:flex;align-items:center;gap:16px;padding-bottom:16px;margin-bottom:24px;border-bottom:1px solid var(--border);flex-wrap:wrap}
+.cn-brand-block{flex:0 0 auto}
+.cn-brand{font-size:20px;font-weight:700;color:var(--fg);letter-spacing:1px}
+.cn-brand-sub{color:var(--fg-dim);font-size:12px;text-transform:uppercase;letter-spacing:2px;margin-top:2px}
+.cn-header-extra{flex:1 1 auto;display:flex;justify-content:flex-end;align-items:center;min-width:0}
+h2.cn-section{color:var(--fg);font-size:18px;text-transform:uppercase;letter-spacing:2px;margin:32px 0 16px 0;padding-bottom:8px;border-bottom:1px solid var(--border)}
+footer.cn-footer{margin-top:48px;padding-top:20px;border-top:1px solid var(--border);font-size:12px;color:var(--fg-dim);text-align:center;letter-spacing:0.5px}
+footer.cn-footer a{color:var(--fg-soft)}
+footer.cn-footer .cn-footer-logo{width:56px;height:auto;display:block;margin:0 auto 10px auto;border-radius:6px}
+`;
+
+const renderClearnetPage = ({ title, ogTitle, ogDescription = '', ogImage = null, extraCss = '', body, headerExtra = '', hubFeedId = null }) => {
+  const safeTitle = escapeHtml(title || 'Oasis');
+  const safeOgTitle = escapeHtml(ogTitle || title || 'Oasis');
+  const safeOgDesc = escapeHtml(ogDescription || '');
+  const palette = getCurrentPalette();
+  const baseCss = buildBaseCss(palette);
+  const brandInner = `<div class="cn-brand">⛱ Oasis HUB</div><div class="cn-brand-sub">Libre · P2P · Federated</div>`;
+  const brandBlock = hubFeedId
+    ? `<a class="cn-brand-block cn-brand-link" href="/c/inhabitant/${encodeURIComponent(hubFeedId)}">${brandInner}</a>`
+    : `<div class="cn-brand-block">${brandInner}</div>`;
+  return `<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="utf-8"/>
+  <meta name="viewport" content="width=device-width, initial-scale=1"/>
+  <title>${safeTitle}</title>
+  <meta property="og:title" content="${safeOgTitle}"/>
+  <meta property="og:description" content="${safeOgDesc}"/>
+  ${ogImage ? `<meta property="og:image" content="${ogImage}"/>` : ''}
+  <meta name="description" content="${safeOgDesc}"/>
+  <meta name="robots" content="index, follow"/>
+  <style>${baseCss}${CLEARNET_SEARCH_CSS}${extraCss}
+.cn-brand-link{display:block;text-decoration:none}
+.cn-brand-link:hover .cn-brand{color:var(--accent)}
+.cn-brand-link:hover{text-decoration:none}
+</style>
+</head>
+<body>
+  <header class="cn-header">
+    ${brandBlock}
+    ${headerExtra ? `<div class="cn-header-extra">${headerExtra}</div>` : ''}
+  </header>
+  ${stripInternalAnchors(body)}
+  <footer class="cn-footer">
+    <a href="https://code.03c8.net/krakenslab/oasis" target="_blank" rel="noopener"><img class="cn-footer-logo" src="/assets/images/snh-oasis.jpg" alt="Oasis"/></a>
+    Powered by <a href="https://code.03c8.net/krakenslab/oasis" target="_blank" rel="noopener">Oasis</a>
+  </footer>
+</body>
+</html>`;
+};
+
+const renderClearnetNotFound = () => {
+  return renderClearnetPage({
+    title: 'Oasis',
+    ogTitle: 'Oasis',
+    ogDescription: '',
+    extraCss: `.cn-notfound{color:var(--fg-soft);font-size:16px;max-width:480px;margin:80px auto 40px auto;text-align:center;line-height:1.5}`,
+    body: `<p class="cn-notfound">The content is not accessible at this moment.</p>`
+  });
+};
+
+const renderClearnetMediaView = ({ kind, item }) => {
+  const blob = blobUrl(item.url);
+  const title = escapeHtml(item.title || 'Untitled');
+  const desc = escapeHtml(item.description || '');
+  const dateStr = item.createdAt ? escapeHtml(new Date(item.createdAt).toISOString().slice(0, 10)) : '';
+  const extraCss = `
+.cn-media-meta{color:var(--fg-dim);font-size:13px;margin-bottom:16px;display:flex;gap:14px;flex-wrap:wrap;align-items:baseline}
+.cn-id-meta{font-family:monospace;font-size:11px;word-break:break-all;color:var(--fg-dim)}
+.cn-media-title{color:var(--fg);font-size:26px;font-weight:700;margin:0 0 12px 0}
+.cn-media-desc{color:var(--fg-soft);white-space:pre-wrap;line-height:1.6;margin:16px 0}
+.cn-media-frame{margin:16px 0}
+.cn-media-frame img{max-width:100%;height:auto;border-radius:6px;border:1px solid var(--border);display:block}
+.cn-media-frame audio,.cn-media-frame video{width:100%;max-width:100%;display:block;border-radius:6px;background:#000}
+.cn-media-frame .cn-media-doc{display:inline-block;background:var(--bg-elev);border:1px solid var(--border);border-radius:6px;padding:10px 18px;color:var(--fg);text-decoration:none}
+.cn-media-frame .cn-media-doc:hover{border-color:var(--fg)}
+`;
+  let mediaHtml = '';
+  if (blob) {
+    if (kind === 'image') {
+      mediaHtml = `<img src="${blob}" alt="${title}"/>`;
+    } else if (kind === 'audio') {
+      mediaHtml = `<audio controls preload="metadata" src="${blob}"></audio>`;
+    } else if (kind === 'video') {
+      mediaHtml = `<video controls preload="metadata" src="${blob}"></video>`;
+    } else if (kind === 'document' || kind === 'torrent') {
+      mediaHtml = `<a class="cn-media-doc" href="${blob}" target="_blank" rel="noopener">⇩ ${title}</a>`;
+    }
+  }
+  const body = `
+  <div class="cn-media-meta">
+    ${dateStr ? `<span>📅 ${dateStr}</span>` : ''}
+  </div>
+  <h1 class="cn-media-title">${title}</h1>
+  ${mediaHtml ? `<div class="cn-media-frame">${mediaHtml}</div>` : ''}
+  ${desc ? `<p class="cn-media-desc">${desc}</p>` : ''}
+`;
+  return renderClearnetPage({
+    title: `${title} — Oasis`,
+    ogTitle: item.title || 'Oasis',
+    ogDescription: item.description || '',
+    ogImage: (kind === 'image') ? blob : null,
+    extraCss,
+    body,
+    hubFeedId: item.author || null
+  });
+};
+
+module.exports = {
+  escapeHtml,
+  blobIdOf,
+  blobUrl,
+  renderReachChip,
+  renderClearnetUrlBlock,
+  renderClearnetSearchForm,
+  renderClearnetPage,
+  renderClearnetNotFound,
+  renderClearnetMediaView
+};

+ 27 - 1
src/views/cv_view.js

@@ -1,4 +1,4 @@
-const { form, button, div, h2, p, section, textarea, label, input, br, img, a, select, option } = require("../server/node_modules/hyperaxe");
+const { form, button, div, h2, p, section, textarea, label, input, br, img, a, select, option, span } = require("../server/node_modules/hyperaxe");
 const { template, i18n, userLink} = require('./main_views');
 const { renderUrl } = require('../backend/renderUrl');
 
@@ -89,6 +89,11 @@ exports.createCVView = async (cv = {}, editMode = false) => {
             select({ name: "preferences", required: true },
               option({ value: "IN PERSON", selected: cv.preferences === "IN-PERSON ONLY" }, "IN-PERSON ONLY"),
               option({ value: "REMOTE WORKING", selected: !cv.preferences || cv.preferences === "REMOTE WORKING" }, "REMOTE-WORKING")
+            ), br(), br(),
+            label(i18n.visibilityLabel || "Visibility"), br(),
+            select({ name: "visibility" },
+              option({ value: "PUBLIC", selected: (cv.visibility || "PUBLIC") === "PUBLIC" }, i18n.visibilityPublic || "Public"),
+              option({ value: "HIDDEN", selected: cv.visibility === "HIDDEN" }, i18n.visibilityHidden || "Hidden")
             ), br()
           ], "availability"),
 
@@ -136,6 +141,23 @@ exports.cvView = async (cv) => {
       ),
       div({ class: "cv-section" },
         div({ class: "cv-item" }, ...[
+          (() => {
+            const vis = (cv.visibility || 'PUBLIC').toUpperCase() === 'HIDDEN' ? 'HIDDEN' : 'PUBLIC';
+            const next = vis === 'PUBLIC' ? 'HIDDEN' : 'PUBLIC';
+            return div({ class: "cv-visibility-row" },
+              span({ class: "card-label" }, `${i18n.visibilityLabel || 'Visibility'}: `),
+              span({ class: vis === 'PUBLIC' ? 'visibility-public' : 'visibility-hidden' },
+                vis === 'PUBLIC' ? (i18n.visibilityPublic || 'Public') : (i18n.visibilityHidden || 'Hidden')
+              ),
+              " ",
+              form({ method: "POST", action: `/cv/visibility/${encodeURIComponent(cv.id)}`, class: "inline-form" },
+                input({ type: "hidden", name: "visibility", value: next }),
+                button({ type: "submit", class: "filter-btn" },
+                  next === 'PUBLIC' ? (i18n.visibilityMakePublic || 'Make public') : (i18n.visibilityMakeHidden || 'Make hidden')
+                )
+              )
+            );
+          })(),
           div({ class: "cv-actions" },
             form({ method: "GET", action: `/cv/edit/${encodeURIComponent(cv.id)}` },
               button({ type: "submit" }, i18n.cvEditButton)
@@ -156,6 +178,10 @@ exports.cvView = async (cv) => {
                 })
               : null,
             cv.name ? h2(`${cv.name}`) : null,
+            (cv.contact || cv.author)
+              ? a({ href: `/author/${encodeURIComponent(cv.contact || cv.author)}`, class: 'inhabitant-qr-link' },
+                  img({ class: 'inhabitant-qr-small', src: `/qr/${encodeURIComponent(cv.contact || cv.author)}?size=120`, alt: 'QR' }))
+              : null,
             cv.contact ? p(userLink(cv.contact)) : null,
             cv.description ? p(...renderUrl(`${cv.description}`)) : null,
             (cv.personalSkills && cv.personalSkills.length)

+ 17 - 3
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, userLink} = require("./main_views");
+const { template, i18n, userLink, renderSpreadButton} = require("./main_views");
 const { config } = require("../server/SSB_server.js");
 const { renderUrl } = require("../backend/renderUrl");
 const opinionCategories = require("../backend/opinion_categories");
@@ -141,7 +141,7 @@ const renderDocumentCommentsSection = (documentKey, rootId, comments = [], retur
   );
 };
 
-const renderDocumentList = (documents, filter, params = {}) => {
+const renderDocumentList = exports.renderDocumentList = (documents, filter, params = {}) => {
   const returnTo = buildReturnTo(filter, params);
 
   return documents.length
@@ -282,7 +282,15 @@ exports.documentView = async (documents, filter = "all", documentId = null, para
   const tpl = template(
     title,
     section(
-      div({ class: "tags-header" }, h2(title), p(i18n.documentDescription)),
+      div({ class: "tags-header" },
+        h2(title),
+        p(i18n.documentDescription),
+        (() => {
+          const { renderReachChip } = require('./clearnet_view');
+          const isClearnet = !!(params.viewerPrefs && params.viewerPrefs.clearnetDocuments);
+          return div({ class: "shop-title-row" }, renderReachChip(isClearnet, i18n));
+        })()
+      ),
       div(
         { class: "filters" },
         form(
@@ -401,6 +409,11 @@ exports.singleDocumentView = async (doc, filter = "all", comments = [], params =
           ? div({ id: pdfId, class: "pdf-viewer-container", "data-pdf-url": `/blob/${encodeURIComponent(doc.url)}` })
           : p(i18n.documentNoFile),
         safeText(doc.description) ? p(...renderUrl(doc.description)) : null,
+        (() => {
+          const { renderReachChip } = require('./clearnet_view');
+          const isClearnet = !!(params.authorPrefs && params.authorPrefs.clearnetDocuments);
+          return div({ class: 'shop-title-row' }, renderReachChip(isClearnet, i18n));
+        })(),
         renderTags(doc.tags),
         br(),
         (() => {
@@ -420,6 +433,7 @@ exports.singleDocumentView = async (doc, filter = "all", comments = [], params =
               : null
           );
         })(),
+        div({ class: "spread-row" }, renderSpreadButton(doc.key, params.spreads)),
         div(
           { class: "voting-buttons" },
           opinionCategories.map((category) =>

+ 54 - 4
src/views/event_view.js

@@ -202,7 +202,7 @@ const renderEventCommentsSection = (eventId, comments = [], currentFilter = "all
   );
 };
 
-const renderEventItem = (e, filter) => {
+const renderEventItem = exports.renderEventItem = (e, filter) => {
   const currentFilter = filter || "all";
   const attendees = safeArray(e.attendees);
   const commentCount = typeof e.commentCount === "number" ? e.commentCount : 0;
@@ -246,9 +246,11 @@ const renderEventItem = (e, filter) => {
   );
 };
 
-exports.eventView = async (events, filter, eventId, returnTo) => {
+exports.eventView = async (events, filter, eventId, returnTo, params = {}) => {
   const list = Array.isArray(events) ? events : [events];
   const currentFilter = filter || "all";
+  const { renderReachChip: renderReachChipEvents } = require('./clearnet_view');
+  const viewerClearnetEvents = !!(params.viewerPrefs && params.viewerPrefs.clearnetEvents);
 
   const title =
     currentFilter === "mine" ? i18n.eventMineSectionTitle :
@@ -297,7 +299,11 @@ exports.eventView = async (events, filter, eventId, returnTo) => {
   return template(
     title,
     section(
-      div({ class: "tags-header" }, h2(i18n.eventsTitle), p(i18n.eventsDescription)),
+      div({ class: "tags-header" },
+        h2(i18n.eventsTitle),
+        p(i18n.eventsDescription),
+        div({ class: "shop-title-row" }, renderReachChipEvents(viewerClearnetEvents, i18n))
+      ),
       div(
         { class: "filters" },
         form(
@@ -453,7 +459,14 @@ exports.singleEventView = async (event, filter, comments = [], params = {}) => {
       div(
         { class: "card card-section event" },
         topbar ? topbar : null,
-        renderCardField(i18n.eventTitleLabel + ":", event.title),
+        (() => {
+          const { renderReachChip } = require('./clearnet_view');
+          const isClearnet = !!(params.authorPrefs && params.authorPrefs.clearnetEvents && normalizeEventStatus(event.status) !== 'CLOSED' && normalizePrivacy(event.isPublic) === 'public');
+          return div({ class: "shop-title-row" },
+            renderCardField(i18n.eventTitleLabel + ":", event.title),
+            renderReachChip(isClearnet, i18n)
+          );
+        })(),
         renderCardField(i18n.eventDescriptionLabel + ":", ""),
         p(...renderUrl(event.description)),
         renderCardField(i18n.eventDateLabel + ":", event.date ? moment(event.date).format("YYYY/MM/DD HH:mm:ss") : ""),
@@ -495,3 +508,40 @@ exports.singleEventView = async (event, filter, comments = [], params = {}) => {
   );
 };
 
+exports.clearnetEventView = async (event) => {
+  const { escapeHtml: esc, renderClearnetPage } = require('./clearnet_view');
+  const title = esc(event.title || 'Event');
+  const desc = esc(event.description || '');
+  const dateStr = event.date ? esc(moment(event.date).format("YYYY-MM-DD HH:mm")) : '';
+  const loc = esc(event.location || '');
+  const price = parseFloat(event.price || 0);
+  const priceStr = price > 0 ? `${price.toFixed(2)} ECO` : '';
+  const urlHref = safeExternalHref(event.url);
+  const extraCss = `
+.cn-event-title{color:var(--fg);margin:0 0 16px 0;font-size:32px;font-weight:700}
+.cn-event-meta{display:flex;flex-wrap:wrap;gap:10px;margin-bottom:20px}
+.cn-event-meta-item{background:var(--bg-sub);border:1px solid var(--border);border-radius:6px;padding:8px 14px;font-size:14px;color:var(--fg-soft);display:inline-flex;align-items:center;gap:6px}
+.cn-event-meta-id{font-family:monospace;font-size:11px;word-break:break-all;max-width:100%}
+.cn-event-desc{color:var(--fg-soft);white-space:pre-wrap;line-height:1.6;font-size:15px;margin:0 0 20px 0}
+.cn-event-link{display:inline-block;margin-top:12px;background:var(--bg-sub);border:1px solid var(--fg);color:var(--fg);padding:8px 16px;border-radius:6px;font-weight:600}
+`;
+  const body = `
+  <h1 class="cn-event-title">${title}</h1>
+  <div class="cn-event-meta">
+    ${dateStr ? `<span class="cn-event-meta-item">📅 ${dateStr}</span>` : ''}
+    ${loc ? `<span class="cn-event-meta-item">📍 ${loc}</span>` : ''}
+    ${priceStr ? `<span class="cn-event-meta-item">💰 ${priceStr}</span>` : ''}
+  </div>
+  ${desc ? `<p class="cn-event-desc">${desc}</p>` : ''}
+  ${urlHref ? `<a class="cn-event-link" href="${esc(urlHref)}" target="_blank" rel="noopener noreferrer">More info →</a>` : ''}
+`;
+  return renderClearnetPage({
+    title: `${event.title || 'Event'} — Oasis`,
+    ogTitle: event.title || 'Event',
+    ogDescription: event.description || '',
+    extraCss,
+    body,
+    hubFeedId: event.organizer || null
+  });
+};
+

+ 3 - 2
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, userLink } = require('./main_views');
+const { template, i18n, userLink, renderSpreadButton } = require('./main_views');
 const { config } = require('../server/SSB_server.js');
 const { renderUrl } = require('../backend/renderUrl');
 const { renderTextWithStyles } = require('../backend/renderTextWithStyles');
@@ -326,7 +326,8 @@ exports.singleForumView = async (forum, messagesData, currentFilter) => {
               `${i18n.forumParticipants.toUpperCase()}: ${forum.participants?.length || 1}`),
             span({ class: 'forum-messages' },
               `${i18n.forumMessages.toUpperCase()}: ${messagesData.total}`)
-          )
+          ),
+          div({ class: 'spread-row' }, renderSpreadButton(forum.key))
         )
       ),
       div({

+ 2 - 1
src/views/graphos_view.js

@@ -68,7 +68,8 @@ const buildGraphSvg = (me, peers) => {
   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>`
+    + `<title>${meName} (you, online)</title>`
+    + `<circle cx="${cx}" cy="${cy}" r="${(meR + 5).toFixed(2)}" class="graphos-node-circle graphos-node-circle-online graphos-me-online-ring" />`
     + `<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>`;

+ 17 - 3
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, userLink} = require("./main_views");
+const { template, i18n, userLink, renderSpreadButton} = require("./main_views");
 const { config } = require("../server/SSB_server.js");
 const { renderUrl } = require("../backend/renderUrl")
 const { renderMapLocationVisitLabel } = require("./maps_view");
@@ -106,7 +106,7 @@ const renderImageOwnerActions = (filter, imgObj, params = {}) => {
   return items;
 };
 
-const renderImageList = (images, filter, params = {}) => {
+const renderImageList = exports.renderImageList = (images, filter, params = {}) => {
   const returnTo = buildReturnTo(filter, params);
   return images.length
     ? images.map((imgObj) => {
@@ -348,7 +348,15 @@ exports.imageView = async (images, filter = "all", imageId = null, params = {})
   return template(
     title,
     section(
-      div({ class: "tags-header" }, h2(title), p(i18n.imageDescription)),
+      div({ class: "tags-header" },
+        h2(title),
+        p(i18n.imageDescription),
+        (() => {
+          const { renderReachChip } = require('./clearnet_view');
+          const isClearnet = !!(params.viewerPrefs && params.viewerPrefs.clearnetImages);
+          return div({ class: "shop-title-row" }, renderReachChip(isClearnet, i18n));
+        })()
+      ),
       div(
         { class: "filters" },
         form(
@@ -466,6 +474,11 @@ exports.singleImageView = async (imageObj, filter = "all", comments = [], params
             )
           : p(i18n.imageNoFile),
         safeText(imageObj.description) ? p(...renderUrl(imageObj.description)) : null,
+        (() => {
+          const { renderReachChip } = require('./clearnet_view');
+          const isClearnet = !!(params.authorPrefs && params.authorPrefs.clearnetImages);
+          return div({ class: 'shop-title-row' }, renderReachChip(isClearnet, i18n));
+        })(),
         renderTags(imageObj.tags),
         br(),
         renderMapLocationVisitLabel(imageObj.mapUrl),
@@ -487,6 +500,7 @@ exports.singleImageView = async (imageObj, filter = "all", comments = [], params
               : null
           );
         })(),
+        div({ class: "spread-row" }, renderSpreadButton(imageObj.key, params.spreads)),
         div(
           { class: "voting-buttons" },
           opinionCategories.map((category) =>

+ 34 - 31
src/views/indexing_view.js

@@ -1,38 +1,41 @@
-const { html, head, title, link, meta, body, main, p, progress } = require("../server/node_modules/hyperaxe");
-const { i18n } = require('./main_views');
+const { a, br, div, h2, p, progress, section, span, strong, meta, head, html, link, title, body, main } = require("../server/node_modules/hyperaxe");
+const { template, i18n } = require('./main_views');
+const { getConfig } = require('../configs/config-manager.js');
 
 const doctypeString = '<!DOCTYPE html>';
 
-function toAttributes(attrs) {
-  return Object.entries(attrs).map(([key, value]) => `${key}=${JSON.stringify(value)}`).join(', ');
-}
-
 exports.indexingView = ({ percent }) => {
-  const message = `Oasis has only processed ${percent}% of the messages and needs to catch up. This page will refresh every 10 seconds. Thanks for your patience! ❤`;
-  const nodes = html(
-    { lang: "en" },
-    head(
-      title("Oasis"),
-      link({ rel: "icon", type: "image/svg+xml", href: "/assets/favicon.svg" }),
-      meta({ charset: "utf-8" }),
-      meta({
-        name: "description",
-        content: i18n.oasisDescription,
-      }),
-      meta({
-        name: "viewport",
-        content: "width=device-width, initial-scale=1",
-      }),
-      meta({ "http-equiv": "refresh", content: 10 })
-    ),
-    body(
-      main(
-        { id: "content" },
-        p(message),
-        progress({ value: percent, max: 100 })
+  let metaRefresh;
+  try {
+    const cfg = getConfig() || {};
+    const theme = cfg.themes?.current || 'Dark-SNH';
+    void theme;
+  } catch (_) {}
+
+  const pct = Math.max(0, Math.min(100, Number(percent) || 0));
+  const headingText = i18n.indexingTitle || 'Synchronizing';
+  const message = i18n.indexingMessage || 'Oasis is trying to syncronize a huge network of inhabitants. Just wait!';
+  const refreshNote = i18n.indexingRefreshNote || 'This page refreshes every 10 seconds.';
+  const editProfileLabel = i18n.indexingEditProfileLink || 'Set up my profile';
+  const welcomeLabel = i18n.indexingWelcomeLink || 'Tour of Oasis';
+
+  return template(
+    headingText,
+    section(
+      div({ class: 'tags-header' },
+        h2(`❤  ${headingText}`),
+        p(message)
+      ),
+      div({ class: 'indexing-progress-block' },
+        progress({ value: String(pct), max: '100', class: 'indexing-progress' }),
+        p({ class: 'indexing-percent' }, strong(`${pct.toFixed(1)} %`)),
+        p({ class: 'indexing-note' }, refreshNote)
+      ),
+      div({ class: 'indexing-actions' },
+        a({ href: '/profile/edit', class: 'filter-btn welcome-action-primary' }, editProfileLabel),
+        a({ href: '/welcome', class: 'filter-btn' }, welcomeLabel),
+        a({ href: '/modules', class: 'filter-btn' }, i18n.modulesTitle || 'Modules')
       )
     )
-  );
-  return doctypeString + nodes.outerHTML;
+  ).replace('</head>', '<meta http-equiv="refresh" content="10"></head>');
 };
-

+ 148 - 30
src/views/inhabitants_view.js

@@ -6,6 +6,15 @@ const { getConfig } = require('../configs/config-manager');
 const DEFAULT_HASH_ENC = "%260000000000000000000000000000000000000000000%3D.sha256";
 const DEFAULT_HASH_PATH_RE = /\/image\/\d+\/%260000000000000000000000000000000000000000000%3D\.sha256$/;
 
+const formatCarbonValue = (g) => {
+  const n = Number(g) || 0;
+  if (!n) return '0 µg CO₂';
+  if (n >= 1) return `${n.toFixed(2)} g CO₂`;
+  const mg = n * 1000;
+  if (mg >= 1) return `${mg.toFixed(2)} mg CO₂`;
+  return `${(mg * 1000).toFixed(2)} µg CO₂`;
+};
+
 function isDefaultImageId(v){
   if (!v) return true;
   if (typeof v === 'string') {
@@ -81,6 +90,37 @@ const lightboxId = (id) => 'inhabitant_' + String(id || 'unknown').replace(/[^a-
 
 const renderInhabitantCard = (user, filter, currentUserId) => {
   const isMe = user.id === currentUserId;
+  const raw = user.visibilityPrefs || {};
+  const prefs = {
+    activity: raw.activity === true,
+    device:   raw.device   === true,
+    karma:    raw.karma !== false,
+    ubi:      raw.ubi      === true,
+    wallet:   raw.wallet   === true,
+    ecoTax:   raw.ecoTax   !== false
+  };
+  const dot = user.lastActivityBucket;
+  const activityChip = prefs.activity && dot
+    ? span({ class: 'inhabitant-last-activity' },
+        `${i18n.inhabitantActivityLevel}: `,
+        span({ class: `activity-dot ${dot}` }, '●'))
+    : null;
+  let deviceChip = null;
+  if (prefs.device) {
+    const src = isMe
+      ? (getConfig().themes.current === 'OasisKIT' ? 'KIT' : (getConfig().themes.current === 'OasisMobile' || process.env.OASIS_MOBILE === '1') ? 'MOBILE' : 'DESKTOP')
+      : user.deviceSource;
+    if (src) {
+      const upper = String(src).toUpperCase();
+      const deviceClass = upper === 'KIT' ? 'device-kit' : upper === 'MOBILE' ? 'device-mobile' : 'device-desktop';
+      deviceChip = span({ class: 'inhabitant-last-activity' },
+        `${i18n.deviceLabel || 'Device'}: `,
+        span({ class: deviceClass }, src));
+    }
+  }
+  const activityGroup = (activityChip || deviceChip)
+    ? div({ class: 'inhabitant-activity-group' }, activityChip, deviceChip)
+    : null;
   return div({ class: 'inhabitant-card' },
     div({ class: 'inhabitant-left' },
       a(
@@ -88,32 +128,15 @@ const renderInhabitantCard = (user, filter, currentUserId) => {
          img({ class: 'inhabitant-photo-details', src: resolvePhoto(user.photo, 256), alt: user.name || 'Anonymous' })
       ),
       br(),
-      ...lastActivityBadge(user, isMe),
-      div({ class: 'inhabitant-karma-ubi' },
-        span({ class: 'karma-line' }, `${i18n.bankingUserEngagementScore}: `, strong(String(typeof user.karmaScore === 'number' ? user.karmaScore : 0)))
-      )
-    ),
-    div({ class: 'inhabitant-details' },
-      h2(user.name || 'Anonymous'),
-      user.description ? p(...renderUrl(user.description)) : null,
-      filter === 'MATCHSKILLS' && user.commonSkills?.length
-        ? div({ class: 'matchskills' },
-            p(`${i18n.commonSkills}: ${user.commonSkills.join(', ')}`),
-            p(`${i18n.matchScore}: ${Math.round(user.matchScore * 100)}%`)
+      activityGroup,
+      (prefs.karma || prefs.ubi || prefs.wallet || prefs.ecoTax)
+        ? div({ class: 'inhabitant-karma-ubi' },
+            prefs.ecoTax ? span({ class: 'karma-line eco-tax-line' }, `${i18n.profileVisibilityEcoTax || 'ECO Tax'}: `, strong(formatCarbonValue(user.carbonGrams))) : null,
+            prefs.karma ? span({ class: 'karma-line' }, `${i18n.bankingUserEngagementScore}: `, strong(String(typeof user.karmaScore === 'number' ? user.karmaScore : 0))) : null,
+            prefs.ubi ? span({ class: 'ubi-line' }, `${i18n.bankUbiThisMonth || 'UBI'}: `, strong(`${Number(user.estimatedUBI || 0).toFixed(6)} ECO`)) : null,
+            prefs.wallet ? span({ class: 'ubi-line' }, `${i18n.statsEcoWalletLabel || 'ECOin Wallet'}: `, strong(user.ecoAddress || (i18n.statsEcoWalletNotConfigured || 'Not configured!'))) : null
           )
         : null,
-      filter === 'SUGGESTED' && user.mutualCount
-        ? p(`${i18n.mutualFollowers}: ${user.mutualCount}`) : null,
-      filter === 'blocked' && user.isBlocked
-        ? p(i18n.blockedLabel) : null,
-      p(userLink(user.id)),
-      user.ecoAddress
-        ? div({ class: "eco-wallet" },
-            p(`${i18n.bankWalletConnected}: `, strong(user.ecoAddress))
-          )
-        : div({ class: "eco-wallet" },
-            p(i18n.ecoWalletNotConfigured || "ECOin Wallet not configured")
-          ),
       div(
         { class: 'cv-actions' },
         !isMe
@@ -130,6 +153,53 @@ const renderInhabitantCard = (user, filter, currentUserId) => {
             )
           : null
       )
+    ),
+    div({ class: 'inhabitant-details' },
+      h2(user.name || 'Anonymous'),
+      user.description ? p(...renderUrl(user.description)) : null,
+      filter === 'MATCHSKILLS' && user.commonSkills?.length
+        ? div({ class: 'matchskills' },
+            p(`${i18n.commonSkills}: ${user.commonSkills.join(', ')}`),
+            p(`${i18n.matchScore}: ${Math.round((user.matchScore || 0) * 100)}%`)
+          )
+        : null,
+      filter === 'SUGGESTED'
+        ? div({ class: 'suggested-meta' },
+            user.followsYou ? span({ class: 'suggested-badge' }, i18n.suggestedFollowsYou || 'Follows you') : null,
+            user.commonSkills?.length
+              ? p(`${i18n.commonSkills || 'Common skills'}: ${user.commonSkills.join(', ')}`)
+              : null,
+            user.mutualCount ? p(`${i18n.mutualFollowers}: ${user.mutualCount}`) : null
+          )
+        : null,
+      filter === 'blocked' && user.isBlocked
+        ? p(i18n.blockedLabel) : null,
+      p(userLink(user.id)),
+      !isMe ? (() => {
+        const rel = user.relationship || {}
+        const blockedBoth = rel.blocking && rel.blockedBy
+        const mutual = rel.following && rel.followsMe
+        const supportAction = rel.following ? 'unfollow' : (rel.blocking ? 'unblock' : 'follow')
+        return div({ class: 'relationship-status inhabitant-relationship' },
+          blockedBoth
+            ? span({ class: 'status blocked' }, i18n.relationshipMutualBlock)
+            : [
+                rel.blocking ? span({ class: 'status blocked' }, i18n.relationshipBlocking) : null,
+                rel.blockedBy ? span({ class: 'status blocked-by' }, i18n.relationshipBlockedBy) : null,
+                mutual
+                  ? span({ class: 'status mutual' }, i18n.relationshipMutuals)
+                  : [
+                      span({ class: 'status supporting' }, rel.following ? i18n.relationshipFollowing : i18n.relationshipNone),
+                      span({ class: 'status supported-by' }, rel.followsMe ? i18n.relationshipTheyFollow : i18n.relationshipNotFollowing)
+                    ]
+              ],
+          div({ class: 'relationship-actions' },
+            form({ method: 'POST', action: `/${supportAction}/${encodeURIComponent(user.id)}` },
+              button({ type: 'submit', class: 'btn' }, i18n[supportAction])
+            )
+          )
+        )
+      })() : null
     )
   );
 };
@@ -178,11 +248,12 @@ exports.inhabitantsView = (inhabitants, filter, query, currentUserId) => {
                : filter === 'blocked'     ? i18n.blockedSectionTitle
                : filter === 'GALLERY'     ? i18n.gallerySectionTitle
                : filter === 'TOP KARMA'    ? i18n.topkarmaSectionTitle
+               : filter === 'TOP ECO'      ? (i18n.topecoSectionTitle || 'Top Eco')
                : filter === 'TOP ACTIVITY' ? i18n.topactivitySectionTitle
                : i18n.allInhabitants;
 
   const showCVFilters = filter === 'CVs' || filter === 'MATCHSKILLS';
-  const filters = ['all', 'TOP ACTIVITY', 'TOP KARMA', 'contacts', 'SUGGESTED', 'blocked', 'CVs', 'MATCHSKILLS', 'GALLERY'];
+  const filters = ['all', 'TOP ACTIVITY', 'TOP KARMA', 'TOP ECO', 'contacts', 'SUGGESTED', 'blocked', 'CVs', 'MATCHSKILLS', 'GALLERY'];
 
   return template(
     title,
@@ -265,6 +336,20 @@ 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 = typeof safe.estimatedUBI === 'number' ? safe.estimatedUBI : 0;
+  const lastClaimedDate = safe.lastClaimedDate || null;
+  const totalClaimed = typeof safe.totalClaimed === 'number' ? safe.totalClaimed : 0;
+  const ecoAddress = typeof safe.ecoAddress === 'string' ? safe.ecoAddress : null;
+  const rawPrefs = safe.visibilityPrefs || {};
+  const prefs = {
+    activity: rawPrefs.activity === true,
+    device:   rawPrefs.device   === true,
+    karma:    rawPrefs.karma !== false,
+    ubi:      rawPrefs.ubi      === true,
+    wallet:   rawPrefs.wallet   === true,
+    ecoTax:   rawPrefs.ecoTax   !== false
+  };
+  const carbonGrams = typeof safe.carbonGrams === 'number' ? safe.carbonGrams : 0;
 
   const providedBucket = typeof safe.lastActivityBucket === 'string' ? safe.lastActivityBucket : null;
   const dotClass = providedBucket === 'green' ? 'green' : providedBucket === 'orange' ? 'orange' : 'red';
@@ -287,16 +372,49 @@ exports.inhabitantsProfileView = (payload, currentUserId) => {
         p(i18n.discoverPeople)
       ),
       div({ class: 'mode-buttons' },
-        ...generateFilterButtons(['all', 'TOP ACTIVITY', 'TOP KARMA', 'contacts', 'SUGGESTED', 'blocked', 'CVs', 'MATCHSKILLS', 'GALLERY'], 'all')
+        ...generateFilterButtons(['all', 'TOP ACTIVITY', 'TOP KARMA', 'TOP ECO', 'contacts', 'SUGGESTED', 'blocked', 'CVs', 'MATCHSKILLS', 'GALLERY'], 'all')
       ),
       div({ class: 'inhabitant-card' },
         div({ class: 'inhabitant-left' },
           img({ class: 'inhabitant-photo-details', src: image, alt: name || 'Anonymous' }),
           h2(name || 'Anonymous'),
-          ...lastActivityBadge({ lastActivityBucket: dotClass, deviceSource: safe.deviceSource }, isMe),
-          div({ class: 'inhabitant-karma-ubi' },
-            span({ class: 'karma-line' }, `${i18n.bankingUserEngagementScore}: `, strong(String(karmaScore)))
-          ),
+          (() => {
+            const activityChip = prefs.activity
+              ? span({ class: 'inhabitant-last-activity' },
+                  `${i18n.inhabitantActivityLevel}: `,
+                  span({ class: `activity-dot ${dotClass}` }, '●'))
+              : null;
+            let deviceChip = null;
+            if (prefs.device) {
+              const src = isMe
+                ? (getConfig().themes.current === 'OasisKIT' ? 'KIT' : (getConfig().themes.current === 'OasisMobile' || process.env.OASIS_MOBILE === '1') ? 'MOBILE' : 'DESKTOP')
+                : safe.deviceSource;
+              if (src) {
+                const upper = String(src).toUpperCase();
+                const deviceClass = upper === 'KIT' ? 'device-kit' : upper === 'MOBILE' ? 'device-mobile' : 'device-desktop';
+                deviceChip = span({ class: 'inhabitant-last-activity' },
+                  `${i18n.deviceLabel || 'Device'}: `,
+                  span({ class: deviceClass }, src));
+              }
+            }
+            return (activityChip || deviceChip)
+              ? div({ class: 'inhabitant-activity-group' }, activityChip, deviceChip)
+              : null;
+          })(),
+          (prefs.karma || prefs.ubi || prefs.ecoTax)
+            ? div({ class: 'inhabitant-karma-ubi' },
+                prefs.ecoTax ? span({ class: 'karma-line eco-tax-line' }, `${i18n.profileVisibilityEcoTax || 'ECO Tax'}: `, strong(formatCarbonValue(carbonGrams))) : null,
+                prefs.karma ? span({ class: 'karma-line' }, `${i18n.bankingUserEngagementScore}: `, strong(String(karmaScore))) : null,
+                prefs.ubi ? span({ class: 'ubi-line' }, `${i18n.bankUbiThisMonth || 'UBI'}: `, strong(`${Number(estimatedUBI || 0).toFixed(6)} ECO`)) : null,
+                prefs.ubi ? span({ class: 'ubi-line' }, `${i18n.bankUbiLastClaimed || 'Last claimed'}: `, lastClaimedDate ? new Date(lastClaimedDate).toLocaleDateString() : strong(i18n.bankUbiNeverClaimed || 'Never claimed')) : null,
+                prefs.ubi ? span({ class: 'ubi-line' }, `${i18n.bankUbiTotalClaimed || 'Total claimed'}: `, strong(`${Number(totalClaimed || 0).toFixed(6)} ECO`)) : null
+              )
+            : null,
+          (prefs.wallet && ecoAddress)
+            ? div({ class: 'eco-wallet' },
+                p(`${i18n.statsEcoWalletLabel || 'ECOin Wallet'}: `, a({ href: '/wallet' }, ecoAddress))
+              )
+            : null,
           (!isMe && (id || viewedId))
             ? form(
                 { method: 'GET', action: '/pm' },

+ 52 - 3
src/views/invites_view.js

@@ -1,4 +1,4 @@
-const { form, button, div, h2, h3, p, section, ul, li, a, br, hr, input, span, table, tr, td } = require("../server/node_modules/hyperaxe");
+const { form, button, div, h2, h3, p, section, ul, li, a, br, hr, input, label, span, table, tr, td, textarea } = require("../server/node_modules/hyperaxe");
 const path = require("path");
 const fs = require('fs');
 const { renderUrl } = require("../backend/renderUrl");
@@ -101,6 +101,36 @@ const invitesView = ({ invitesEnabled }) => {
         p(description)
       )
     ),
+    section(
+      div({ class: 'invites-peers' },
+        h2(i18n.peers || 'Peers'),
+        p(i18n.directConnectDescription),
+        form({ action: '/peers/connect', method: 'post' },
+          input({ type: 'text', id: 'peer_host', name: 'host', required: true, placeholder: '192.168.1.100', pattern: '(\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}|[a-zA-Z0-9]([a-zA-Z0-9\\-]*[a-zA-Z0-9])?(\\.[a-zA-Z0-9]([a-zA-Z0-9\\-]*[a-zA-Z0-9])?)*)', title: i18n.peerHostValidation || 'Valid IPv4 (e.g. 192.168.1.100) or hostname (e.g. pub.example.com)', maxlength: 253 }),
+          br(),
+          label({ for: 'peer_port' }, i18n.peerPort),
+          br(),
+          input({ type: 'number', id: 'peer_port', name: 'port', placeholder: '8008', value: '8008', min: 1, max: 65535, required: true, title: i18n.peerPortValidation || 'Port 1-65535' }),
+          br(), br(),
+          label({ for: 'peer_key' }, i18n.peerPublicKey),
+          br(),
+          input({ type: 'text', id: 'peer_key', name: 'key', required: true, placeholder: '@...=.ed25519', pattern: '@[A-Za-z0-9+/_\\-]{43}=\\.ed25519', title: i18n.peerKeyValidation || 'SSB ed25519 public key (@<44 chars base64>=.ed25519)', maxlength: 56 }),
+          br(), br(),
+          button({ type: 'submit' }, i18n.connectAndFollow)
+        )
+      )
+    ),
+    section(
+      div({ class: 'invites-inhabitants' },
+        h2(i18n.invitesInhabitantsTitle || 'Inhabitants'),
+        form(
+          { action: '/invites/inhabitant/follow', method: 'post' },
+          input({ name: 'feedId', id: 'inh_oasis_id', type: 'text', placeholder: '@...=.ed25519', pattern: '@[A-Za-z0-9+/_\\-]{43}=\\.ed25519', required: true, maxlength: 56 }),
+          br(),
+          button({ type: 'submit' }, i18n.invitesInhabitantsFollow || 'Follow')
+        )
+      )
+    ),
     section(
       div({ class: 'invites-tribes' },
         h2(i18n.invitesTribesTitle),
@@ -131,8 +161,27 @@ const invitesView = ({ invitesEnabled }) => {
             input({ type: 'hidden', name: 'invite', value: snhInvite.code }),
             button({ type: 'submit', class: 'filter-btn' }, snhInvite.code)
           )
-        ) : null,
-        hr(),
+        ) : null
+      )
+    ),
+    section(
+      div({ class: 'federations-section' },
+        h2(i18n.invitesFederationsTitle || 'Federations'),
+        div({ class: 'conn-actions invites-pubs-actions' },
+          form({ action: '/invites/refresh-pubs', method: 'post' }, button({ type: 'submit' }, i18n.invitesPubsRefresh || 'Refresh')),
+          form({ action: '/invites/clear-unreachable', method: 'post' }, button({ type: 'submit' }, i18n.invitesPubsClearUnreachable || 'Remove unreachable')),
+          form({ action: '/invites/export-pubs', method: 'get' }, button({ type: 'submit' }, i18n.invitesPubsExport || 'Export'))
+        ),
+        form(
+          { action: '/invites/import-pubs', method: 'post', enctype: 'multipart/form-data', class: 'peers-import-form' },
+          label({ class: 'peers-import-label' }, i18n.invitesPubsImportTitle || 'Import pubs'),
+          br(),
+          textarea({ name: 'peerList', rows: '4', placeholder: i18n.invitesPubsImportPlaceholder || 'Paste one multiserver address or invite code per line…' }),
+          br(),
+          input({ type: 'file', name: 'peerFile', accept: '.txt,text/plain' }),
+          br(),
+          button({ type: 'submit', class: 'filter-btn' }, i18n.invitesPubsImport || 'Import')
+        ),
         h2(`${i18n.invitesAcceptedInvites} (${activePubs.length})`),
         activePubs.length
           ? renderPubTable(activePubs, pubItem =>

+ 201 - 8
src/views/jobs_view.js

@@ -29,6 +29,7 @@ const FILTERS = [
   { key: "PRESENCIAL", i18n: "jobsFilterPresencial", title: "jobsPresencialTitle" },
   { key: "FREELANCER", i18n: "jobsFilterFreelancer", title: "jobsFreelancerTitle" },
   { key: "EMPLOYEE", i18n: "jobsFilterEmployee", title: "jobsEmployeeTitle" },
+  { key: "EXCHANGE", i18n: "jobsFilterExchange", title: "jobsExchangeTitle" },
   { key: "OPEN", i18n: "jobsFilterOpen", title: "jobsOpenTitle" },
   { key: "CLOSED", i18n: "jobsFilterClosed", title: "jobsClosedTitle" },
   { key: "RECENT", i18n: "jobsFilterRecent", title: "jobsRecentTitle" },
@@ -92,6 +93,34 @@ const renderCardFieldRich = (labelText, parts) =>
     span({ class: "card-value" }, ...(Array.isArray(parts) ? parts : [String(parts ?? "")]))
   )
 
+const renderLifespanField = (item) => {
+  const lt = item && item.lifetime;
+  if (!lt || !lt.bucket) return null;
+  const labelText = i18n.lifespanLabel || 'Lifespan';
+  return div(
+    { class: "card-field" },
+    span({ class: "card-label" }, `${labelText}:`),
+    span({ class: "card-value" },
+      span({ class: `activity-dot ${lt.bucket}` }, '●')
+    )
+  );
+};
+
+const renderCompensation = (job) => {
+  if (String(job.job_type || "").toLowerCase() === "exchange") {
+    const offered = Number(job.hoursOffered) || 0;
+    const requested = Number(job.hoursRequested) || 0;
+    const skill = String(job.exchangeSkill || "").trim();
+    return [
+      renderCardField(`${i18n.jobsHoursOffered || 'Hours offered'}:`, `${offered} h`),
+      renderCardField(`${i18n.jobsHoursRequested || 'Hours requested'}:`, `${requested} h`),
+      skill ? renderCardField(`${i18n.jobsExchangeSkill || 'Skill wanted in exchange'}:`, skill) : null
+    ];
+  }
+  const salaryText = `${fmtSalary(job.salary)} ECO`;
+  return [renderCardFieldRich(`${i18n.jobSalary}:`, [span({ class: "card-salary" }, salaryText)])];
+}
+
 const renderTags = (tags = []) => {
   const arr = safeArr(tags).map((t) => String(t || "").trim()).filter(Boolean)
   return arr.length
@@ -221,7 +250,7 @@ const renderJobTopbar = (job, filter, params = {}) => {
   return topbarChildren.length ? div({ class: topbarClass }, ...topbarChildren) : null
 }
 
-const renderJobList = (jobs, filter, params = {}) => {
+const renderJobList = exports.renderJobList = (jobs, filter, params = {}) => {
   const returnTo = buildReturnTo(filter, params)
   const list = safeArr(jobs)
 
@@ -246,7 +275,8 @@ const renderJobList = (jobs, filter, params = {}) => {
           renderCardField(`${i18n.jobLocation}:`, String(job.location || "").toUpperCase()),
           renderMapLocationVisitLabel(job.mapUrl),
           renderCardField(`${i18n.jobTime}:`, i18n["jobTime" + String(job.job_time || "").toUpperCase()] || String(job.job_time || "").toUpperCase()),
-          renderCardFieldRich(`${i18n.jobSalary}:`, [span({ class: "card-salary" }, salaryText)]),
+          ...renderCompensation(job),
+          renderLifespanField(job),
           br(),
           div(
             { class: "card-comments-summary" },
@@ -291,8 +321,9 @@ const renderJobForm = (job = {}, mode = "create") => {
       br(),
       select(
         { name: "job_type", required: true },
-        option({ value: "freelancer", selected: job.job_type === "freelancer" }, i18n.jobTypeFreelance),
-        option({ value: "employee", selected: job.job_type === "employee" }, i18n.jobTypeSalary)
+        option({ value: "freelancer", selected: job.job_type === "freelancer" ? "selected" : undefined }, i18n.jobTypeFreelance),
+        option({ value: "employee", selected: job.job_type === "employee" ? "selected" : undefined }, i18n.jobTypeSalary),
+        option({ value: "exchange", selected: job.job_type === "exchange" ? "selected" : undefined }, i18n.jobTypeExchange || "Hour exchange")
       ),
       br(),
       br(),
@@ -355,6 +386,15 @@ const renderJobForm = (job = {}, mode = "create") => {
       input({ type: "text", name: "mapUrl", placeholder: i18n.mapUrlPlaceholder || "/maps/MAP_ID", value: job.mapUrl || "" }),
       br(),
       br(),
+      label(i18n.visibilityLabel || "Visibility"),
+      br(),
+      select(
+        { name: "visibility" },
+        option({ value: "PUBLIC", selected: (job.visibility || "PUBLIC") === "PUBLIC" }, i18n.visibilityPublic || "Public"),
+        option({ value: "HIDDEN", selected: job.visibility === "HIDDEN" }, i18n.visibilityHidden || "Hidden")
+      ),
+      br(),
+      br(),
       label(i18n.jobVacants),
       br(),
       input({ type: "number", name: "vacants", min: "1", placeholder: i18n.jobVacantsPlaceholder, value: job.vacants || 1, required: true }),
@@ -365,6 +405,21 @@ const renderJobForm = (job = {}, mode = "create") => {
       input({ type: "number", name: "salary", step: "0.000001", min: "0", placeholder: i18n.jobSalaryPlaceholder, value: job.salary || "" }),
       br(),
       br(),
+      label(i18n.jobsHoursOffered || "Hours offered"),
+      br(),
+      input({ type: "number", name: "hoursOffered", step: "0.5", min: "0", placeholder: i18n.jobsHoursOfferedPlaceholder || "e.g. 4", value: job.hoursOffered || "" }),
+      br(),
+      br(),
+      label(i18n.jobsHoursRequested || "Hours requested in return"),
+      br(),
+      input({ type: "number", name: "hoursRequested", step: "0.5", min: "0", placeholder: i18n.jobsHoursRequestedPlaceholder || "e.g. 4", value: job.hoursRequested || "" }),
+      br(),
+      br(),
+      label(i18n.jobsExchangeSkill || "Skill wanted in exchange"),
+      br(),
+      input({ type: "text", name: "exchangeSkill", placeholder: i18n.jobsExchangeSkillPlaceholder || "e.g. plumbing, carpentry, design", value: job.exchangeSkill || "" }),
+      br(),
+      br(),
       button({ type: "submit" }, isEdit ? i18n.jobsUpdateButton : i18n.createJobButton)
     )
   )
@@ -383,7 +438,11 @@ const renderCVList = (inhabitants) =>
               a({ href: `/author/${encodeURIComponent(user.id)}` },
                 img({ class: "inhabitant-photo", src: resolvePhoto(user.photo) })
               ),
-              h2(user.name)
+              h2(user.name),
+              user.id
+                ? a({ href: `/author/${encodeURIComponent(user.id)}`, class: 'inhabitant-qr-link' },
+                    img({ class: 'inhabitant-qr-small', src: `/qr/${encodeURIComponent(user.id)}?size=96`, alt: 'QR' }))
+                : null
             ),
             div(
               { class: "inhabitant-details" },
@@ -408,11 +467,17 @@ exports.jobsView = async (jobsOrCVs, filter = "ALL", params = {}) => {
 
   const filterObj = FILTERS.find((f) => f.key === filter) || FILTERS[0]
   const sectionTitle = i18n[filterObj.title] || i18n.jobsTitle
+  const { renderReachChip: renderReachChipJobs } = require('./clearnet_view');
+  const viewerClearnet = !!(params.viewerPrefs && params.viewerPrefs.clearnetJobs)
 
   return template(
     i18n.jobsTitle,
     section(
-      div({ class: "tags-header" }, h2(sectionTitle), p(i18n.jobsDescription)),
+      div({ class: "tags-header" },
+        h2(sectionTitle),
+        p(i18n.jobsDescription),
+        div({ class: "shop-title-row" }, renderReachChipJobs(viewerClearnet, i18n))
+      ),
       div(
         { class: "filters" },
         form(
@@ -537,12 +602,67 @@ const renderJobCommentsSection = (jobId, returnTo, comments = []) => {
   )
 }
 
+const renderCandidates = (candidates, jobId) => {
+  if (!Array.isArray(candidates) || candidates.length === 0) return null;
+  return div(
+    { class: "job-candidates" },
+    h2(i18n.jobsCandidatesTitle || "Suggested candidates"),
+    p(i18n.jobsCandidatesDescription || "Inhabitants with skills matching your job. Send them a private message to invite them."),
+    div(
+      { class: "inhabitants-list" },
+      candidates.map(c => div(
+        { class: "inhabitant-card" },
+        div(
+          { class: "inhabitant-left" },
+          a({ href: `/author/${encodeURIComponent(c.id)}` },
+            img({ class: "inhabitant-photo", src: resolvePhoto(c.photo) })
+          ),
+          h2(c.name || 'Anonymous'),
+          c.id
+            ? a({ href: `/author/${encodeURIComponent(c.id)}`, class: 'inhabitant-qr-link' },
+                img({ class: 'inhabitant-qr-small', src: `/qr/${encodeURIComponent(c.id)}?size=96`, alt: 'QR' }))
+            : null
+        ),
+        div(
+          { class: "inhabitant-details" },
+          c.description ? p(...renderUrl(c.description)) : null,
+          p(userLink(c.id)),
+          div({ class: "matchskills" },
+            p(`${i18n.matchScore || 'Match score'}: ${Math.round(c.matchScore * 100)}%`),
+            p(`${i18n.commonSkills || 'Common skills'}: ${(c.commonSkills || []).join(', ')}`)
+          ),
+          c.location ? p(`${i18n.locationLabel || 'Location'}: ${c.location}`) : null,
+          c.status ? p(`${i18n.statusLabel || 'Status'}: ${c.status}`) : null,
+          c.preferences ? p(`${i18n.preferencesLabel || 'Preferences'}: ${c.preferences}`) : null,
+          div({ class: "cv-actions" },
+            form({ method: 'GET', action: `/inhabitant/${encodeURIComponent(c.id)}` },
+              button({ type: 'submit', class: 'filter-btn' }, i18n.inhabitantviewDetails)
+            ),
+            form({ method: 'GET', action: '/pm' },
+              input({ type: 'hidden', name: 'recipients', value: c.id }),
+              input({ type: 'hidden', name: 'subject', value: `${i18n.jobsTitle || 'Job'}: ${job.title || ''}`.slice(0, 150) }),
+              input({ type: 'hidden', name: 'text', value: `${i18n.jobsCandidatesPmBody || 'Hi, I think your profile matches my job opening'}: /jobs/${jobId}` }),
+              button({ type: 'submit', class: 'filter-btn' }, i18n.pmCreateButton || 'Send PM')
+            )
+          )
+        )
+      ))
+    )
+  );
+};
+
 exports.singleJobsView = async (job, filter = "ALL", comments = [], params = {}) => {
   const returnTo = safeText(params.returnTo) || buildReturnTo(filter, params)
   const topbar = renderJobTopbar(job, filter, { ...params, single: true })
   const subs = safeArr(job.subscribers)
   const tagsNode = renderTags(job.tags)
   const salaryText = `${fmtSalary(job.salary)} ECO`
+  const candidatesBlock = (String(job.author) === String(userId))
+    ? renderCandidates(params.candidates || [], job.id)
+    : null;
+  const isAuthor = String(job.author) === String(userId);
+  const { renderReachChip } = require('./clearnet_view');
+  const isClearnet = !!(params.authorPrefs && params.authorPrefs.clearnetJobs && String(job.status || '').toUpperCase() !== 'CLOSED' && String(job.visibility || 'PUBLIC').toUpperCase() !== 'HIDDEN');
 
   return template(
     i18n.jobsTitle,
@@ -564,11 +684,14 @@ exports.singleJobsView = async (job, filter = "ALL", comments = [], params = {})
       div(
         { class: "job-card" },
         topbar ? topbar : null,
-        safeText(job.title) ? h2(job.title) : null,
+        safeText(job.title)
+          ? div({ class: "shop-title-row" }, h2(job.title), renderReachChip(isClearnet, i18n))
+          : null,
         job.image ? div({ class: "activity-image-preview" }, renderMediaBlob(job.image)) : null,
         safeText(job.description) ? renderCardFieldRich(`${i18n.jobDescription}:`, renderUrl(job.description)) : null,
         renderCardField(`${i18n.jobStatus}:`, i18n["jobStatus" + String(job.status || "").toUpperCase()] || String(job.status || "").toUpperCase()),
-        renderCardFieldRich(`${i18n.jobSalary}:`, [span({ class: "card-salary" }, salaryText)]),
+        ...renderCompensation(job),
+        renderLifespanField(job),
         renderCardField(`${i18n.jobVacants}:`, job.vacants),
         renderCardField(`${i18n.jobLanguages}:`, String(job.languages || "").toUpperCase()),
         renderCardField(`${i18n.jobType}:`, i18n["jobType" + String(job.job_type || "").toUpperCase()] || String(job.job_type || "").toUpperCase()),
@@ -582,6 +705,25 @@ exports.singleJobsView = async (job, filter = "ALL", comments = [], params = {})
         br(),
         tagsNode ? tagsNode : null,
         br(),
+        String(job.author) === String(userId)
+          ? (() => {
+              const vis = (job.visibility || 'PUBLIC').toUpperCase() === 'HIDDEN' ? 'HIDDEN' : 'PUBLIC';
+              const next = vis === 'PUBLIC' ? 'HIDDEN' : 'PUBLIC';
+              return div({ class: "card-field" },
+                span({ class: "card-label" }, `${i18n.visibilityLabel || 'Visibility'}: `),
+                span({ class: vis === 'PUBLIC' ? 'visibility-public' : 'visibility-hidden' },
+                  vis === 'PUBLIC' ? (i18n.visibilityPublic || 'Public') : (i18n.visibilityHidden || 'Hidden')
+                ),
+                " ",
+                form({ method: "POST", action: `/jobs/visibility/${encodeURIComponent(job.id)}`, class: "inline-form" },
+                  input({ type: "hidden", name: "visibility", value: next }),
+                  button({ type: "submit", class: "filter-btn" },
+                    next === 'PUBLIC' ? (i18n.visibilityMakePublic || 'Make public') : (i18n.visibilityMakeHidden || 'Make hidden')
+                  )
+                )
+              );
+            })()
+          : null,
         p(
           { class: "card-footer" },
           span({ class: "date-link" }, `${moment(job.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
@@ -589,8 +731,59 @@ exports.singleJobsView = async (job, filter = "ALL", comments = [], params = {})
           renderUpdatedLabel(job.createdAt, job.updatedAt)
         )
       ),
+      candidatesBlock ? candidatesBlock : null,
       div({ id: "comments" }, renderJobCommentsSection(job.id, returnTo, comments))
     )
   )
 }
 
+exports.clearnetJobView = async (job) => {
+  const { escapeHtml: esc, blobUrl: cnBlob, renderClearnetPage } = require('./clearnet_view');
+  const title = esc(job.title || 'Job');
+  const desc = esc(job.description || '');
+  const req = esc(job.requirements || '');
+  const lang = esc(String(job.languages || '').toUpperCase());
+  const loc = esc(String(job.location || '').toUpperCase());
+  const jobType = String(job.job_type || '').toLowerCase();
+  const jobTypeLabel = jobType === 'exchange' ? 'Hour exchange' : jobType === 'employee' ? 'Employee' : 'Freelancer';
+  let compensation = '';
+  if (jobType === 'exchange') {
+    compensation = `${Number(job.hoursOffered || 0)}h offered · ${Number(job.hoursRequested || 0)}h requested`;
+    if (job.exchangeSkill) compensation += ` · ${esc(job.exchangeSkill)}`;
+  } else {
+    compensation = `${parseFloat(job.salary || 0).toFixed(2)} ECO`;
+  }
+  const jobImg = cnBlob(job.image);
+  const extraCss = `
+.cn-job-title{color:var(--fg);margin:0 0 16px 0;font-size:32px;font-weight:700}
+.cn-job-meta{display:flex;flex-wrap:wrap;gap:10px;margin-bottom:20px}
+.cn-job-meta-item{background:var(--bg-sub);border:1px solid var(--border);border-radius:6px;padding:8px 14px;font-size:14px;color:var(--fg-soft);display:inline-flex;align-items:center;gap:6px}
+.cn-job-comp{background:var(--bg-sub);border:1px solid var(--fg);color:var(--fg);padding:8px 16px;border-radius:6px;font-weight:600;display:inline-block;margin-bottom:20px}
+.cn-job-img{display:block;max-width:100%;border:1px solid var(--border);border-radius:8px;margin-bottom:20px}
+.cn-job-section h2{color:var(--fg);font-size:18px;text-transform:uppercase;letter-spacing:2px;margin:24px 0 10px;padding-bottom:6px;border-bottom:1px solid var(--border)}
+.cn-job-section p{color:var(--fg-soft);white-space:pre-wrap;line-height:1.6;font-size:15px}
+`;
+  const body = `
+  <h1 class="cn-job-title">${title}</h1>
+  <div class="cn-job-meta">
+    <span class="cn-job-meta-item">💼 ${jobTypeLabel}</span>
+    ${job.createdAt ? `<span class="cn-job-meta-item">📅 ${esc(new Date(job.createdAt).toISOString().slice(0,10))}</span>` : ''}
+    ${loc ? `<span class="cn-job-meta-item">📍 ${loc}</span>` : ''}
+    ${lang ? `<span class="cn-job-meta-item">🗣 ${lang}</span>` : ''}
+  </div>
+  <div class="cn-job-comp">${compensation}</div>
+  ${jobImg ? `<img class="cn-job-img" src="${jobImg}" alt="${title}"/>` : ''}
+  ${desc ? `<div class="cn-job-section"><h2>Description</h2><p>${desc}</p></div>` : ''}
+  ${req ? `<div class="cn-job-section"><h2>Requirements</h2><p>${req}</p></div>` : ''}
+`;
+  return renderClearnetPage({
+    title: `${job.title || 'Job'} — Oasis`,
+    ogTitle: job.title || 'Job',
+    ogDescription: job.description || '',
+    ogImage: jobImg,
+    extraCss,
+    body,
+    hubFeedId: job.author || null
+  });
+};
+

+ 11 - 8
src/views/logs_view.js

@@ -18,7 +18,7 @@ const filterLabel = (f) => {
   return map[f] || f.toUpperCase();
 };
 
-const renderFilterBar = (current) =>
+const renderFilterBar = (current, hasItems = false) =>
   div({ class: "logs-toolbar" },
     form({ method: "GET", action: "/logs", class: "logs-toolbar-inline" },
       FILTERS.map(f =>
@@ -32,9 +32,11 @@ const renderFilterBar = (current) =>
       input({ type: "hidden", name: "view", value: "create" }),
       button({ type: "submit", class: "create-button" }, i18n.logsCreate || 'Create Log')
     ),
-    form({ method: "GET", action: "/logs/export", class: "logs-toolbar-inline" },
-      button({ type: "submit", class: "create-button" }, i18n.logsExport || 'Export Logs')
-    )
+    hasItems
+      ? form({ method: "GET", action: "/logs/export", class: "logs-toolbar-inline" },
+          button({ type: "submit", class: "create-button" }, i18n.logsExport || 'Export Logs')
+        )
+      : null
   );
 
 const renderSearchBox = (current, search) => {
@@ -63,9 +65,9 @@ const renderSearchBox = (current, search) => {
   );
 };
 
-const renderToolbar = (current, search) =>
+const renderToolbar = (current, search, hasItems) =>
   div({ class: "logs-toolbar-wrap" },
-    renderFilterBar(current),
+    renderFilterBar(current, hasItems),
     renderSearchBox(current, search)
   );
 
@@ -211,12 +213,13 @@ exports.logsView = (items, filter, mode, opts = {}) => {
   const description = i18n.logsDescription || 'Record your experience in the network.';
   const view = opts.view || 'list';
   const aiModOn = !!opts.aiModOn;
+  const hasItems = Array.isArray(items) && items.length > 0;
 
   if (view === 'create') {
     const h = i18n.logsCreateTitle || 'Create Log';
     const body = section(
       div({ class: "tags-header" }, h2(h), p(description)),
-      renderFilterBar(filter),
+      renderFilterBar(filter, hasItems),
       renderCreateForm(mode, aiModOn)
     );
     return template(h, body);
@@ -239,7 +242,7 @@ exports.logsView = (items, filter, mode, opts = {}) => {
   }
   const body = section(
     div({ class: "tags-header" }, h2(listTitle), p(description)),
-    renderToolbar(filter, opts.search || {}),
+    renderToolbar(filter, opts.search || {}, hasItems),
     div({ class: "logs-list" }, renderTable(items))
   );
   return template(listTitle, body);

+ 610 - 162
src/views/main_views.js

@@ -8,6 +8,7 @@ const debug = require("../server/node_modules/debug")("oasis");
 const highlightJs = require("../server/node_modules/highlight.js");
 const prettyMs = require("../server/node_modules/pretty-ms");
 const moment = require('../server/node_modules/moment');
+const QRCode = require('../server/node_modules/qrcode');
 const { renderUrl } = require('../backend/renderUrl');
 const ssbClientGUI = require("../client/gui");
 const config = require("../server/ssb_config");
@@ -62,6 +63,64 @@ const errorView = ({ title, message, backHref }) => {
 };
 exports.errorView = errorView;
 
+const renderSpreadButton = (msgKey, opts = {}) => {
+  if (!msgKey || typeof msgKey !== 'string') return null;
+  const voters = Array.isArray(opts.voters) ? opts.voters : [];
+  const count = typeof opts.count === 'number' ? opts.count : voters.length;
+  const alreadySpread = opts.alreadySpread === true;
+  const maxNames = 16;
+  const maxLen = 16;
+  const tooltipNames = voters.slice(0, maxNames)
+    .map(v => (v && typeof v === 'object' ? (v.name || v.key || '') : String(v || '')))
+    .filter(Boolean)
+    .map(n => n.slice(0, maxLen))
+    .join(', ');
+  const extra = count > maxNames ? ` +${count - maxNames} ${i18n.spreadMore || 'more'}` : '';
+  const tooltip = count > 0 ? `${tooltipNames}${extra}` : (i18n.spreadHint || 'Spread this to your followers (replicates via your feed).');
+  return form(
+    { method: 'POST', action: `/spread/${encodeURIComponent(msgKey)}`, class: 'spread-form' },
+    button(
+      { type: 'submit', class: alreadySpread ? 'spread-btn spread-btn-on' : 'spread-btn', title: tooltip },
+      `🔁 ${count}`
+    )
+  );
+};
+exports.renderSpreadButton = renderSpreadButton;
+
+const aiNavResultsView = ({ query, results }) => {
+  const title = i18n.aiNavResultsTitle || 'AI navigation results';
+  const safeQuery = String(query || '').trim();
+  const safeResults = Array.isArray(results) ? results : [];
+  const fmtScore = (s) => {
+    const n = Number(s);
+    return Number.isFinite(n) ? n.toFixed(2) : '—';
+  };
+  const splitTerms = (desc) => String(desc || '')
+    .split(/[,;]/)
+    .map(s => s.trim())
+    .filter(Boolean);
+  return exports.template(
+    title,
+    section(
+      div({ class: 'tags-header' },
+        h2(title),
+        safeQuery ? p({ class: 'ai-nav-query' }, `${i18n.aiNavQueryLabel || 'Query'}: "${safeQuery}"`) : null
+      ),
+      safeResults.length
+        ? div({ class: 'ai-nav-results' },
+            safeResults.map(r => div({ class: 'ai-nav-result-card card-section' },
+              div({ class: 'card-field' },
+                span({ class: 'card-label' }, `${(i18n.aiNavResultMatch || 'Match').toUpperCase()}: ${fmtScore(r.score)}`),
+                span({ class: 'card-value' }, a({ href: r.path, class: 'filter-btn' }, r.path))
+              )
+            ))
+          )
+        : p(i18n.aiNavResultsEmpty || 'No matching routes. Try /search instead.')
+    )
+  );
+};
+exports.aiNavResultsView = aiNavResultsView;
+
 const i18nBase = require("../client/assets/translations/i18n");
 let selectedLanguage = "en";
 let i18n = {};
@@ -108,16 +167,15 @@ const renderFooter = () => {
   const pkgName = pkg?.name || "@krakenslab/oasis";
   const pkgVersion = pkg?.version || "?";
 
-  let blockchainCycle = {};
-  try {
-    blockchainCycle = JSON.parse(fs.readFileSync(path.join(__dirname, "../configs/blockchain-cycle.json"), "utf8"));
-  } catch (_) {}
-  const cycleVal = blockchainCycle.cycle || "?";
-  const cycleUrl = blockchainCycle.url || "https://laplaza.solarnethub.com";
-
   const hcT = sharedState.getCarbonHcT();
   const hcH = sharedState.getCarbonHcH();
 
+  const peersOnline = sharedState.getOnlinePeerCount ? sharedState.getOnlinePeerCount() : null;
+  const inboxUnread = sharedState.getInboxUnreadCount ? sharedState.getInboxUnreadCount() : null;
+  const lastSyncTs = sharedState.getLastSyncTs ? sharedState.getLastSyncTs() : null;
+  const lastSyncLabel = lastSyncTs ? moment(lastSyncTs).fromNow() : '–';
+  const lastActivity = sharedState.getLastActivity ? sharedState.getLastActivity() : null;
+
   return div(
     { class: "oasis-footer" },
     div(
@@ -130,6 +188,21 @@ const renderFooter = () => {
           alt: "Oasis"
         })
       ),
+      (() => {
+        const myId = (config.keys && config.keys.id) ? config.keys.id : '';
+        if (!myId) return null;
+        return [
+          br(),
+          a({ href: "/profile" }, span(myId))
+        ];
+      })(),
+      br(),
+      span({ class: "oasis-footer-carbon" },
+        span("HcT: "),
+        a({ href: "/stats?filter=ALL" }, hcT != null ? String(hcT) : '–'),
+        span(" | HcH: "),
+        a({ href: "/stats?filter=MINE" }, hcH != null ? String(hcH) : '–')
+      ),
       br(),
       a(
         { href: "https://code.03c8.net/krakenslab/oasis", target: "_blank", rel: "noreferrer noopener" },
@@ -138,23 +211,14 @@ const renderFooter = () => {
       span("["),
          span({ class: "oasis-footer-version" }, pkgVersion),
       span("]"),
-      span({ class: "oasis-footer-sep" }, " - "),
+      br(),
+      span(`${i18n.footerLicenseLabel || 'License'}: `),
       a(
         { href: "https://www.gnu.org/licenses/gpl-3.0.html", target: "_blank", rel: "noreferrer noopener" },
         i18n.footerLicense
       ),
       span({ class: "oasis-footer-sep" }, " - "),
-      span({ class: "oasis-footer-year" }, year),
-      br(),
-      span("BLOCKCHAIN CYCLE: "),
-      a({ href: cycleUrl, target: "_blank", rel: "noreferrer noopener" }, String(cycleVal)),
-      br(),
-      span({ class: "oasis-footer-carbon" },
-        span("HcT: "),
-        a({ href: "/stats?filter=ALL" }, hcT != null ? String(hcT) : '–'),
-        span(" | HcH: "),
-        a({ href: "/stats?filter=MINE" }, hcH != null ? String(hcH) : '–')
-      )
+      span({ class: "oasis-footer-year" }, year)
     )
   );
 };
@@ -702,6 +766,20 @@ const renderPixeliaLink = () => {
     : "";
 };
 
+const renderMelodyLink = () => {
+  const melodyMod = getConfig().modules.melodyMod === "on";
+  return melodyMod
+    ? [
+        navLink({
+          href: "/melody",
+          emoji: "♪",
+          text: i18n.melodyTitle,
+          class: "melody-link enabled"
+        })
+      ]
+    : "";
+};
+
 const renderGamesLink = () => {
   const gamesMod = getConfig().modules.gamesMod === "on";
   return gamesMod
@@ -902,8 +980,9 @@ const template = (titlePrefix, ...elements) => {
           { class: "top-bar-right" },
           nav(
             ul(
-              renderTagsLink(),
-              navLink({ href: "/search", emoji: "ꔅ", text: i18n.searchTitle })
+              navLink({ href: "/search", emoji: "ꔅ", text: i18n.searchTitle }),
+              renderGraphosLink(),
+              navLink({ href: "/peers", emoji: "⧖", text: i18n.peers })
             )
           )
         )
@@ -1015,14 +1094,8 @@ const template = (titlePrefix, ...elements) => {
                   text: i18n.blockchain
                 }),
                 renderCipherLink(),
-                renderGraphosLink(),
                 renderInvitesLink(),
                 renderLegacyLink(),
-                navLink({
-                  href: "/peers",
-                  emoji: "⧖",
-                  text: i18n.peers
-                }),
                 navLink({
                   href: "/stats",
                   emoji: "ꕷ",
@@ -1048,6 +1121,7 @@ const template = (titlePrefix, ...elements) => {
                   emoji: "ꔙ",
                   text: i18n.activityTitle
                 }),
+                renderTagsLink(),
                 renderTrendingLink(),
                 renderOpinionsLink(),
                 renderPadsLink(),
@@ -1063,7 +1137,8 @@ const template = (titlePrefix, ...elements) => {
                 },
                 renderFeedLink(),
                 renderGamesLink(),
-                renderPixeliaLink()
+                renderPixeliaLink(),
+                renderMelodyLink()
               ),
               navGroup(
                 {
@@ -1686,8 +1761,41 @@ const post = ({ msg, aside = false, preview = false }) => {
     }
 };
 
-exports.editProfileView = ({ name, description }) =>
-  template(
+exports.editProfileView = ({ name, description, visibilityPrefs = {}, feedId = '', baseUrl = '' }) => {
+  const prefs = {
+    activity: visibilityPrefs.activity === true,
+    device:   visibilityPrefs.device   === true,
+    karma:    visibilityPrefs.karma !== false,
+    ubi:      visibilityPrefs.ubi      === true,
+    wallet:   visibilityPrefs.wallet   === true,
+    clearnetShops:     visibilityPrefs.clearnetShops     === true,
+    clearnetJobs:      visibilityPrefs.clearnetJobs      === true,
+    clearnetEvents:    visibilityPrefs.clearnetEvents    === true,
+    clearnetProjects:  visibilityPrefs.clearnetProjects  === true,
+    clearnetPosts:     visibilityPrefs.clearnetPosts     === true,
+    clearnetAudios:    visibilityPrefs.clearnetAudios    === true,
+    clearnetVideos:    visibilityPrefs.clearnetVideos    === true,
+    clearnetImages:    visibilityPrefs.clearnetImages    === true,
+    clearnetDocuments: visibilityPrefs.clearnetDocuments === true,
+    clearnetTorrents:  visibilityPrefs.clearnetTorrents  === true,
+    profileShops:      visibilityPrefs.profileShops      === true,
+    profileJobs:       visibilityPrefs.profileJobs       === true,
+    profileEvents:     visibilityPrefs.profileEvents     === true,
+    profileProjects:   visibilityPrefs.profileProjects   === true,
+    profilePosts:      visibilityPrefs.profilePosts      === true,
+    profileAudios:     visibilityPrefs.profileAudios     === true,
+    profileVideos:     visibilityPrefs.profileVideos     === true,
+    profileImages:     visibilityPrefs.profileImages     === true,
+    profileDocuments:  visibilityPrefs.profileDocuments  === true,
+    profileTorrents:   visibilityPrefs.profileTorrents   === true,
+    ecoTax:            visibilityPrefs.ecoTax            !== false
+  };
+  prefs.clearnet = prefs.clearnetShops || prefs.clearnetJobs || prefs.clearnetEvents || prefs.clearnetProjects || prefs.clearnetPosts || prefs.clearnetAudios || prefs.clearnetVideos || prefs.clearnetImages || prefs.clearnetDocuments || prefs.clearnetTorrents;
+  const togglePill = (key, labelText) => label({ class: "pref-pill", for: `vis_${key}` },
+    input({ type: "checkbox", name: `vis_${key}`, id: `vis_${key}`, value: "1", class: "pref-pill-input", checked: prefs[key] ? "checked" : undefined }),
+    span({ class: "pref-pill-label" }, labelText)
+  );
+  return template(
     i18n.editProfile,
     section(
       h1(i18n.editProfile),
@@ -1720,6 +1828,59 @@ exports.editProfileView = ({ name, description }) =>
             description
           )
         ),
+        br(), br(),
+        div({ class: "prefs-card" },
+          div({ class: "tags-header" },
+            h2(i18n.profileContentSectionTitle || 'Avatar Content'),
+            p({ class: "prefs-help" }, i18n.profileContentHelp || 'Choose which of your modules will be displayed on your profile.')
+          ),
+          div({ class: "pref-pill-row" },
+            togglePill('profileShops',     i18n.profileClearnetShopsLabel     || 'Shops'),
+            togglePill('profileJobs',      i18n.profileClearnetJobsLabel      || 'Jobs'),
+            togglePill('profileEvents',    i18n.profileClearnetEventsLabel    || 'Events'),
+            togglePill('profileProjects',  i18n.profileClearnetProjectsLabel  || 'Projects'),
+            togglePill('profilePosts',     i18n.profileClearnetPostsLabel     || 'Blogs'),
+            togglePill('profileAudios',    i18n.profileClearnetAudiosLabel    || 'Audios'),
+            togglePill('profileVideos',    i18n.profileClearnetVideosLabel    || 'Videos'),
+            togglePill('profileImages',    i18n.profileClearnetImagesLabel    || 'Images'),
+            togglePill('profileDocuments', i18n.profileClearnetDocumentsLabel || 'Documents'),
+            togglePill('profileTorrents',  i18n.profileClearnetTorrentsLabel  || 'Torrents')
+          )
+        ),
+        br(),
+        div({ class: "prefs-card" },
+          div({ class: "tags-header" },
+            h2(i18n.profileSensorsSectionTitle || 'Sensors'),
+            p({ class: "prefs-help" }, i18n.profileSensorsHelp || 'Optional metrics shown on your profile.')
+          ),
+          div({ class: "pref-pill-row" },
+            togglePill('ecoTax',   i18n.profileVisibilityEcoTax   || 'ECO Tax'),
+            togglePill('activity', i18n.profileVisibilityActivity || 'Activity Level'),
+            togglePill('device',   i18n.profileVisibilityDevice   || 'Device'),
+            togglePill('karma',    i18n.profileVisibilityKarma    || 'KARMA Scoring'),
+            togglePill('ubi',      i18n.profileVisibilityUbi      || 'UBI'),
+            togglePill('wallet',   i18n.profileVisibilityWallet   || 'ECOIN Wallet')
+          )
+        ),
+        br(),
+        div({ class: "prefs-card" },
+          div({ class: "tags-header" },
+            h2(i18n.clearnetSectionTitle || 'Clearnet'),
+            p({ class: "prefs-help" }, i18n.profileClearnetHelp || 'Modules that can be accessed from outside Oasis.')
+          ),
+          div({ class: "pref-pill-row" },
+            togglePill('clearnetShops',     i18n.profileClearnetShopsLabel     || 'Shops'),
+            togglePill('clearnetJobs',      i18n.profileClearnetJobsLabel      || 'Jobs'),
+            togglePill('clearnetEvents',    i18n.profileClearnetEventsLabel    || 'Events'),
+            togglePill('clearnetProjects',  i18n.profileClearnetProjectsLabel  || 'Projects'),
+            togglePill('clearnetPosts',     i18n.profileClearnetPostsLabel     || 'Blogs'),
+            togglePill('clearnetAudios',    i18n.profileClearnetAudiosLabel    || 'Audios'),
+            togglePill('clearnetVideos',    i18n.profileClearnetVideosLabel    || 'Videos'),
+            togglePill('clearnetImages',    i18n.profileClearnetImagesLabel    || 'Images'),
+            togglePill('clearnetDocuments', i18n.profileClearnetDocumentsLabel || 'Documents'),
+            togglePill('clearnetTorrents',  i18n.profileClearnetTorrentsLabel  || 'Torrents')
+          )
+        ),
         br(),
         button(
           {
@@ -1730,8 +1891,158 @@ exports.editProfileView = ({ name, description }) =>
       )
     )
   );
+};
+
+exports.clearnetBlogView = async ({ msgKey, text, author, authorName, contentWarning, sentAt }) => {
+  const { escapeHtml: esc, renderClearnetPage } = require('./clearnet_view');
+  const rawText = String(text || '');
+  const renderedHtml = sanitizeHtml(markdown(rawText))
+    .replace(/(["'])\/blob\//g, '$1/c/blob/');
+  const plainPreview = rawText.replace(/!\[[^\]]*\]\([^)]*\)/g, '').replace(/<[^>]+>/g, '').slice(0, 200);
+  const authorEsc = esc(authorName || (author || '').slice(1, 9));
+  const dateStr = sentAt ? esc(new Date(sentAt).toISOString().slice(0, 10)) : '';
+  const cw = esc(contentWarning || '');
+  const firstLine = rawText.replace(/!\[[^\]]*\]\([^)]*\)/g, '').replace(/<[^>]+>/g, '').split('\n').map(s => s.trim()).find(Boolean) || '';
+  const titleText = cw || firstLine.slice(0, 100) || 'Post';
+  const extraCss = `
+.cn-blog-meta{color:var(--fg-dim);font-size:13px;margin-bottom:16px;display:flex;gap:14px;flex-wrap:wrap}
+.cn-blog-cw{background:#663d00;color:#ffd700;border:1px solid #ff7300;padding:8px 14px;border-radius:6px;margin-bottom:16px;font-weight:600}
+.cn-blog-body{color:var(--fg-soft);line-height:1.7;font-size:16px;margin:0;word-wrap:break-word}
+.cn-blog-body p{margin:0 0 14px 0}
+.cn-blog-body img{max-width:100%;height:auto;border-radius:6px;border:1px solid var(--border);display:block;margin:10px 0}
+.cn-blog-body a{color:var(--fg);text-decoration:underline}
+.cn-blog-body a:hover{color:var(--accent)}
+.cn-blog-body pre,.cn-blog-body code{background:var(--bg-sub);color:var(--fg);border:1px solid var(--border);border-radius:4px;padding:2px 6px;font-family:monospace;font-size:13px}
+.cn-blog-body pre{padding:10px 14px;overflow-x:auto;white-space:pre-wrap;word-break:break-word}
+.cn-blog-body blockquote{margin:10px 0;padding:6px 14px;border-left:3px solid var(--fg);color:var(--fg-soft);background:var(--bg-sub);border-radius:0 4px 4px 0}
+.cn-blog-body h1,.cn-blog-body h2,.cn-blog-body h3{color:var(--fg);margin:18px 0 10px 0}
+.cn-blog-body ul,.cn-blog-body ol{padding-left:24px;margin:8px 0}
+.cn-blog-body video,.cn-blog-body audio{max-width:100%;display:block;margin:10px 0}
+`;
+  const body = `
+  <div class="cn-blog-meta">
+    ${dateStr ? `<span>📅 ${dateStr}</span>` : ''}
+  </div>
+  ${cw ? `<div class="cn-blog-cw">${cw}</div>` : ''}
+  <article class="cn-blog-body">${renderedHtml}</article>
+`;
+  return renderClearnetPage({
+    title: `${esc(titleText)} — Oasis`,
+    ogTitle: titleText,
+    ogDescription: plainPreview,
+    extraCss,
+    body,
+    hubFeedId: author || null
+  });
+};
+
+exports.clearnetInhabitantView = async ({ feedId, name, description, image, prefs, items = {}, query = '', filterType = '' }) => {
+  const { blobUrl: cnBlob, escapeHtml: esc, renderClearnetPage } = require('./clearnet_view');
+  const blobAvatarUrl = cnBlob(image);
+  const avatarSrc = blobAvatarUrl || '/assets/images/default-avatar.png';
+  const qrSrc = feedId ? `/qr/${encodeURIComponent(feedId)}` : null;
+  const displayName = esc(name || 'Anonymous');
+  const desc = esc(description || '');
+  const renderHubItem = (modulePath, it) => {
+    const blob = cnBlob(it.image);
+    const title = esc(it.title || 'Untitled');
+    const snippet = esc((it.snippet || '').slice(0, 160));
+    const meta = esc(it.meta || '');
+    const kind = esc(it.kind || '');
+    return `<a class="cn-hub-card" href="/c/${modulePath}/${encodeURIComponent(it.id)}">
+      ${blob ? `<img class="cn-hub-thumb" src="${blob}" alt="" loading="lazy"/>` : ''}
+      <div class="cn-hub-body">
+        ${kind ? `<div class="cn-hub-kind">${kind}</div>` : ''}
+        <div class="cn-hub-title">${title}</div>
+        ${snippet ? `<div class="cn-hub-snippet">${snippet}${(it.snippet || '').length > 160 ? '…' : ''}</div>` : ''}
+        ${meta ? `<div class="cn-hub-meta">${meta}</div>` : ''}
+      </div>
+    </a>`;
+  };
+  const moduleDef = [
+    { key: 'shops',     label: 'Shops',     kind: 'Shop',     prefKey: 'clearnetShops' },
+    { key: 'jobs',      label: 'Jobs',      kind: 'Job',      prefKey: 'clearnetJobs' },
+    { key: 'events',    label: 'Events',    kind: 'Event',    prefKey: 'clearnetEvents' },
+    { key: 'projects',  label: 'Projects',  kind: 'Project',  prefKey: 'clearnetProjects' },
+    { key: 'posts',     label: 'Blogs',     kind: 'Blog',     prefKey: 'clearnetPosts',     modulePath: 'blog' },
+    { key: 'audios',    label: 'Audios',    kind: 'Audio',    prefKey: 'clearnetAudios' },
+    { key: 'videos',    label: 'Videos',    kind: 'Video',    prefKey: 'clearnetVideos' },
+    { key: 'images',    label: 'Images',    kind: 'Image',    prefKey: 'clearnetImages' },
+    { key: 'documents', label: 'Documents', kind: 'Document', prefKey: 'clearnetDocuments' },
+    { key: 'torrents',  label: 'Torrents',  kind: 'Torrent',  prefKey: 'clearnetTorrents' }
+  ];
+  const allItems = [];
+  for (const m of moduleDef) {
+    for (const it of (items[m.key] || [])) {
+      allItems.push({ ...it, modulePath: m.modulePath || m.key, kind: m.kind, _moduleKey: m.key });
+    }
+  }
+  const activeFilter = (filterType || '').toLowerCase();
+  const visibleItems = activeFilter
+    ? allItems.filter(it => it._moduleKey === activeFilter)
+    : allItems;
+  const totalCount = visibleItems.length;
+  const filterBase = `/c/inhabitant/${encodeURIComponent(feedId)}`;
+  const filterButtons = `<div class="cn-filter-row">
+    <a class="cn-filter-btn${activeFilter ? '' : ' active'}" href="${filterBase}">All (${allItems.length})</a>
+    ${moduleDef.filter(m => prefs && prefs[m.prefKey] && (items[m.key] || []).length).map(m => {
+      const isActive = activeFilter === m.key;
+      const count = (items[m.key] || []).length;
+      return `<a class="cn-filter-btn${isActive ? ' active' : ''}" href="${filterBase}?type=${m.key}">${esc(m.label)} (${count})</a>`;
+    }).join('')}
+  </div>`;
+  const sections = totalCount
+    ? `${filterButtons}<h2 class="cn-section">Public Content (${totalCount})</h2><div class="cn-hub-grid">${visibleItems.map(it => renderHubItem(it.modulePath, it)).join('')}</div>`
+    : (allItems.length ? `${filterButtons}<div class="cn-empty-content">No content in this category.</div>` : '');
+  const noResults = '<div class="cn-empty-content">This inhabitant has not published content to Clearnet yet.</div>';
+  const extraCss = `
+.cn-profile{display:flex;gap:24px;flex-wrap:wrap;align-items:flex-start;margin-bottom:24px}
+.cn-avatar{width:160px;height:160px;border-radius:8px;border:3px solid var(--fg);object-fit:cover;background:#000;flex:0 0 auto}
+.cn-profile-body{flex:1 1 280px;min-width:0}
+.cn-name{color:var(--fg);margin:0 0 8px 0;font-size:28px;font-weight:700}
+.cn-id{color:var(--fg-dim);font-size:12px;word-break:break-all;font-family:monospace;background:var(--bg-sub);border:1px solid var(--border);padding:6px 10px;border-radius:4px;display:inline-block;margin-bottom:14px}
+.cn-desc{color:var(--fg-soft);white-space:pre-wrap;margin:0}
+.cn-qr-col{flex:0 0 auto;display:flex;align-items:flex-start;justify-content:center}
+.cn-qr-img{width:160px;height:160px;background:#fff;padding:8px;border-radius:8px;image-rendering:pixelated}
+.cn-filter-row{display:flex;flex-wrap:wrap;gap:8px;margin:24px 0 12px 0}
+.cn-filter-btn{display:inline-block;padding:6px 14px;background:var(--bg-elev);color:var(--fg-soft);border:1px solid var(--border);border-radius:14px;font-size:13px;text-decoration:none;transition:border-color .15s ease,color .15s ease,background .15s ease}
+.cn-filter-btn:hover{border-color:var(--fg);color:var(--fg);text-decoration:none}
+.cn-filter-btn.active{background:var(--bg-sub);border-color:var(--fg);color:var(--fg);font-weight:600}
+.cn-hub-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:14px;margin-top:16px}
+.cn-hub-kind{color:var(--fg-dim);font-size:10px;text-transform:uppercase;letter-spacing:2px;font-weight:600}
+.cn-hub-card{display:flex;flex-direction:column;background:var(--bg-elev);border:1px solid var(--border);border-radius:8px;overflow:hidden;transition:border-color .15s ease;color:var(--fg);text-decoration:none}
+.cn-hub-card:hover{border-color:var(--fg);text-decoration:none}
+.cn-hub-thumb{width:100%;height:140px;object-fit:cover;background:#000;border-bottom:1px solid var(--border)}
+.cn-hub-body{padding:12px 14px;display:flex;flex-direction:column;gap:6px;min-width:0}
+.cn-hub-title{color:var(--fg);font-weight:600;font-size:15px;word-break:break-word}
+.cn-hub-snippet{color:var(--fg-soft);font-size:13px;line-height:1.4;word-break:break-word;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
+.cn-hub-meta{color:var(--fg-dim);font-size:11px;text-transform:uppercase;letter-spacing:1px;margin-top:auto}
+.cn-empty-content{background:var(--bg-elev);border:1px dashed var(--border);border-radius:8px;padding:24px;text-align:center;color:var(--fg-dim);font-size:14px}
+`;
+  const body = `
+  <div class="cn-profile">
+    <img class="cn-avatar" src="${avatarSrc}" alt="${displayName}"/>
+    <div class="cn-profile-body">
+      <h1 class="cn-name">${displayName}</h1>
+      <div class="cn-id">${esc(feedId)}</div>
+      ${desc ? `<p class="cn-desc">${desc}</p>` : ''}
+    </div>
+    ${qrSrc ? `<div class="cn-qr-col"><img class="cn-qr-img" src="${qrSrc}" alt="QR"/></div>` : ''}
+  </div>
+  ${totalCount > 0 ? sections : noResults}
+`;
+  return renderClearnetPage({
+    title: `${name || 'Inhabitant'} — Oasis`,
+    ogTitle: name || 'Oasis',
+    ogDescription: description || '',
+    ogImage: blobAvatarUrl,
+    extraCss,
+    body,
+    hubFeedId: feedId || null
+  });
+};
 
-exports.authorView = ({
+exports.authorView = async ({
   avatarUrl,
   description,
   feedId,
@@ -1745,19 +2056,44 @@ exports.authorView = ({
   estimatedUBI = 0,
   lastClaimedDate = null,
   totalClaimed = 0,
-  lastActivityBucket
+  carbonGrams = 0,
+  lastActivityBucket,
+  visibilityPrefs = null,
+  baseUrl = '',
+  userActions = [],
+  allActions = [],
+  profileItems = null,
+  profileFilterType = ''
 }) => {
+  const isOwnProfile = !!(relationship && relationship.me);
+  const rawPrefs = visibilityPrefs || {};
+  const prefs = {
+    activity: rawPrefs.activity === true,
+    device:   rawPrefs.device   === true,
+    karma:    rawPrefs.karma !== false,
+    ubi:      rawPrefs.ubi      === true,
+    wallet:   rawPrefs.wallet   === true,
+    ecoTax:   rawPrefs.ecoTax   !== false,
+    clearnet: rawPrefs.clearnet === true
+  };
+  const clearnetSubKeys = ['clearnetShops','clearnetJobs','clearnetEvents','clearnetProjects','clearnetPosts','clearnetAudios','clearnetVideos','clearnetImages','clearnetDocuments','clearnetTorrents'];
+  const anySubClearnet = clearnetSubKeys.some(k => rawPrefs[k] === true);
+  prefs.clearnet = prefs.clearnet || anySubClearnet;
+  const showField = (key) => prefs[key];
+  const qrSrc = feedId ? `/qr/${encodeURIComponent(feedId)}` : null;
   const linkUrl = `/author/${encodeURIComponent(feedId)}`;
+  const { renderReachChip, renderClearnetUrlBlock } = require('./clearnet_view');
+  const reachChip = renderReachChip(!!prefs.clearnet, i18n, prefs.clearnet ? `/c/inhabitant/${encodeURIComponent(feedId)}` : null);
 
-  const mention = `[@${name}](${feedId})`;
-  const markdownMention = highlightJs.highlight(mention, { language: "markdown", ignoreIllegals: true }).value;
+  const escHtml = s => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
+  const markdownMention = `[@${escHtml(name)}](<strong>${escHtml(feedId)}</strong>)`;
 
   const contactForms = [];
   const addForm = ({ action }) =>
     contactForms.push(
       form(
         { action: `/${action}/${encodeURIComponent(feedId)}`, method: "post" },
-        button({ type: "submit" }, i18n[action])
+        button({ type: "submit", class: "btn" }, i18n[action])
       )
     );
 
@@ -1780,124 +2116,200 @@ exports.authorView = ({
 
   const bucket = lastActivityBucket || 'red';
 
-  const { lastActivityBadge } = require('./inhabitants_view');
+  const dotClass = bucket === 'green' ? 'green' : bucket === 'orange' ? 'orange' : bucket === 'red' ? 'red' : null;
+  const activityChip = (dotClass && showField('activity'))
+    ? span({ class: 'inhabitant-last-activity' },
+        `${i18n.inhabitantActivityLevel}: `,
+        span({ class: `activity-dot ${dotClass}` }, '●'))
+    : null;
+  const deviceSrc = (() => {
+    if (!isOwnProfile) return null;
+    const t = getConfig().themes.current;
+    return t === 'OasisKIT' ? 'KIT' : (t === 'OasisMobile' || process.env.OASIS_MOBILE === '1') ? 'MOBILE' : 'DESKTOP';
+  })();
+  const deviceChip = (deviceSrc && showField('device'))
+    ? (() => {
+        const upper = String(deviceSrc).toUpperCase();
+        const deviceClass = upper === 'KIT' ? 'device-kit' : upper === 'MOBILE' ? 'device-mobile' : 'device-desktop';
+        return span({ class: 'inhabitant-last-activity' },
+          `${i18n.deviceLabel || 'Device'}: `,
+          span({ class: deviceClass }, deviceSrc));
+      })()
+    : null;
+  const activityGroup = (activityChip || deviceChip)
+    ? div({ class: 'inhabitant-activity-group' }, activityChip, deviceChip)
+    : null;
 
-  const prefix = section(
-    { class: "message" },
-    div(
-      { class: "profile" },
-      div({ class: "avatar-container" },
-        img({ class: "inhabitant-photo-details", src: avatarUrl }),
-        h1({ class: "name" }, name),
-      ),
-      pre({ class: "md-mention", innerHTML: sanitizeHtml(markdownMention) }),
-      p(userLink(feedId, name)),
-      div({ class: "profile-metrics" },
-        ...lastActivityBadge({ lastActivityBucket: bucket }, true),
-        div({ class: "inhabitant-karma-ubi" },
-          span({ class: "karma-line" }, `${i18n.bankingUserEngagementScore}: `, strong(karmaScore !== undefined ? karmaScore : 0)),
-          span({ class: "ubi-line" }, `${i18n.bankUbiThisMonth}: `, strong(`${Number(estimatedUBI || 0).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(`${Number(totalClaimed || 0).toFixed(6)} ECO`))
-        ),
-        (ecoAddress || relationship.me)
-          ? div({ class: "eco-wallet" },
-              p(`${i18n.statsEcoWalletLabel || 'ECOin Wallet'}: `,
-                a({ href: '/wallet' }, ecoAddress || i18n.statsEcoWalletNotConfigured || 'Not configured!')))
-          : null
-      )
-    ),
-    description !== "" ? article({ innerHTML: sanitizeHtml(markdown(description)) }) : null,
-    footer(
-      div(
-        { class: "profile" },
-        ...contactForms.map(form => span({ class: "contact-bold" }, form)),
-        relationship.me
-          ? span({ class: "status you" }, i18n.relationshipYou)
-          : div({ class: "relationship-status" },
-              relationship.blocking && relationship.blockedBy
-                ? span({ class: "status blocked" }, i18n.relationshipMutualBlock)
+  const formatCarbonValue = (g) => {
+    const n = Number(g) || 0;
+    if (!n) return '0 µg CO₂';
+    if (n >= 1) return `${n.toFixed(2)} g CO₂`;
+    const mg = n * 1000;
+    if (mg >= 1) return `${mg.toFixed(2)} mg CO₂`;
+    return `${(mg * 1000).toFixed(2)} µg CO₂`;
+  };
+  const karmaUbiChildren = [];
+  if (showField('ecoTax')) {
+    karmaUbiChildren.push(span({ class: "karma-line eco-tax-line" }, `${i18n.profileVisibilityEcoTax || 'ECO Tax'}: `, strong(formatCarbonValue(carbonGrams))));
+  }
+  if (showField('karma')) {
+    karmaUbiChildren.push(span({ class: "karma-line" }, `${i18n.bankingUserEngagementScore}: `, strong(karmaScore !== undefined ? karmaScore : 0)));
+  }
+  if (showField('ubi')) {
+    karmaUbiChildren.push(span({ class: "ubi-line" }, `${i18n.bankUbiThisMonth}: `, strong(`${Number(estimatedUBI || 0).toFixed(6)} ECO`)));
+    karmaUbiChildren.push(span({ class: "ubi-line" }, `${i18n.bankUbiLastClaimed}: `,
+      lastClaimedDate
+        ? a({ href: "/transfers?filter=ubi", class: "user-link" }, new Date(lastClaimedDate).toLocaleDateString())
+        : strong(i18n.bankUbiNeverClaimed)));
+    karmaUbiChildren.push(span({ class: "ubi-line" }, `${i18n.bankUbiTotalClaimed}: `, strong(`${Number(totalClaimed || 0).toFixed(6)} ECO`)));
+  }
+  const sensorsItems = [];
+  if (activityChip) sensorsItems.push(activityChip);
+  if (deviceChip) sensorsItems.push(deviceChip);
+  for (const c of karmaUbiChildren) sensorsItems.push(c);
+  if (showField('wallet') && (ecoAddress || isOwnProfile)) {
+    sensorsItems.push(span({ class: "ubi-line" }, `${i18n.statsEcoWalletLabel || 'ECOin Wallet'}: `,
+      a({ href: '/wallet' }, ecoAddress || i18n.statsEcoWalletNotConfigured || 'Not configured!')));
+  }
+  const metricsBlock = sensorsItems.length ? div({ class: "profile-sensors-box" }, ...sensorsItems) : null;
+
+  const relationshipBlock = relationship.me
+    ? span({ class: "status you" }, i18n.relationshipYou)
+    : div({ class: "relationship-status" },
+        relationship.blocking && relationship.blockedBy
+          ? span({ class: "status blocked" }, i18n.relationshipMutualBlock)
+          : [
+              relationship.blocking ? span({ class: "status blocked" }, i18n.relationshipBlocking) : null,
+              relationship.blockedBy ? span({ class: "status blocked-by" }, i18n.relationshipBlockedBy) : null,
+              relationship.following && relationship.followsMe
+                ? span({ class: "status mutual" }, i18n.relationshipMutuals)
                 : [
-                    relationship.blocking ? span({ class: "status blocked" }, i18n.relationshipBlocking) : null,
-                    relationship.blockedBy ? span({ class: "status blocked-by" }, i18n.relationshipBlockedBy) : null,
-                    relationship.following && relationship.followsMe
-                      ? span({ class: "status mutual" }, i18n.relationshipMutuals)
-                      : [
-                          span({ class: "status supporting" }, relationship.following ? i18n.relationshipFollowing : i18n.relationshipNone),
-                          span({ class: "status supported-by" }, relationship.followsMe ? i18n.relationshipTheyFollow : i18n.relationshipNotFollowing)
-                        ]
+                    span({ class: "status supporting" }, relationship.following ? i18n.relationshipFollowing : i18n.relationshipNone),
+                    span({ class: "status supported-by" }, relationship.followsMe ? i18n.relationshipTheyFollow : i18n.relationshipNotFollowing)
                   ]
-            ),
-        relationship.me ? a({ href: `/profile/edit`, class: "btn" }, nbsp, i18n.editProfile) : null,
-        a({ href: `/likes/${encodeURIComponent(feedId)}`, class: "btn" }, i18n.viewLikes),
-        !relationship.me ? a({ href: `/pm?recipients=${encodeURIComponent(feedId)}`, class: "btn" }, i18n.pmCreateButton) : null
-      )
+            ],
+        contactForms.length
+          ? div({ class: "relationship-actions" }, ...contactForms)
+          : null
+      );
+
+  const sideColumn = div({ class: "tribe-side profile-side" },
+    img({ class: "inhabitant-photo-details", src: avatarUrl, alt: name }),
+    h2({ class: "profile-side-name" }, name),
+    div({ class: "profile-side-mention" },
+      a({ href: `/author/${encodeURIComponent(feedId)}` }, strong(feedId))
+    ),
+    qrSrc ? img({ src: qrSrc, alt: feedId, class: "profile-side-qr", width: "180", height: "180" }) : null,
+    description !== ""
+      ? div({ class: "profile-side-description", innerHTML: sanitizeHtml(markdown(description)) })
+      : null,
+    div({ class: "profile-side-relationship" }, relationshipBlock),
+    metricsBlock,
+    div({ class: "profile-reach" },
+      reachChip,
+      isOwnProfile && prefs.clearnet
+        ? renderClearnetUrlBlock({ baseUrl, path: `/c/inhabitant/${encodeURIComponent(feedId)}`, i18nObj: i18n })
+        : null,
+      isOwnProfile
+        ? form({ method: 'POST', action: '/profile/clearnet-toggle', class: 'profile-reach-toggle' },
+            button({ type: 'submit', class: 'btn' },
+              prefs.clearnet
+                ? (i18n.profileSwitchToOasis || 'Return to Oasis')
+                : (i18n.profileSwitchToClearnet || 'Dive into Clearnet')
+            )
+          )
+        : null
+    ),
+    div({ class: "profile-side-actions" },
+      isOwnProfile ? a({ href: `/profile/edit`, class: "btn" }, i18n.editProfile) : null,
+      a({ href: `/likes/${encodeURIComponent(feedId)}`, class: "btn" }, i18n.viewLikes),
+      !isOwnProfile ? a({ href: `/pm?recipients=${encodeURIComponent(feedId)}`, class: "btn" }, i18n.pmCreateButton) : null
     )
   );
 
-  let items = messages.map((msg) => post({ msg }));
-  if (items.length === 0) {
-    if (lastPost === undefined) {
-      items.push(section(div(span(i18n.feedEmpty))));
-    } else {
-      items.push(
-        section(
-          div(
-            span(i18n.feedRangeEmpty),
-            a({ href: `${linkUrl}` }, i18n.seeFullFeed)
+  let mainColumnContent = [];
+
+  if (Array.isArray(allActions) && allActions.length) {
+    const keyToTypes = {
+      shops:     new Set(['shop', 'shopProduct']),
+      jobs:      new Set(['job']),
+      events:    new Set(['event']),
+      projects:  new Set(['project']),
+      posts:     new Set(['post']),
+      audios:    new Set(['audio']),
+      videos:    new Set(['video']),
+      images:    new Set(['image']),
+      documents: new Set(['document']),
+      torrents:  new Set(['torrent'])
+    };
+    const moduleDef = [
+      { key: 'shops',     label: i18n.profileClearnetShopsLabel     || 'Shops' },
+      { key: 'jobs',      label: i18n.profileClearnetJobsLabel      || 'Jobs' },
+      { key: 'events',    label: i18n.profileClearnetEventsLabel    || 'Events' },
+      { key: 'projects',  label: i18n.profileClearnetProjectsLabel  || 'Projects' },
+      { key: 'posts',     label: i18n.profileClearnetPostsLabel     || 'Blogs' },
+      { key: 'audios',    label: i18n.profileClearnetAudiosLabel    || 'Audios' },
+      { key: 'videos',    label: i18n.profileClearnetVideosLabel    || 'Videos' },
+      { key: 'images',    label: i18n.profileClearnetImagesLabel    || 'Images' },
+      { key: 'documents', label: i18n.profileClearnetDocumentsLabel || 'Documents' },
+      { key: 'torrents',  label: i18n.profileClearnetTorrentsLabel  || 'Torrents' }
+    ];
+    const enabledKeys = moduleDef.filter(m => rawPrefs[`profile${m.key.charAt(0).toUpperCase() + m.key.slice(1)}`] === true).map(m => m.key);
+    if (enabledKeys.length > 0) {
+      const allowedTypes = new Set();
+      for (const k of enabledKeys) for (const t of keyToTypes[k]) allowedTypes.add(t);
+      const authorActions = allActions.filter(a => a && a.author === feedId && allowedTypes.has(a.type));
+      const counts = {};
+      for (const k of enabledKeys) counts[k] = 0;
+      for (const a of authorActions) {
+        for (const k of enabledKeys) {
+          if (keyToTypes[k].has(a.type)) { counts[k]++; break; }
+        }
+      }
+      const totalCount = authorActions.length;
+      if (totalCount > 0) {
+        const activeFilter = (profileFilterType || '').toLowerCase();
+        const filterBase = isOwnProfile ? `/profile` : `/author/${encodeURIComponent(feedId)}`;
+        const filterRow = div({ class: "tribe-section-nav no-border" },
+          div({ class: "tribe-section-group no-border" },
+            a({ href: filterBase, class: `filter-btn${activeFilter ? '' : ' active'}` }, `${String(i18n.profileHubAll || 'All').toUpperCase()} (${totalCount})`),
+            ...moduleDef.filter(m => enabledKeys.includes(m.key) && counts[m.key] > 0).map(m => {
+              const isActive = activeFilter === m.key;
+              return a({ href: `${filterBase}?type=${encodeURIComponent(m.key)}`, class: `filter-btn${isActive ? ' active' : ''}` }, `${String(m.label).toUpperCase()} (${counts[m.key]})`);
+            })
           )
-        )
-      );
+        );
+        const visible = activeFilter && keyToTypes[activeFilter]
+          ? authorActions.filter(a => keyToTypes[activeFilter].has(a.type))
+          : authorActions;
+        visible.sort((a, b) => (b.ts || 0) - (a.ts || 0));
+        const limited = visible.slice(0, 50);
+        const { renderActionCards } = require('./activity_view');
+        mainColumnContent.push(filterRow);
+        mainColumnContent.push(div({ class: 'feed-container profile-module-section' },
+          renderActionCards(limited, feedId, allActions || limited)
+        ));
+      }
     }
-  } else {
-    const highestSeqNum = messages[0].value.sequence;
-    const lowestSeqNum = messages[messages.length - 1].value.sequence;
-
-    const newerPostsLink = a(
-      {
-        href:
-          lastPost !== undefined && highestSeqNum < lastPost.value.sequence
-            ? `${linkUrl}?gt=${highestSeqNum}`
-            : "#",
-        class:
-          lastPost !== undefined && highestSeqNum < lastPost.value.sequence
-            ? "btn"
-            : "btn disabled",
-        "aria-disabled":
-          lastPost === undefined || highestSeqNum >= lastPost.value.sequence
-      },
-      i18n.newerPosts
-    );
-
-    const olderPostsLink = a(
-      {
-        href:
-          lowestSeqNum > firstPost.value.sequence
-            ? `${linkUrl}?lt=${lowestSeqNum}`
-            : "#",
-        class:
-          lowestSeqNum > firstPost.value.sequence
-            ? "btn"
-            : "btn disabled",
-        "aria-disabled": !(lowestSeqNum > firstPost.value.sequence)
-      },
-      i18n.olderPosts
-    );
-
-    const pagination = section(
-      { class: "message" },
-      footer(div(newerPostsLink, olderPostsLink), br())
-    );
-
-    items.unshift(pagination);
-    items.push(pagination);
   }
 
-  return template(i18n.profile, prefix, items);
+  const hasMainContent = mainColumnContent.length > 0;
+  const layout = hasMainContent
+    ? section(div({ class: "tribe-details profile-layout" },
+        sideColumn,
+        div({ class: "tribe-main profile-main" }, ...mainColumnContent)
+      ))
+    : section(div({ class: "profile-layout profile-layout-single" }, sideColumn));
+
+  let html = template(i18n.profile, layout);
+  const hasDocument = Array.isArray(allActions) && allActions.some(a => a && a.author === feedId && a.type === 'document');
+  if (hasDocument) {
+    html += `
+      <script type="module" src="/js/pdf.min.mjs"></script>
+      <script src="/js/pdf-viewer.js"></script>
+    `;
+  }
+  return html;
 };
 
 exports.previewCommentView = async ({
@@ -2038,7 +2450,6 @@ exports.commentView = async (
 const renderMessage = (msg) => {
   const content = lodash.get(msg, "value.content", {});
   const authorId = msg.value.author || "Anonymous";
-  const authorName = lodash.get(msg, "value.meta.author.name") || authorId.slice(0, 10) + '...';
   const createdAt = new Date(msg.value.timestamp).toLocaleString();
   const mentionsText = content.text || '';
   const isTribe = content.type === 'tribe-content';
@@ -2052,19 +2463,27 @@ const renderMessage = (msg) => {
   const badge = isTribe && content.tribeName
     ? span({ class: 'tribe-badge' }, content.tribeName)
     : null;
-
-  return div({ class: "mention-item" },
-    div({ class: "mention-content" },
-      badge,
-      ...renderUrl(mentionsText || '[No content]')
+  const typeLabel = isTribe ? 'TRIBE' : 'POST';
+
+  return div({ class: 'card card-rpg mention-card' },
+    div({ class: 'card-header' },
+      h2({ class: 'card-label' }, `[${typeLabel}]`),
+      visitUrl
+        ? form({ method: 'GET', action: visitUrl, class: 'inline-form' },
+            button({ type: 'submit', class: 'filter-btn' }, i18n.viewDetails || 'View details')
+          )
+        : null
     ),
-    p(userLink(authorId, authorName)),
-    p(`${i18n.createdAtLabel || 'Created at'}: ${createdAt}`),
-    visitUrl
-      ? form({ method: 'GET', action: visitUrl },
-          button({ type: 'submit', class: 'filter-btn' }, i18n.visitContent || 'Visit')
-        )
-      : null
+    div({ class: 'card-body' },
+      div({ class: 'card-section' },
+        badge,
+        p({ class: 'post-text' }, ...renderUrl(mentionsText || '[No content]'))
+      )
+    ),
+    p({ class: 'card-footer' },
+      span({ class: 'date-link' }, `${createdAt} ${i18n.performed || ''} `),
+      userLink(authorId)
+    )
   );
 };
 
@@ -2192,8 +2611,9 @@ exports.privateView = async (messagesInput, filter) => {
   function actions({ key, replyId, subjectRaw, text }) {
     const stop = { onclick: 'event.stopPropagation()' }
     const subjectReply = /^(\s*RE:\s*)/i.test(subjectRaw || '') ? (subjectRaw || '') : `RE: ${subjectRaw || ''}`
+    const isSelf = replyId === userId
     return div({ class: 'pm-actions' },
-      form({ method: 'GET', action: '/pm', class: 'pm-action-form', ...stop },
+      isSelf ? null : form({ method: 'GET', action: '/pm', class: 'pm-action-form', ...stop },
         input({ type: 'hidden', name: 'recipients', value: replyId }),
         input({ type: 'hidden', name: 'subject', value: subjectReply }),
         input({ type: 'hidden', name: 'quote', value: text || '' }),
@@ -2270,6 +2690,8 @@ exports.privateView = async (messagesInput, filter) => {
       .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>`)
       .replace(/\/calendars\/([%A-Za-z0-9/+._=-]+\.sha256)/g, (match, id) => `<a class="calendar-link" href="/calendars/${encodeURIComponent(id)}">${match}</a>`)
+      .replace(/\/ai\/ask\?[^\s<"]+/g, (match) => `<a class="ai-ask-link" href="${match}">${match}</a>`)
+      .replace(/(?<![A-Za-z0-9_])\/(profile|inbox|invites|peers|tribes|inhabitants|publish|activity|settings|modules)(?![A-Za-z0-9_\/])/g, (match) => `<a class="oasis-path-link" href="${match}">${match}</a>`)
       .replace(/(https?:\/\/[^\s<"]+)/g, (match) => `<a href="${match}" target="_blank" rel="noopener noreferrer">${match}</a>`)
   }
 
@@ -2286,13 +2708,20 @@ exports.privateView = async (messagesInput, filter) => {
     if (hasInbound) for (const m of arr) inboxSet.add(m)
   }
 
+  const isReminder = m => {
+    const s = String(m?.value?.content?.subject || '')
+    return /^(Task Reminder:|Calendar Reminder:)/i.test(s)
+  }
+
   const data =
-    filter === 'sent' ? messages.filter(isSent) :
-    filter === 'inbox' ? Array.from(inboxSet) :
+    filter === 'sent' ? messages.filter(m => isSent(m) && !isReminder(m)) :
+    filter === 'reminders' ? messages.filter(isReminder) :
+    filter === 'inbox' ? Array.from(inboxSet).filter(m => !isReminder(m)) :
     messages
 
-  const inboxCount = Array.from(inboxSet).length
-  const sentCount = messages.filter(isSent).length
+  const inboxCount = Array.from(inboxSet).filter(m => !isReminder(m)).length
+  const sentCount = messages.filter(m => isSent(m) && !isReminder(m)).length
+  const reminderCount = messages.filter(isReminder).length
 
   const sorted = [...data].sort((a, b) => {
     const ta = threadId(a)
@@ -2401,6 +2830,18 @@ exports.privateView = async (messagesInput, filter) => {
         h2(i18n.private),
         p(i18n.privateDescription)
       ),
+      (() => {
+        const pmVis = getConfig().pmVisibility === 'mutuals' ? 'mutuals' : 'whole'
+        const pmVisLabel = pmVis === 'mutuals' ? i18n.settingsPmVisibilityMutuals : i18n.settingsPmVisibilityWhole
+        const pmVisIcon = pmVis === 'mutuals' ? '🤝' : '🌐'
+        return div({ class: 'pm-exposition inbox-exposition' },
+          span({ class: 'inbox-filters-label' }, i18n.inboxFiltersLabel || 'Filters:'),
+          span({ class: `pm-exposition-chip pm-exposition-${pmVis}` },
+            span({ class: 'pm-exposition-icon' }, pmVisIcon),
+            span({ class: 'pm-exposition-text' }, pmVisLabel)
+          )
+        )
+      })(),
       div({ class: 'filters' },
         form({ method: 'GET', action: '/inbox' }, [
           button({
@@ -2409,6 +2850,12 @@ exports.privateView = async (messagesInput, filter) => {
             value: 'inbox',
             class: filter === 'inbox' ? 'filter-btn active' : 'filter-btn'
           }, `${i18n.privateInbox} (${inboxCount})`),
+          button({
+            type: 'submit',
+            name: 'filter',
+            value: 'reminders',
+            class: filter === 'reminders' ? 'filter-btn active' : 'filter-btn'
+          }, `${i18n.privateReminders || 'Reminders'} (${reminderCount})`),
           button({
             type: 'submit',
             name: 'filter',
@@ -2602,7 +3049,8 @@ exports.publishView = (preview, text, contentWarning) => {
                 rows: "6",
                 cols: "50",
                 placeholder: i18n.publishWarningPlaceholder,
-                class: "publish-textarea"
+                class: "publish-textarea",
+                maxlength: "8096"
               },
               text || ""
             ),

+ 46 - 79
src/views/market_view.js

@@ -455,6 +455,15 @@ exports.marketView = async (items, filter, itemToEdit = null, params = {}) => {
               input({ type: "text", name: "mapUrl", placeholder: i18n.mapUrlPlaceholder || "/maps/MAP_ID", value: itemEdit?.mapUrl || "" }),
               br(),
               br(),
+              label(i18n.visibilityLabel || "Visibility"),
+              br(),
+              select(
+                { name: "visibility" },
+                option({ value: "PUBLIC", selected: (itemEdit?.visibility || "PUBLIC") === "PUBLIC" }, i18n.visibilityPublic || "Public"),
+                option({ value: "HIDDEN", selected: itemEdit?.visibility === "HIDDEN" }, i18n.visibilityHidden || "Hidden")
+              ),
+              br(),
+              br(),
               label(i18n.marketItemPrice),
               br(),
               input({ type: "number", name: "price", id: "price", value: (itemEdit && itemEdit.price) || "", required: true, step: "0.000001", min: "0.000001" }),
@@ -525,86 +534,25 @@ exports.marketView = async (items, filter, itemToEdit = null, params = {}) => {
                           : null
                       ].filter(Boolean)
 
-                  const actionNodes = Array.isArray(actionNodesRaw) ? actionNodesRaw.filter(Boolean) : []
-                  return div(
-                    { class: "market-item" },
-                    div(
-                      { class: "market-card left-col" },
-                      div(
-                        { class: "market-owner-actions-inline" },
-                        form(
-                          { method: "GET", action: `/market/${encodeURIComponent(item.id)}` },
-                          input({ type: "hidden", name: "returnTo", value: returnTo }),
-                          input({ type: "hidden", name: "filter", value: filter || "all" }),
-                          input({ type: "hidden", name: "q", value: q }),
-                          input({ type: "hidden", name: "minPrice", value: String(minPrice ?? "") }),
-                          input({ type: "hidden", name: "maxPrice", value: String(maxPrice ?? "") }),
-                          input({ type: "hidden", name: "sort", value: sort }),
-                          button({ class: "filter-btn", type: "submit" }, i18n.viewDetails)
-                        ),
-                        renderPmButton(item.seller),
-                        myBid ? span({ class: "chip chip-you" }, i18n.marketMyBidBadge) : null
-                      ),
-                      h2({ class: "market-card type" }, `${i18n.marketItemType}: ${String(item.item_type || "").toUpperCase()}`),
-                      h2(item.title),
-                      item.shopId && item.shopTitle
-                        ? div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.marketShopLabel || "Shop"}:`), span({ class: "card-value" }, a({ href: `/shops/${encodeURIComponent(item.shopId)}`, class: "user-link" }, item.shopTitle)))
-                        : null,
-                      renderCardField(`${i18n.marketItemStatus}:`, item.status),
-                      renderCountdownField(item),
-                      item.deadline ? renderCardField(`${i18n.marketItemAvailable}:`, moment(item.deadline).format("YYYY/MM/DD HH:mm:ss")) : null,
-                      br(),
-                      br(),
-                      div(
-                        { class: "market-card image" },
-                        renderMediaBlob(item.image, "/assets/images/default-market.png")
+                  return div({ class: "tribe-card market-tribe-card" },
+                    div({ class: "tribe-card-image-wrapper" },
+                      a({ href: `/market/${encodeURIComponent(item.id)}` },
+                        renderMediaBlob(item.image, '/assets/images/default-market.png', { class: 'tribe-card-hero-image' })
                       ),
-                      p(...renderUrl(item.description))
+                      form({ method: 'GET', action: `/market/${encodeURIComponent(item.id)}`, class: 'tribe-visit-btn-wrapper' },
+                        input({ type: "hidden", name: "returnTo", value: returnTo }),
+                        input({ type: "hidden", name: "filter", value: filter || "all" }),
+                        input({ type: "hidden", name: "q", value: q }),
+                        input({ type: "hidden", name: "minPrice", value: String(minPrice ?? "") }),
+                        input({ type: "hidden", name: "maxPrice", value: String(maxPrice ?? "") }),
+                        input({ type: "hidden", name: "sort", value: sort }),
+                        button({ type: 'submit', class: 'filter-btn' }, String(i18n.marketVisitItem || 'VISIT ITEM').toUpperCase())
+                      )
                     ),
-                    div(
-                      { class: "market-card right-col" },
-                      div({ class: "market-card price" }, renderCardField(`${i18n.marketItemPrice}:`, `${item.price} ECO`)),
-                      renderCardField(`${i18n.marketItemCondition}:`, item.item_status),
-                      renderCardField(`${i18n.marketItemIncludesShipping}:`, item.includesShipping ? i18n.YESLabel : i18n.NOLabel),
-                      renderMapLocationVisitLabel(item.mapUrl),
-                      br(),
-                      renderStockBar(item.stock, maxStock),
-                      item.item_type === "auction" && parsedBids.length > 0
-                        ? div(
-                            { class: "auction-info" },
-                            p({ class: "auction-bid-text" }, i18n.marketAuctionBids),
-                            table(
-                              { class: "auction-bid-table" },
-                              tr(th(i18n.marketAuctionBidTime), th(i18n.marketAuctionUser), th(i18n.marketAuctionBidAmount)),
-                              parsedBids.map((bid) =>
-                                tr(
-                                  td(moment(bid.time).format("YYYY-MM-DD HH:mm:ss")),
-                                  td(a({ href: `/author/${encodeURIComponent(bid.bidder)}` }, bid.bidder)),
-                                  td(`${parseFloat(bid.amount).toFixed(6)} ECO`)
-                                )
-                              )
-                            )
-                          )
-                        : null,
-                      br(),
-                      br(),
-                      div(
-                        { class: "card-comments-summary" },
-                        span({ class: "card-label" }, i18n.voteCommentsLabel + ":"),
-                        span({ class: "card-value" }, String(item.commentCount || 0)),
-                        br(),
-                        br(),
-                        form(
-                          { method: "GET", action: `/market/${encodeURIComponent(item.id)}` },
-                          input({ type: "hidden", name: "returnTo", value: returnTo }),
-                          input({ type: "hidden", name: "filter", value: filter || "all" }),
-                          input({ type: "hidden", name: "q", value: q }),
-                          input({ type: "hidden", name: "minPrice", value: String(minPrice ?? "") }),
-                          input({ type: "hidden", name: "maxPrice", value: String(maxPrice ?? "") }),
-                          input({ type: "hidden", name: "sort", value: sort }),
-                          button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton)
-                        )
-                      ),
+                    div({ class: "tribe-card-body" },
+                      span({ class: "market-card-kind" }, String(item.item_type || "").toUpperCase()),
+                      h2({ class: "tribe-card-title" }, a({ href: `/market/${encodeURIComponent(item.id)}` }, item.title)),
+                      div({ class: "market-card-price" }, `${item.price} ECO`)
                     )
                   )
                 })
@@ -684,7 +632,26 @@ 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}:`, [userLink(item.seller)])
+        renderCardFieldRich(`${i18n.marketItemSeller}:`, [userLink(item.seller)]),
+        String(item.seller) === String(userId)
+          ? (() => {
+              const vis = (item.visibility || 'PUBLIC').toUpperCase() === 'HIDDEN' ? 'HIDDEN' : 'PUBLIC';
+              const next = vis === 'PUBLIC' ? 'HIDDEN' : 'PUBLIC';
+              return div({ class: "card-field" },
+                span({ class: "card-label" }, `${i18n.visibilityLabel || 'Visibility'}: `),
+                span({ class: vis === 'PUBLIC' ? 'visibility-public' : 'visibility-hidden' },
+                  vis === 'PUBLIC' ? (i18n.visibilityPublic || 'Public') : (i18n.visibilityHidden || 'Hidden')
+                ),
+                " ",
+                form({ method: "POST", action: `/market/visibility/${encodeURIComponent(item.id)}`, class: "inline-form" },
+                  input({ type: "hidden", name: "visibility", value: next }),
+                  button({ type: "submit", class: "filter-btn" },
+                    next === 'PUBLIC' ? (i18n.visibilityMakePublic || 'Make public') : (i18n.visibilityMakeHidden || 'Make hidden')
+                  )
+                )
+              );
+            })()
+          : null
       ),
       item.item_type === "auction"
         ? div(

+ 68 - 0
src/views/melody_view.js

@@ -0,0 +1,68 @@
+"use strict";
+
+const { div, h2, p, section, audio, source, span, a, form, input, button, br } = require("../server/node_modules/hyperaxe");
+const { template, i18n, userLink } = require("./main_views");
+
+const renderSequenceList = (sequence) => {
+  if (!sequence || sequence.length === 0) {
+    return p({ class: "empty" }, i18n.melodyEmpty || "No notes yet — your blockchain has not produced any block.");
+  }
+  return div({ class: "melody-composition" },
+    h2({ class: "melody-section-title" }, i18n.melodyCompositionTitle || "Composition"),
+    div({ class: "melody-notes-grid" },
+      sequence.slice(0, 256).map(n => div({ class: "melody-note-chip", title: `${n.type} · ${n.name} · ${n.durMs}ms` },
+        span({ class: "melody-note-name" }, n.name),
+        span({ class: "melody-note-type" }, n.type)
+      ))
+    )
+  );
+};
+
+const renderFilters = () => div({ class: "filters" },
+  form({ method: "GET", action: "/melody", class: "ui-toolbar ui-toolbar--filters" },
+    button({ type: "submit", name: "filter", value: "all", class: "filter-btn active" }, i18n.melodyFilterAll || "ALL")
+  )
+);
+
+exports.melodyView = ({ feedId, total, sequence }) => {
+  const title = i18n.melodyTitle || "Melody";
+  const description = i18n.melodyDescription || "Play the melody of your blockchain.";
+  const audioHref = `/melody/audio.wav?_=${Date.now()}`;
+  const body = div(
+    div({ class: "melody-player-card" },
+      div({ class: "melody-meta" },
+        span({ class: "card-label" }, `${i18n.melodyInhabitantLabel || "Inhabitant"}: `),
+        userLink(feedId),
+        span({ class: "melody-meta-sep" }, " · "),
+        span({ class: "card-label" }, `${i18n.melodyTotalBlocks || "Notes"}: `),
+        span({ class: "card-value" }, String(total))
+      ),
+      sequence && sequence.length > 0
+        ? div(
+            audio({ controls: true, preload: "none", class: "melody-audio" },
+              source({ src: audioHref, type: "audio/wav" })
+            ),
+            div({ class: "melody-regen-form" },
+              form({ method: "GET", action: "/melody", class: "inline-form" },
+                input({ type: "hidden", name: "r", value: String(Date.now()) }),
+                button({ type: "submit", class: "filter-btn" }, i18n.melodyRegenerate || "Regenerate")
+              ),
+              a({ href: "/melody/audio.wav?download=1", download: "oasis-melody.wav", class: "filter-btn melody-download-btn" }, i18n.melodyDownload || "Download Melody")
+            )
+          )
+        : p({ class: "empty" }, i18n.melodyEmpty || "No notes yet")
+    ),
+    renderSequenceList(sequence)
+  );
+  return template(
+    title,
+    section(
+      div({ class: "tags-header" },
+        h2(title),
+        p(description)
+      ),
+      renderFilters(),
+      body
+    )
+  );
+};

+ 3 - 2
src/views/modules_view.js

@@ -30,6 +30,7 @@ const modulesView = () => {
     { name: 'logs', label: i18n.modulesLogsLabel, description: i18n.modulesLogsDescription },
     { name: 'maps', label: i18n.modulesMapLabel, description: i18n.modulesMapDescription },
     { name: 'market', label: i18n.modulesMarketLabel, description: i18n.modulesMarketDescription },
+    { name: 'melody', label: i18n.modulesMelodyLabel, description: i18n.modulesMelodyDescription },
     { name: 'multiverse', label: i18n.modulesMultiverseLabel, description: i18n.modulesMultiverseDescription },
     { name: 'opinions', label: i18n.modulesOpinionsLabel, description: i18n.modulesOpinionsDescription },
     { name: 'pads', label: i18n.modulesPadsLabel, description: i18n.modulesPadsDescription },
@@ -83,8 +84,8 @@ const modulesView = () => {
 
   const PRESETS = {
     minimal: ['feed', 'forum', 'games', 'images', 'videos', 'audios', 'bookmarks', 'tags', 'trending', 'popular', 'latest', 'threads', 'opinions', 'cipher', 'legacy'],
-    social: ['agenda', 'audios', 'bookmarks', 'calendars', 'chats', 'cipher', 'courts', 'docs', 'events', 'favorites', 'feed', 'forum', 'games', 'images', 'invites', 'legacy', 'logs', 'maps', 'multiverse', 'opinions', 'pads', 'parliament', 'pixelia', 'projects', 'reports', 'tags', 'tasks', 'threads', 'trending', 'tribes', 'videos', 'votes'],
-    economy: ['agenda', 'audios', 'bookmarks', 'calendars', 'chats', 'cipher', 'courts', 'docs', 'events', 'favorites', 'feed', 'forum', 'games', 'images', 'invites', 'legacy', 'logs', 'maps', 'multiverse', 'opinions', 'pads', 'parliament', 'pixelia', 'projects', 'reports', 'tags', 'tasks', 'threads', 'trending', 'tribes', 'videos', 'votes', 'banking', 'wallet', 'transfers', 'market', 'jobs', 'shops'],
+    social: ['agenda', 'audios', 'bookmarks', 'calendars', 'chats', 'cipher', 'courts', 'docs', 'events', 'favorites', 'feed', 'forum', 'games', 'images', 'invites', 'legacy', 'logs', 'maps', 'multiverse', 'opinions', 'pads', 'parliament', 'pixelia', 'melody', 'projects', 'reports', 'tags', 'tasks', 'threads', 'trending', 'tribes', 'videos', 'votes'],
+    economy: ['agenda', 'audios', 'bookmarks', 'calendars', 'chats', 'cipher', 'courts', 'docs', 'events', 'favorites', 'feed', 'forum', 'games', 'images', 'invites', 'legacy', 'logs', 'maps', 'multiverse', 'opinions', 'pads', 'parliament', 'pixelia', 'melody', 'projects', 'reports', 'tags', 'tasks', 'threads', 'trending', 'tribes', 'videos', 'votes', 'banking', 'wallet', 'transfers', 'market', 'jobs', 'shops'],
     full: modules.map(m => m.name)
   };
 

+ 1 - 1
src/views/pads_view.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" }, userLink(pad.author))),
+      isRestrictedInviteOnly ? null : tr(td({ class: "tribe-info-value pad-author-cell", 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"))
     ),

+ 82 - 29
src/views/peers_view.js

@@ -1,5 +1,5 @@
-const peersView = async ({ onlinePeers, discoveredPeers, unknownPeers }) => {
-  const { form, button, div, h2, p, section, a, hr, input, label, br, span, table, tr, td } = require("../server/node_modules/hyperaxe");
+const peersView = async ({ onlinePeers, discoveredPeers, unknownPeers, lanBroadcastActive = false, technicalPeers = [] }) => {
+  const { form, button, div, h2, p, section, a, hr, input, label, br, span, table, tr, td, textarea } = require("../server/node_modules/hyperaxe");
   const { template, i18n } = require('./main_views');
 
   const startButton = form({ action: "/settings/conn/start", method: "post" }, button({ type: "submit" }, i18n.startNetworking));
@@ -18,6 +18,16 @@ const peersView = async ({ onlinePeers, discoveredPeers, unknownPeers }) => {
     });
   };
 
+  const sourceLabel = (src) => {
+    const s = String(src || '').toLowerCase();
+    if (s === 'rpc') return i18n.peerSourceRpc || 'RPC';
+    if (s === 'gossip') return i18n.peerSourceGossip || 'Gossip';
+    if (s === 'ebt') return i18n.peerSourceEbt || 'EBT';
+    if (s === 'recent') return i18n.peerSourceRecent || 'Recent';
+    if (s === 'lan') return i18n.peerSourceLan || 'LAN';
+    return null;
+  };
+
   const renderPeerRow = (peerData) => {
     const peer = peerData[1];
     const { name, users, key } = peer;
@@ -39,8 +49,8 @@ const peersView = async ({ onlinePeers, discoveredPeers, unknownPeers }) => {
   const discoveredCount = dedupDiscovered.length;
   const unknownCount = dedupUnknown.length;
 
-  const renderPeerTable = (peers) => {
-    if (peers.length === 0) return p(i18n.noConnections || i18n.noDiscovered);
+  const renderPeerTable = (peers, emptyKey) => {
+    if (peers.length === 0) return p(i18n[emptyKey] || i18n.noConnections);
     return table({ class: 'block-info-table' },
       tr(
         td({ class: 'card-label' }, i18n.peerHost || 'Pub'),
@@ -51,6 +61,60 @@ const peersView = async ({ onlinePeers, discoveredPeers, unknownPeers }) => {
     );
   };
 
+  const technicalRows = (technicalPeers || []).map(tp => {
+    const k = tp.key || '';
+    const connected = tp.state === 'connected';
+    const action = connected ? 'disconnect' : 'connect';
+    const btnLabel = connected ? (i18n.peerDisconnect || 'Disconnect') : (i18n.peerConnect || 'Connect');
+    return tr(
+      td(a({ href: `/author/${encodeURIComponent(k)}`, class: 'user-link peer-key' }, k ? k.slice(0, 20) + '…' : '—')),
+      td(String(tp.host || '—')),
+      td(String(tp.port || '—')),
+      td(String(tp.state || tp.source || '—')),
+      td(String(tp.stateChange ? new Date(tp.stateChange).toISOString().slice(0, 16).replace('T', ' ') : '—')),
+      td(
+        form({ method: "POST", action: `/peers/${action}`, class: "inline-form" },
+          input({ type: "hidden", name: "key", value: k }),
+          input({ type: "hidden", name: "host", value: String(tp.host || '') }),
+          input({ type: "hidden", name: "port", value: String(tp.port || 8008) }),
+          button({ type: "submit", class: "filter-btn" }, btnLabel)
+        )
+      )
+    );
+  });
+  const refreshButton = form({ action: "/peers/refresh", method: "post" }, button({ type: "submit" }, i18n.peerRefresh || 'Refresh'));
+  const pruneButton = form({ action: "/peers/prune", method: "post" }, button({ type: "submit" }, i18n.peerPruneIdle || 'Remove idle'));
+  const exportButton = form({ action: "/peers/export", method: "get" }, button({ type: "submit" }, i18n.peerExport || 'Export'));
+  const importForm = form(
+    { action: "/peers/import", method: "post", enctype: "multipart/form-data", class: "peers-import-form" },
+    label({ class: 'peers-import-label' }, i18n.peerImportTitle || 'Import peer list'),
+    br(),
+    textarea({ name: "peerList", rows: "4", placeholder: i18n.peerImportPlaceholder || 'Paste one multiserver address per line…' }),
+    br(),
+    input({ type: "file", name: "peerFile", accept: ".txt,text/plain" }),
+    br(),
+    button({ type: "submit", class: "filter-btn" }, i18n.peerImport || 'Import')
+  );
+
+  const peersTechnicalBlock = div({ class: 'tags-header peers-technical-block' },
+    h2(i18n.peerConnectionsTitle || 'Connections'),
+    div({ class: "conn-actions peers-conn-actions" }, refreshButton, pruneButton, exportButton),
+    technicalPeers.length
+      ? table({ class: 'block-info-table' },
+          tr(
+            td({ class: 'card-label' }, 'Key'),
+            td({ class: 'card-label' }, 'Host'),
+            td({ class: 'card-label' }, i18n.peerPort || 'Port'),
+            td({ class: 'card-label' }, 'State'),
+            td({ class: 'card-label' }, 'Last change'),
+            td({ class: 'card-label' }, '')
+          ),
+          ...technicalRows
+        )
+      : p(i18n.peersTechnicalEmpty || 'No peers registered yet.'),
+    importForm
+  );
+
   return template(
     i18n.peers,
     section(
@@ -59,31 +123,20 @@ const peersView = async ({ onlinePeers, discoveredPeers, unknownPeers }) => {
         p(i18n.peerConnectionsIntro)
       ),
       div({ class: "conn-actions" }, ...connButtons),
-      div({ class: 'tags-header', style: 'margin-top:16px;' },
-        h2(i18n.directConnect),
-        p(i18n.directConnectDescription),
-        form({ action: "/peers/connect", method: "post" },
-          label({ for: "peer_host" }, i18n.peerHost), br(),
-          input({ type: "text", id: "peer_host", name: "host", required: true, placeholder: "192.168.1.100", pattern: "(\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}|[a-zA-Z0-9]([a-zA-Z0-9\\-]*[a-zA-Z0-9])?(\\.[a-zA-Z0-9]([a-zA-Z0-9\\-]*[a-zA-Z0-9])?)*)", title: i18n.peerHostValidation || "Valid IPv4 (e.g. 192.168.1.100) or hostname (e.g. pub.example.com)", maxlength: 253 }), br(),
-          label({ for: "peer_port" }, i18n.peerPort), br(),
-          input({ type: "number", id: "peer_port", name: "port", placeholder: "8008", value: "8008", min: 1, max: 65535, required: true, title: i18n.peerPortValidation || "Port 1-65535" }), br(), br(),
-          label({ for: "peer_key" }, i18n.peerPublicKey), br(),
-          input({ type: "text", id: "peer_key", name: "key", required: true, placeholder: "@...=.ed25519", pattern: "@[A-Za-z0-9+/_\\-]{43}=\\.ed25519", title: i18n.peerKeyValidation || "SSB ed25519 public key (@<44 chars base64>=.ed25519)", maxlength: 56 }), br(), br(),
-          button({ type: "submit" }, i18n.connectAndFollow)
-        )
-      ),
-      hr(),
-      div({ class: "peers-list" },
-        h2(`${i18n.online} (${onlineCount})`),
-        renderPeerTable(dedupOnline),
-        hr(),
-        h2(`${i18n.discovered} (${discoveredCount})`),
-        renderPeerTable(dedupDiscovered),
-        hr(),
-        h2(`${i18n.unknown} (${unknownCount})`),
-        renderPeerTable(dedupUnknown),
-        p(i18n.connectionActionIntro)
-      )
+      (onlineCount + discoveredCount + unknownCount) > 0
+        ? div({ class: "peers-list" },
+            div({ class: "tags-header" }, h2(`${i18n.online} (${onlineCount})`)),
+            renderPeerTable(dedupOnline, 'noConnections'),
+            hr(),
+            div({ class: "tags-header" }, h2(`${i18n.discovered} (${discoveredCount})`)),
+            renderPeerTable(dedupDiscovered, 'noDiscovered'),
+            hr(),
+            div({ class: "tags-header" }, h2(`${i18n.unknown} (${unknownCount})`)),
+            renderPeerTable(dedupUnknown, 'noUnknownPeers')
+          )
+        : null,
+      peersTechnicalBlock,
+      p(i18n.connectionActionIntro)
     )
   );
 };

+ 5 - 7
src/views/pm_view.js

@@ -1,4 +1,4 @@
-const { div, h2, p, section, button, form, input, textarea, br, label, pre, span } = require("../server/node_modules/hyperaxe");
+const { div, h2, p, section, button, form, input, textarea, br, label, pre, span, strong } = require("../server/node_modules/hyperaxe");
 const { template, i18n } = require('./main_views');
 const { getConfig } = require('../configs/config-manager.js');
 
@@ -6,8 +6,6 @@ exports.pmView = async (initialRecipients = '', initialSubject = '', initialText
   const title = i18n.pmSendTitle;
   const description = i18n.pmDescription;
   const textLen = (initialText || '').length;
-  const pmVis = getConfig().pmVisibility === 'mutuals' ? 'mutuals' : 'whole';
-  const pmVisLabel = pmVis === 'mutuals' ? i18n.settingsPmVisibilityMutuals : i18n.settingsPmVisibilityWhole;
 
   return template(
     title,
@@ -19,20 +17,20 @@ exports.pmView = async (initialRecipients = '', initialSubject = '', initialText
       section(
         div({ class: "pm-form" },
           form({ method: "POST", action: "/pm", id: "pm-form" },
-            p({ class: "pm-visibility-note" }, span({ class: "accent" }, "* "), pmVisLabel),
-            label({ for: "recipients" }, i18n.pmRecipients),
+            label({ for: "recipients" }, i18n.pmLimitsHint),
             br(),
             input({
               type: "text",
               name: "recipients",
               placeholder: i18n.pmRecipientsHint,
               required: true,
-              value: initialRecipients
+              value: initialRecipients,
+              maxlength: "511"
             }),
             br(),
             label({ for: "subject" }, i18n.pmSubject),
             br(),
-            input({ type: "text", name: "subject", placeholder: i18n.pmSubjectHint, value: initialSubject }),
+            input({ type: "text", name: "subject", placeholder: i18n.pmSubjectHint, value: initialSubject, maxlength: "150" }),
             br(),
             label({ for: "text" }, i18n.pmText),
             br(),

+ 73 - 13
src/views/projects_view.js

@@ -365,6 +365,7 @@ const renderMilestonesAndBounties = (project, filter, editable) => {
       )
     : null
 
+  if (blocks.length === 0 && !unassignedBlock) return null
   return div({ class: "milestones-bounties" }, ...blocks, unassignedBlock)
 }
 
@@ -466,7 +467,7 @@ const renderProjectTopbar = (project, filter, opts) => {
   return nodes.length ? div({ class: isSingle ? "bookmark-topbar project-topbar-single" : "bookmark-topbar" }, ...nodes) : null
 }
 
-const renderProjectList = (projects, filter) => {
+const renderProjectList = exports.renderProjectList = (projects, filter) => {
   const list = safeArr(projects)
   const returnTo = buildReturnTo(filter)
 
@@ -497,7 +498,6 @@ const renderProjectList = (projects, filter) => {
           br(),
           div({ class: "project-goal-highlight" }, renderCardField(i18n.projectGoal + ":", `${pr.goal} ECO`)),
           div({ class: "project-goal-highlight" }, renderCardField(i18n.projectFollowers + ":", String(followersCount(pr)))),
-          renderProgressBlock(i18n.projectProgress + ":", `${pct}%`, pct, 100),
           renderProgressBlock(i18n.projectFunding + ":", `${fundingPct}%`, fundingPct, 100),
             pr.mapUrl ? div({ class: "project-maploc" },
             span({ class: "card-label" }, (i18n.mapLocationTitle || "Map Location") + ":"),
@@ -577,14 +577,6 @@ const renderProjectList = (projects, filter) => {
                 )
               )
             : null,
-          div(
-            { class: "card-comments-summary" },
-            span({ class: "card-label" }, i18n.voteCommentsLabel + ":"),
-            span({ class: "card-value" }, String(pr.commentCount || 0)),
-            br(),
-            br(),
-            form({ method: "GET", action: `/projects/${encodeURIComponent(pr.id)}` }, button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton))
-          ),
           div(
             { class: "card-footer" },
             span({ class: "date-link" }, `${moment(pr.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
@@ -665,15 +657,21 @@ const renderProjectForm = (project, mode) => {
   )
 }
 
-exports.projectsView = async (projectsOrForm, filter) => {
+exports.projectsView = async (projectsOrForm, filter, _unused, params = {}) => {
   const f = String(filter || "ALL").toUpperCase()
   const filterObj = FILTERS.find((x) => x.key === f) || FILTERS[0]
   const sectionTitle = i18n[filterObj.title] || i18n.projectAllTitle
+  const { renderReachChip: renderReachChipProjects } = require('./clearnet_view');
+  const viewerClearnetProjects = !!(params.viewerPrefs && params.viewerPrefs.clearnetProjects);
 
   return template(
     i18n.projectsTitle,
     section(
-      div({ class: "tags-header" }, h2(sectionTitle), p(i18n.projectsDescription)),
+      div({ class: "tags-header" },
+        h2(sectionTitle),
+        p(i18n.projectsDescription),
+        div({ class: "shop-title-row" }, renderReachChipProjects(viewerClearnetProjects, i18n))
+      ),
       div(
         { class: "filters" },
         form(
@@ -730,12 +728,12 @@ exports.singleProjectView = async (project, filter, comments, params = {}) => {
         !isAuthor && safeArr(pr.followers).includes(userId) ? p({ class: "hint" }, i18n.projectYouFollowHint) : null,
         h2(pr.title),
         safeText(pr.description) ? renderCardFieldRich(i18n.projectDescription + ":", renderUrl(pr.description)) : null,
+        safeText(pr.description) ? br() : null,
         pr.image ? renderMediaBlob(pr.image) : null,
         renderMapEmbedWithZoom(params.mapData, pr.mapUrl, `/projects/${encodeURIComponent(pr.id || pr.key)}`, params.zoom),
         div({ class: "project-goal-highlight" }, renderCardField(i18n.projectGoal + ":", `${pr.goal} ECO`)),
         renderBackers(pr, f),
         renderCardField(i18n.projectStatus + ":", i18n["projectStatus" + statusUpper] || statusUpper),
-        br(),
         renderProgressBlock(i18n.projectProgress + ":", `${pct}%`, pct, 100),
         renderProgressBlock(i18n.projectFunding + ":", `${fundingPct}%`, fundingPct, 100),
         renderBudget(pr),
@@ -787,3 +785,65 @@ exports.singleProjectView = async (project, filter, comments, params = {}) => {
   )
 }
 
+exports.clearnetProjectView = async (project) => {
+  const { escapeHtml: esc, blobUrl: cnBlob, renderClearnetPage } = require('./clearnet_view');
+  const pr = project || {};
+  const title = esc(pr.title || 'Project');
+  const desc = esc(pr.description || '');
+  const goal = Math.max(0, toNum(pr.goal) || 0);
+  const pledged = Math.max(0, toNum(pr.pledged) || 0);
+  const fundingPct = goal > 0 ? Math.min(100, Math.round((pledged / goal) * 100)) : 0;
+  const status = String(pr.status || 'ACTIVE').toUpperCase();
+  const projectImg = cnBlob(pr.image);
+  const deadline = pr.deadline ? new Date(pr.deadline).toISOString().slice(0, 10) : '';
+  const milestones = Array.isArray(pr.milestones) ? pr.milestones.slice(0, 10) : [];
+  const milestonesBlock = milestones.length
+    ? `<div class="cn-prj-section"><h2>Milestones</h2><ol class="cn-prj-ms">${milestones.map(m => `<li>${esc(m.title || '')}${m.targetPercent ? ` <span class="cn-prj-pct">— ${m.targetPercent}%</span>` : ''}</li>`).join('')}</ol></div>`
+    : '';
+  const extraCss = `
+.cn-prj-title{color:var(--fg);margin:0 0 12px 0;font-size:32px;font-weight:700}
+.cn-prj-status{display:inline-block;background:var(--bg-sub);border:1px solid var(--fg);color:var(--fg);padding:6px 12px;border-radius:6px;font-weight:600;text-transform:uppercase;letter-spacing:1px;font-size:12px;margin-bottom:16px}
+.cn-prj-img{display:block;max-width:100%;border:1px solid var(--border);border-radius:8px;margin-bottom:20px}
+.cn-prj-funding{background:var(--bg-sub);border:1px solid var(--border);border-radius:8px;padding:14px;margin-bottom:20px}
+.cn-prj-funding-label{color:var(--fg-dim);font-size:12px;text-transform:uppercase;letter-spacing:1px;margin-bottom:6px}
+.cn-prj-funding-amount{color:var(--fg);font-size:18px;font-weight:700;margin-bottom:8px}
+.cn-prj-bar{height:8px;background:#000;border-radius:4px;overflow:hidden}
+.cn-prj-bar-fill{height:100%;background:var(--fg);border-radius:4px}
+.cn-prj-section{margin-top:24px}
+.cn-prj-section h2{color:var(--fg);font-size:18px;text-transform:uppercase;letter-spacing:2px;margin:0 0 12px;padding-bottom:6px;border-bottom:1px solid var(--border)}
+.cn-prj-section p{color:var(--fg-soft);white-space:pre-wrap;line-height:1.6;font-size:15px;margin:0}
+.cn-prj-ms{padding-left:22px;margin:0;color:var(--fg-soft);line-height:1.7}
+.cn-prj-pct{color:var(--fg-dim);font-size:13px}
+.cn-prj-deadline{color:var(--fg-dim);font-size:13px;margin-top:8px}
+.cn-prj-meta{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-bottom:16px}
+.cn-prj-date,.cn-prj-idmeta{background:var(--bg-sub);border:1px solid var(--border);border-radius:6px;padding:6px 12px;font-size:13px;color:var(--fg-soft)}
+.cn-prj-idmeta{font-family:monospace;font-size:11px;word-break:break-all}
+`;
+  const body = `
+  <h1 class="cn-prj-title">${title}</h1>
+  <div class="cn-prj-meta">
+    <span class="cn-prj-status">${esc(status)}</span>
+    ${pr.createdAt ? `<span class="cn-prj-date">📅 ${esc(new Date(pr.createdAt).toISOString().slice(0,10))}</span>` : ''}
+  </div>
+  ${projectImg ? `<img class="cn-prj-img" src="${projectImg}" alt="${title}"/>` : ''}
+  ${goal > 0 ? `
+  <div class="cn-prj-funding">
+    <div class="cn-prj-funding-label">Funding</div>
+    <div class="cn-prj-funding-amount">${pledged.toFixed(2)} / ${goal.toFixed(2)} ECO · ${fundingPct}%</div>
+    <div class="cn-prj-bar"><div class="cn-prj-bar-fill" style="width:${fundingPct}%"></div></div>
+    ${deadline ? `<div class="cn-prj-deadline">Deadline: ${deadline}</div>` : ''}
+  </div>` : ''}
+  ${desc ? `<div class="cn-prj-section"><h2>Description</h2><p>${desc}</p></div>` : ''}
+  ${milestonesBlock}
+`;
+  return renderClearnetPage({
+    title: `${pr.title || 'Project'} — Oasis`,
+    ogTitle: pr.title || 'Project',
+    ogDescription: pr.description || '',
+    ogImage: projectImg,
+    extraCss,
+    body,
+    hubFeedId: pr.author || null
+  });
+};
+

+ 33 - 10
src/views/search_view.js

@@ -1,4 +1,4 @@
-const { form, button, div, h2, p, section, input, select, option, img, audio: audioHyperaxe, video: videoHyperaxe, table, hr, hd, br, td, tr, th, a, span } = require("../server/node_modules/hyperaxe");
+const { form, button, div, h2, p, section, input, label, select, option, img, audio: audioHyperaxe, video: videoHyperaxe, table, hr, hd, br, td, tr, th, a, span } = require("../server/node_modules/hyperaxe");
 const { template, i18n, userLink} = require('./main_views');
 const moment = require("../server/node_modules/moment");
 const { renderTextWithStyles } = require('../backend/renderTextWithStyles');
@@ -22,6 +22,7 @@ const rewriteHashtagLinks = (html) => {
 
 const searchView = ({ messages = [], blobs = {}, query = "", type = "", types = [], hashtag = null, results = {}, resultCount = "10" }) => {
   const searchInput = input({
+    id: "search_query",
     name: "query",
     required: false,
     type: "search",
@@ -58,10 +59,10 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
       class: "input-select",
       style: "position:relative; z-index:10;margin-left:10px;"
     },
-    option({ value: "100", selected: resultCount === "100" }, "100"),
-    option({ value: "50", selected: resultCount === "50" }, "50"),
-    option({ value: "10", selected: resultCount === "10" }, "10"),
-    option({ value: "all", selected: resultCount === "all" }, i18n.allTypesLabel)
+    option({ value: "100", selected: resultCount === "100" ? "selected" : undefined }, "100"),
+    option({ value: "50", selected: resultCount === "50" ? "selected" : undefined }, "50"),
+    option({ value: "10", selected: resultCount === "10" ? "selected" : undefined }, "10"),
+    option({ value: "all", selected: resultCount === "all" ? "selected" : undefined }, i18n.allTypesLabel)
   );
 
   const getViewDetailsActionForSearch = (type, contentId, content) => {
@@ -607,11 +608,33 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
       ),
       form(
         { action: "/search", method: "POST", class: "search-form" },
-        div({ class: "search-bar" },
-          filterSelect,
-          resultsPerPageSelect,
-          searchInput,
-          br(), br(),
+        div({ class: "search-filters-row" },
+          table({ class: "search-filters-table" },
+            tr(
+              td({ class: 'card-label' }, label({ for: "search_from" }, i18n.searchFromLabel || "From")),
+              td(input({ id: "search_from", type: "datetime-local", name: "from" }))
+            ),
+            tr(
+              td({ class: 'card-label' }, label({ for: "search_to" }, i18n.searchToLabel || "To")),
+              td(input({ id: "search_to", type: "datetime-local", name: "to" }))
+            ),
+            tr(
+              td({ class: 'card-label' }, label({ for: "search_inhabitant" }, "Oasis ID")),
+              td(input({ id: "search_inhabitant", type: "text", name: "inhabitant", placeholder: "@...=.ed25519", pattern: "@[A-Za-z0-9+/_\\-]{43}=\\.ed25519", maxlength: 56, class: "search-oasis-id" }))
+            )
+          ),
+          table({ class: "search-filters-table" },
+            tr(
+              td({ class: 'card-label' }, label({ for: "search_query" }, i18n.searchQueryLabel || "Query")),
+              td(searchInput)
+            ),
+            tr(
+              td({ class: 'card-label' }, label({ for: "results-per-page" }, i18n.searchPerPageLabel || "Results per page")),
+              td(resultsPerPageSelect)
+            )
+          )
+        ),
+        div({ class: "search-submit-row" },
           button({ type: "submit" }, i18n.searchSubmit)
         )
       )

+ 2 - 1
src/views/settings_view.js

@@ -155,7 +155,8 @@ const settingsView = ({ version, aiPrompt }) => {
           { action: "/settings/wish", method: "POST" },
           select({ name: "wish" },
             option({ value: "whole", selected: currentWish === "whole" ? true : undefined }, i18n.settingsWishWhole),
-            option({ value: "mutuals", selected: currentWish === "mutuals" ? true : undefined }, i18n.settingsWishMutuals)
+            option({ value: "mutuals", selected: currentWish === "mutuals" ? true : undefined }, i18n.settingsWishMutuals),
+            option({ value: "only-lan", selected: currentWish === "only-lan" ? true : undefined }, i18n.settingsWishOnlyLan || "Only LAN")
           ), br(), br(),
           button({ type: "submit" }, i18n.saveSettings)
         )

+ 124 - 8
src/views/shops_view.js

@@ -5,6 +5,7 @@ const { config } = require("../server/SSB_server.js")
 const { renderUrl } = require("../backend/renderUrl")
 const { renderMapLocationUrl, renderMapEmbed, renderMapLocationVisitLabel } = require("./maps_view")
 const opinionCategories = require("../backend/opinion_categories")
+const { renderReachChip, renderClearnetUrlBlock, renderClearnetPage, renderClearnetSearchForm, blobUrl: cnBlobUrl, escapeHtml: cnEscapeHtml } = require("./clearnet_view")
 
 const userId = config.keys.id
 const safeArr = (v) => (Array.isArray(v) ? v : [])
@@ -61,7 +62,7 @@ const renderFavoriteToggle = (shop, returnTo) =>
     button({ type: "submit", class: "filter-btn" }, shop.isFavorite ? i18n.shopRemoveFavorite : i18n.shopAddFavorite)
   )
 
-const renderShopCard = (shop, filter, params = {}) => {
+const renderShopCard = exports.renderShopCard = (shop, filter, params = {}) => {
   const returnTo = buildReturnTo(filter, params)
   const isAuthor = String(shop.author) === String(userId)
 
@@ -130,9 +131,7 @@ const renderProductCard = (product, shopId, returnTo) => {
       (() => {
         const actions = [];
         if (!isAuthor && stock > 0) {
-          actions.push(form({ method: "POST", action: `/shops/product/buy/${encodeURIComponent(product.key)}` },
-            input({ type: "hidden", name: "returnTo", value: returnTo }),
-            button({ type: "submit", class: "buy-btn" }, i18n.marketActionsBuy || i18n.shopBuy)));
+          actions.push(a({ href: productUrl, class: "buy-btn" }, i18n.marketActionsBuy || i18n.shopBuy));
         }
         actions.push(form({ method: "POST", action: product.isFavorite ? `/shops/favorites/remove/${encodeURIComponent(product.key)}` : `/shops/favorites/add/${encodeURIComponent(product.key)}` },
           returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
@@ -263,7 +262,14 @@ exports.shopsView = async (shops, filter, shopToEdit = null, params = {}) => {
 
   const isForm = filter === "create" || filter === "edit"
 
-  const header = div({ class: "tags-header" }, h2(title), p(i18n.shopDescription))
+  const viewerClearnet = !!(params.viewerPrefs && params.viewerPrefs.clearnetShops)
+  const header = [
+    div({ class: "tags-header" },
+      h2(title),
+      p(i18n.shopDescription)
+    ),
+    div({ class: "shop-title-row" }, renderReachChip(viewerClearnet, i18n))
+  ]
 
   const searchBar = div({ class: "filters" },
     form({ method: "GET", action: "/shops" },
@@ -283,7 +289,7 @@ exports.shopsView = async (shops, filter, shopToEdit = null, params = {}) => {
 
   return template(
     title,
-    section(header),
+    section(...header),
     section(renderModeButtons(filter)),
     !isForm ? section(searchBar) : null,
     section(
@@ -311,8 +317,12 @@ exports.singleShopView = async (shop, filter, products = [], comments = [], para
   const isAuthor = String(shop.author) === String(userId)
   const fullShareUrl = `/shops/${encodeURIComponent(shop.key)}`
 
+  const isClearnet = !!(params.authorPrefs && params.authorPrefs.clearnetShops && shop.visibility !== 'CLOSED');
   const shopSide = div({ class: "tribe-side" },
-    h2(shop.title || i18n.shopUntitled),
+    div({ class: "shop-title-row" },
+      h2(shop.title || i18n.shopUntitled),
+      renderReachChip(isClearnet, i18n)
+    ),
     renderMediaBlob(shop.image, '/assets/images/default-avatar.png', { class: 'tribe-detail-image' }),
     div({ class: "shop-share" },
       span({ class: "tribe-info-label" }, `${i18n.shopShareUrl}: `),
@@ -354,6 +364,11 @@ exports.singleShopView = async (shop, filter, products = [], comments = [], para
             button({ type: "submit", class: "tribe-action-btn" }, i18n.shopUpdate)
           )
         : null,
+      isAuthor
+        ? form({ method: "GET", action: `/shops/${encodeURIComponent(shop.key)}/orders` },
+            button({ type: "submit", class: "tribe-action-btn" }, i18n.shopOrdersTitle || "Orders")
+          )
+        : null,
       isAuthor
         ? form({ method: "POST", action: `/shops/delete/${encodeURIComponent(shop.key)}` },
             button({ type: "submit", class: "tribe-action-btn" }, i18n.shopDelete)
@@ -449,8 +464,24 @@ exports.singleProductView = async (product, shop, comments = [], params = {}) =>
           progress({ class: "confirmations-progress stock-progress", value: Math.min(stock, 100), max: 100 })
         ),
         !isAuthor && stock > 0
-          ? form({ method: "POST", action: `/shops/product/buy/${encodeURIComponent(product.key)}` },
+          ? form({ method: "POST", action: `/shops/product/buy/${encodeURIComponent(product.key)}`, class: "shop-buy-form" },
               input({ type: "hidden", name: "returnTo", value: returnTo }),
+              p({ class: "shop-buy-form-note" }, i18n.shopBuyEncryptedNote || "Your delivery details are sent encrypted only to the shop owner."),
+              label(i18n.shopBuyDeliveryAddress || "Delivery address"),
+              br(),
+              textarea({ name: "deliveryAddress", required: true, rows: 3, placeholder: i18n.shopBuyDeliveryAddressPlaceholder || "" }),
+              br(),
+              br(),
+              label(i18n.shopBuyContact || "Contact"),
+              br(),
+              input({ type: "text", name: "contact", placeholder: i18n.shopBuyContactPlaceholder || "email, phone, etc." }),
+              br(),
+              br(),
+              label(i18n.shopBuyNotes || "Notes"),
+              br(),
+              textarea({ name: "notes", rows: 2, placeholder: i18n.shopBuyNotesPlaceholder || "" }),
+              br(),
+              br(),
               button({ type: "submit", class: "buy-btn" }, i18n.marketActionsBuy || i18n.shopBuy)
             )
           : null,
@@ -486,3 +517,88 @@ exports.editProductView = async (product, shopId, params = {}) => {
     )
   )
 }
+
+exports.shopOrdersView = async (shop, orders) => {
+  const title = `${i18n.shopOrdersTitle || "Orders"}: ${shop.title || ""}`
+  const rows = (orders || []).map(o => div({ class: "shop-order-card card-section" },
+    div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.shopOrderProduct || "Product"}:`), span({ class: "card-value" }, String(o.title || o.productId || ""))),
+    div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.shopOrderPrice || "Price"}:`), span({ class: "card-value" }, `${Number(o.price || 0).toFixed(6)} ECO`)),
+    div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.shopOrderBuyer || "Buyer"}:`), userLink(o.buyer)),
+    div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.shopBuyDeliveryAddress || "Delivery address"}:`), span({ class: "card-value" }, String(o.deliveryAddress || ""))),
+    o.contact ? div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.shopBuyContact || "Contact"}:`), span({ class: "card-value" }, String(o.contact))) : null,
+    o.notes ? div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.shopBuyNotes || "Notes"}:`), span({ class: "card-value" }, String(o.notes))) : null,
+    p({ class: "card-footer" }, span({ class: "date-link" }, moment(o.createdAt || o.ts).format("YYYY-MM-DD HH:mm")))
+  ))
+  return template(
+    title,
+    section(
+      div({ class: "tags-header" }, h2(title), p(i18n.shopOrdersDescription || "Encrypted purchase orders received by this shop.")),
+      a({ href: `/shops/${encodeURIComponent(shop.key || shop.id || "")}`, class: "filter-btn" }, i18n.goBack || "Go back")
+    ),
+    section(
+      rows.length ? div({ class: "shop-orders-list" }, ...rows) : p(i18n.shopOrdersEmpty || "No orders yet.")
+    )
+  )
+}
+
+exports.clearnetShopView = async (shop, products = []) => {
+  const fmtPrice = (v) => {
+    const n = Number(v);
+    return Number.isFinite(n) ? n.toFixed(6) : '0.000000';
+  };
+  const productCards = (products || []).filter(p => Number(p.stock) > 0 || p.featured).map(prod => {
+    const pImg = cnBlobUrl(prod.image);
+    return `<article class="cn-product">
+      ${pImg ? `<img class="cn-product-img" src="${pImg}" alt="" loading="lazy"/>` : ''}
+      <h3 class="cn-product-title">${cnEscapeHtml(prod.title || '')}</h3>
+      ${prod.description ? `<p class="cn-product-desc">${cnEscapeHtml(prod.description)}</p>` : ''}
+      <p class="cn-product-price">${fmtPrice(prod.price)} ECO</p>
+      ${Number(prod.stock) > 0 ? `<p class="cn-product-stock">Stock: ${prod.stock}</p>` : ''}
+    </article>`;
+  }).join('\n');
+  const shopBlobUrl = cnBlobUrl(shop.image);
+  const shopImg = shopBlobUrl ? `<img class="cn-shop-img" src="${shopBlobUrl}" alt="${cnEscapeHtml(shop.title || '')}"/>` : '';
+  const desc = cnEscapeHtml(shop.shortDescription || shop.description || '');
+  const extraCss = `
+.cn-hero{display:flex;gap:24px;margin-bottom:24px;flex-wrap:wrap;align-items:flex-start}
+.cn-shop-img{display:block;max-width:280px;width:100%;border:3px solid var(--fg);border-radius:8px;background:#000}
+.cn-hero-body{flex:1 1 320px;min-width:0}
+.cn-shop-title{color:var(--fg);margin:0 0 12px 0;font-size:32px;font-weight:700;letter-spacing:0.3px}
+.cn-shop-desc{color:var(--fg-soft);margin:0 0 16px 0;font-size:15px;white-space:pre-wrap}
+.cn-shop-meta{display:flex;gap:12px;flex-wrap:wrap;background:var(--bg-sub);border:1px solid var(--border);border-radius:8px;padding:10px 14px;font-size:13px;color:var(--fg-soft)}
+.cn-shop-meta-item{display:inline-flex;align-items:center;gap:6px}
+.cn-products{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:16px}
+.cn-product{background:var(--bg-elev);border:1px solid var(--border);border-radius:8px;padding:16px;transition:border-color .15s ease}
+.cn-product:hover{border-color:var(--fg)}
+.cn-product-img{width:100%;height:180px;object-fit:cover;border-radius:6px;margin-bottom:10px;background:#000;border:1px solid var(--border)}
+.cn-product-title{color:var(--fg);font-size:16px;margin:0 0 8px 0;font-weight:600}
+.cn-product-desc{color:var(--fg-soft);font-size:13px;margin:0 0 10px 0;line-height:1.4}
+.cn-product-price{color:var(--fg);background:var(--bg-sub);border:1px solid var(--fg);display:inline-block;padding:4px 10px;border-radius:4px;font-weight:bold;margin:0;font-size:14px}
+.cn-product-stock{color:var(--fg-dim);font-size:12px;margin:8px 0 0 0;text-transform:uppercase;letter-spacing:1px}
+.cn-empty{background:var(--bg-elev);border:1px dashed var(--border);border-radius:8px;padding:32px;text-align:center;color:var(--fg-dim)}
+`;
+  const body = `
+  <div class="cn-hero">
+    ${shopImg}
+    <div class="cn-hero-body">
+      <h1 class="cn-shop-title">${cnEscapeHtml(shop.title || '')}</h1>
+      ${desc ? `<p class="cn-shop-desc">${desc}</p>` : ''}
+      <div class="cn-shop-meta">
+        ${shop.createdAt ? `<span class="cn-shop-meta-item">📅 ${new Date(shop.createdAt).toISOString().slice(0,10)}</span>` : ''}
+        ${shop.location ? `<span class="cn-shop-meta-item">📍 ${cnEscapeHtml(shop.location)}</span>` : ''}
+      </div>
+    </div>
+  </div>
+  <h2 class="cn-section">Products</h2>
+  ${productCards ? `<div class="cn-products">${productCards}</div>` : '<div class="cn-empty">No products available.</div>'}
+`;
+  return renderClearnetPage({
+    title: `${shop.title || 'Shop'} — Oasis`,
+    ogTitle: shop.title || 'Oasis',
+    ogDescription: shop.shortDescription || shop.description || '',
+    ogImage: shopBlobUrl,
+    extraCss,
+    body,
+    hubFeedId: shop.author || null
+  });
+};

+ 85 - 89
src/views/stats_view.js

@@ -1,4 +1,4 @@
-const { div, h2, p, section, button, form, input, ul, li, a, h3, span, strong, table, tr, td, th } = require("../server/node_modules/hyperaxe");
+const { div, h2, p, section, button, form, input, ul, li, a, h3, span, strong, table, thead, tbody, tr, td, th } = require("../server/node_modules/hyperaxe");
 const { template, i18n, userLink } = require('./main_views');
 
 Object.assign(i18n, {
@@ -99,10 +99,22 @@ exports.statsView = (stats, filter) => {
     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 isZero = (value) => {
+    if (value === 0 || value === '0') return true;
+    const s = String(value == null ? '' : value).trim();
+    if (!s) return true;
+    const n = parseFloat(s);
+    if (!Number.isFinite(n)) return false;
+    return n === 0;
+  };
+
+  const kpi = (label, value) => {
+    if (isZero(value)) return null;
+    return 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));
@@ -256,15 +268,12 @@ exports.statsView = (stats, filter) => {
   );
 
   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)))
+      tr(td({ class: 'card-label' }, i18n.statsEcoWalletLabel), td({ class: 'card-value' }, a({ href: '/wallet', class: 'stats-link-break' }, stats?.banking?.myAddress || i18n.statsEcoWalletNotConfigured)))
     )
   );
 
   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)}%`)
@@ -322,52 +331,63 @@ exports.statsView = (stats, filter) => {
     )
   ) : 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 marketTiles = [
+    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)
+  ].filter(Boolean);
+  const marketBlock = marketTiles.length
+    ? div({ class: 'stats-block' }, h2(i18n.statsMarketTitle), kpiGrid(...marketTiles))
+    : null;
 
-  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 projectsTiles = [
+    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`)
+  ].filter(Boolean);
+  const projectsBlock = projectsTiles.length
+    ? div({ class: 'stats-block' }, h2(i18n.statsProjectsTitle), kpiGrid(...projectsTiles))
+    : null;
 
   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 = [];
+  const buildContentRows = () => {
+    const rows = [];
     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')));
+      rows.push([labels[t], cnt]);
+      if (t === 'shop') rows.push([labels.shopProduct, C(stats, 'shopProduct')]);
+      else if (t === 'pad') rows.push([labels.padEntry, C(stats, 'padEntry')]);
+      else if (t === 'chat') rows.push([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')));
+        rows.push([labels.calendarDate, C(stats, 'calendarDate')]);
+        rows.push([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));
+        rows.push([i18n.statsPublic, stats.tribePublicCount || 0]);
+        rows.push([i18n.statsPrivate, stats.tribePrivateCount || 0]);
       }
     });
-    return tiles;
+    return rows;
+  };
+  const buildContentTable = () => {
+    const rows = buildContentRows().slice().sort((a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0));
+    if (!rows.length) return p({ class: 'no-content' }, i18n.no_results || 'No data');
+    return table({ class: 'tag-table' },
+      thead(tr(
+        th(i18n.statsContentTypeColumn || 'Type'),
+        th(i18n.statsContentCountColumn || 'Count')
+      )),
+      tbody(...rows.map(([label, count]) => tr(td(label), td(String(count)))))
+    );
   };
 
   const buildOpinionTiles = () =>
@@ -386,28 +406,18 @@ exports.statsView = (stats, filter) => {
     ? 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())
-        )
+        totalOpinions > 0
+          ? div({ class: 'stats-block' },
+              h2(`${i18n.statsNetworkOpinions}: ${totalOpinions}`),
+              kpiGrid(...buildOpinionTiles())
+            )
+          : null,
+        totalContent > 0
+          ? div({ class: 'stats-block' },
+              h2(`${i18n.statsNetworkContent}: ${totalContent}`),
+              buildContentTable()
+            )
+          : null
       ])
     : null;
 
@@ -415,31 +425,18 @@ exports.statsView = (stats, filter) => {
     ? 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)
+        totalOpinions > 0
+          ? div({ class: 'stats-block' },
+              h2(`${i18n.statsYourOpinions}: ${totalOpinions}`),
+              kpiGrid(...buildOpinionTiles())
+            )
           : null,
-        marketBlock,
-        projectsBlock,
-        div({ class: 'stats-block' },
-          h2(`${i18n.statsYourOpinions}: ${totalOpinions}`),
-          kpiGrid(...buildOpinionTiles())
-        ),
-        div({ class: 'stats-block' },
-          h2(`${i18n.statsYourContent}: ${totalContent}`),
-          kpiGrid(...buildContentTiles())
-        )
+        totalContent > 0
+          ? div({ class: 'stats-block' },
+              h2(`${i18n.statsYourContent}: ${totalContent}`),
+              buildContentTable()
+            )
+          : null
       ])
     : null;
 
@@ -473,7 +470,6 @@ exports.statsView = (stats, filter) => {
         topStrip,
         headerCard,
         bankingCard,
-        carbonCard,
         allMode,
         mineMode,
         tombMode

+ 17 - 3
src/views/torrents_view.js

@@ -19,7 +19,7 @@ const {
   td
 } = require("../server/node_modules/hyperaxe");
 
-const { template, i18n, userLink} = require("./main_views");
+const { template, i18n, userLink, renderSpreadButton} = require("./main_views");
 const moment = require("../server/node_modules/moment");
 const { config } = require("../server/SSB_server.js");
 const { renderUrl } = require("../backend/renderUrl");
@@ -162,7 +162,7 @@ const formatSize = (bytes) => {
   return (n / (1024 * 1024)).toFixed(1) + " MB";
 };
 
-const renderTorrentTable = (torrents, filter, params = {}) => {
+const renderTorrentTable = exports.renderTorrentTable = (torrents, filter, params = {}) => {
   const returnTo = buildReturnTo(filter, params);
 
   if (!torrents.length) return p(params.q ? i18n.torrentNoMatch : i18n.noTorrents);
@@ -269,7 +269,15 @@ exports.torrentsView = async (torrents, filter = "all", torrentId = null, params
   return template(
     title,
     section(
-      div({ class: "tags-header" }, h2(title), p(i18n.torrentsDescription)),
+      div({ class: "tags-header" },
+        h2(title),
+        p(i18n.torrentsDescription),
+        (() => {
+          const { renderReachChip } = require('./clearnet_view');
+          const isClearnet = !!(params.viewerPrefs && params.viewerPrefs.clearnetTorrents);
+          return div({ class: "shop-title-row" }, renderReachChip(isClearnet, i18n));
+        })()
+      ),
       div(
         { class: "filters" },
         form(
@@ -360,6 +368,11 @@ exports.singleTorrentView = async (torrentObj, filter = "all", comments = [], pa
         topbar,
         title ? h2(title) : null,
         safeText(torrentObj.description) ? p(...renderUrl(torrentObj.description)) : null,
+        (() => {
+          const { renderReachChip } = require('./clearnet_view');
+          const isClearnet = !!(params.authorPrefs && params.authorPrefs.clearnetTorrents);
+          return div({ class: 'shop-title-row' }, renderReachChip(isClearnet, i18n));
+        })(),
         torrentObj.url && torrentObj.url.startsWith("&")
           ? div({ class: "torrent-download" },
               a({ href: `/blob/${encodeURIComponent(torrentObj.url)}?name=${encodeURIComponent((torrentObj.title || 'download').replace(/\.torrent$/i, '') + '.torrent')}` , class: "filter-btn" }, i18n.torrentDownloadButton || "DOWNLOAD IT!")
@@ -384,6 +397,7 @@ exports.singleTorrentView = async (torrentObj, filter = "all", comments = [], pa
               : null
           );
         })(),
+        div({ class: "spread-row" }, renderSpreadButton(torrentObj.key, params.spreads)),
         div(
           { class: "voting-buttons" },
           opinionCategories.map((category) =>

+ 114 - 10
src/views/transfer_view.js

@@ -19,6 +19,26 @@ const fmtAmount = (v) => {
   return Number.isFinite(n) ? n.toFixed(6) : String(v ?? "")
 }
 
+const categoryOf = (t) => {
+  const c = String(t?.category || "ECONOMIC").toUpperCase()
+  return ["ECONOMIC", "TIME", "TRUST"].includes(c) ? c : "ECONOMIC"
+}
+
+const fmtAmountWithUnit = (transfer) => {
+  const amt = fmtAmount(transfer.amount)
+  const cat = categoryOf(transfer)
+  const unit = cat === "TIME" ? (i18n.transfersUnitHours || "h")
+             : cat === "TRUST" ? (i18n.transfersUnitTrust || "trust")
+             : (i18n.transfersUnitEco || "ECO")
+  return `${amt} ${unit}`
+}
+
+const categoryLabel = (cat) => (
+  cat === "TIME" ? (i18n.transfersCategoryTime || "Time") :
+  cat === "TRUST" ? (i18n.transfersCategoryTrust || "Trust") :
+  (i18n.transfersCategoryEconomic || "Economic")
+)
+
 const buildReturnTo = (filter, params = {}) => {
   const f = safeText(filter || "all")
   const q = safeText(params.q || "")
@@ -56,6 +76,26 @@ const renderCardField = (labelText, valueNode) =>
     span({ class: "card-value" }, valueNode)
   )
 
+const formatBlockSize = (bytes) => {
+  const n = Number(bytes || 0)
+  if (n < 1024) return `${n} B`
+  if (n < 1024 * 1024) return `${(n / 1024).toFixed(2)} KB`
+  return `${(n / (1024 * 1024)).toFixed(2)} MB`
+}
+
+const renderBlockInfoCard = (block) => {
+  if (!block || !block.id) return null
+  return div(
+    { class: "transfer-item transfer-block-card" },
+    div(
+      { class: "card-section transfer" },
+      renderCardField(`${i18n.blockchainBlockID || "Block ID"}:`,
+        a({ href: `/blockexplorer/block/${encodeURIComponent(block.id)}`, class: "user-link" }, block.id)
+      )
+    )
+  )
+}
+
 const renderConfirmationsBar = (confirmedCount, required) => {
   const req = Math.max(1, Number(required || 2))
   const cc = Math.max(0, Number(confirmedCount || 0))
@@ -171,10 +211,11 @@ const generateTransferCard = (transfer, filter, params = {}) => {
       { class: "card-section transfer" },
       topbar ? topbar : null,
       renderCardField(`${i18n.transfersConcept}:`, transfer.concept || ""),
+      renderCardField(`${i18n.transfersCategory || "Category"}:`, categoryLabel(categoryOf(transfer))),
       isUbi ? null : renderCardField(`${i18n.transfersDeadline}:`, dl && dl.isValid() ? dl.format("YYYY-MM-DD HH:mm") : ""),
       renderCardField(`${i18n.transfersStatus}:`, i18n[statusKey(transfer.status)] || String(transfer.status || "")),
       br,
-      div({ class: "transfer-amount-highlight" }, renderCardField(`${i18n.transfersAmount}:`, `${fmtAmount(transfer.amount)} ECO`)),
+      categoryOf(transfer) === "TRUST" ? null : div({ class: "transfer-amount-highlight" }, renderCardField(`${i18n.transfersAmount}:`, fmtAmountWithUnit(transfer))),
       renderConfirmationsBar(confirmedCount, required),
       showConfirm
         ? form(
@@ -208,6 +249,9 @@ exports.transferView = async (transfers, filter, transferId, params = {}) => {
     normalizedFilter === "discarded"   ? i18n.transfersDiscardedSectionTitle :
     normalizedFilter === "create"      ? i18n.transfersCreateSectionTitle :
     normalizedFilter === "edit"        ? i18n.transfersUpdateSectionTitle :
+    normalizedFilter === "economic"    ? (i18n.transfersEconomicSectionTitle || (i18n.transfersCategoryEconomic + " " + i18n.transfersTitle)) :
+    normalizedFilter === "time"        ? (i18n.transfersTimeSectionTitle || (i18n.transfersCategoryTime + " " + i18n.transfersTitle)) :
+    normalizedFilter === "trust"       ? (i18n.transfersTrustSectionTitle || (i18n.transfersCategoryTrust + " " + i18n.transfersTitle)) :
                                         i18n.transfersAllSectionTitle
 
   const q = safeText(params.q || "")
@@ -227,6 +271,9 @@ exports.transferView = async (transfers, filter, transferId, params = {}) => {
     normalizedFilter === "unconfirmed" ? list.filter(t => String(t.status || "").toUpperCase() === "UNCONFIRMED") :
     normalizedFilter === "closed"      ? list.filter(t => String(t.status || "").toUpperCase() === "CLOSED") :
     normalizedFilter === "discarded"   ? list.filter(t => String(t.status || "").toUpperCase() === "DISCARDED") :
+    normalizedFilter === "economic"    ? list.filter(t => categoryOf(t) === "ECONOMIC") :
+    normalizedFilter === "time"        ? list.filter(t => categoryOf(t) === "TIME") :
+    normalizedFilter === "trust"       ? list.filter(t => categoryOf(t) === "TRUST") :
     normalizedFilter === "market"      ? list :
                                         list
 
@@ -255,6 +302,16 @@ exports.transferView = async (transfers, filter, transferId, params = {}) => {
   const isForm = normalizedFilter === "create" || normalizedFilter === "edit"
   const transferToEdit = normalizedFilter === "edit" ? (list.find(t => t.id === transferId) || {}) : {}
   const returnToForForm = buildReturnTo("all", {})
+  const validCategories = ["ECONOMIC", "TIME", "TRUST"]
+  const paramCategoryRaw = safeText(params.category || "").toUpperCase()
+  const selectedCategory =
+    transferToEdit.category && validCategories.includes(transferToEdit.category)
+      ? transferToEdit.category
+      : (validCategories.includes(paramCategoryRaw) ? paramCategoryRaw : "ECONOMIC")
+  const amountUnitLabel =
+    selectedCategory === "TIME" ? (i18n.transfersUnitHours || "h") :
+    selectedCategory === "TRUST" ? (i18n.transfersUnitTrust || "trust") :
+    (i18n.transfersUnitEco || "ECO")
 
   return template(
     title,
@@ -271,6 +328,9 @@ exports.transferView = async (transfers, filter, transferId, params = {}) => {
           button({ type: "submit", name: "filter", value: "all", class: normalizedFilter === "all" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterAll),
           button({ type: "submit", name: "filter", value: "mine", class: normalizedFilter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterMine),
           button({ type: "submit", name: "filter", value: "ubi", class: normalizedFilter === "ubi" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterUBI),
+          button({ type: "submit", name: "filter", value: "economic", class: normalizedFilter === "economic" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterEconomic || (i18n.transfersCategoryEconomic || "ECONOMIC")),
+          button({ type: "submit", name: "filter", value: "time", class: normalizedFilter === "time" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterTime || (i18n.transfersCategoryTime || "TIME")),
+          button({ type: "submit", name: "filter", value: "trust", class: normalizedFilter === "trust" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterTrust || (i18n.transfersCategoryTrust || "TRUST")),
           button({ type: "submit", name: "filter", value: "market", class: normalizedFilter === "market" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterMarket),
           button({ type: "submit", name: "filter", value: "pending", class: normalizedFilter === "pending" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterPending),
           button({ type: "submit", name: "filter", value: "unconfirmed", class: normalizedFilter === "unconfirmed" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterUnconfirmed),
@@ -285,9 +345,27 @@ exports.transferView = async (transfers, filter, transferId, params = {}) => {
       isForm
         ? div(
             { class: "transfer-form" },
+            normalizedFilter === "create"
+              ? form(
+                  { method: "GET", action: "/transfers", class: "transfer-category-picker" },
+                  input({ type: "hidden", name: "filter", value: "create" }),
+                  label(i18n.transfersCategory || "Category"),
+                  br(),
+                  select(
+                    { name: "category", class: "transfer-category-select" },
+                    option({ value: "ECONOMIC", selected: selectedCategory === "ECONOMIC" ? "selected" : undefined }, i18n.transfersCategoryEconomic || "Economic"),
+                    option({ value: "TIME", selected: selectedCategory === "TIME" ? "selected" : undefined }, i18n.transfersCategoryTime || "Time"),
+                    option({ value: "TRUST", selected: selectedCategory === "TRUST" ? "selected" : undefined }, i18n.transfersCategoryTrust || "Trust")
+                  ),
+                  " ",
+                  button({ type: "submit", class: "filter-btn" }, i18n.transfersCategoryApply || "Apply"),
+                  br(), br()
+                )
+              : null,
             form(
               { action: normalizedFilter === "edit" ? `/transfers/update/${encodeURIComponent(transferId)}` : "/transfers/create", method: "POST" },
               input({ type: "hidden", name: "returnTo", value: returnToForForm }),
+              normalizedFilter === "create" ? input({ type: "hidden", name: "category", value: selectedCategory }) : null,
               label(i18n.transfersToUser),
               br(),
               input({ type: "text", name: "to", required: true, pattern: "^@[A-Za-z0-9+/]+={0,2}\\.ed25519$", title: i18n.transfersToUserValidation, value: transferToEdit.to || "" }),
@@ -296,11 +374,27 @@ exports.transferView = async (transfers, filter, transferId, params = {}) => {
               br(),
               input({ type: "text", name: "concept", required: true, value: transferToEdit.concept || "" }),
               br(),
-              label(i18n.transfersAmount),
-              br(),
-              input({ type: "number", name: "amount", step: "0.000001", required: true, min: "0.000001", value: transferToEdit.amount || "" }),
-              br(),
-              br(),
+              normalizedFilter === "edit"
+                ? [
+                    label(i18n.transfersCategory || "Category"),
+                    br(),
+                    select(
+                      { name: "category", required: true },
+                      option({ value: "ECONOMIC", selected: selectedCategory === "ECONOMIC" ? "selected" : undefined }, i18n.transfersCategoryEconomic || "Economic"),
+                      option({ value: "TIME", selected: selectedCategory === "TIME" ? "selected" : undefined }, i18n.transfersCategoryTime || "Time"),
+                      option({ value: "TRUST", selected: selectedCategory === "TRUST" ? "selected" : undefined }, i18n.transfersCategoryTrust || "Trust")
+                    ),
+                    br(), br()
+                  ]
+                : null,
+              selectedCategory === "TRUST"
+                ? input({ type: "hidden", name: "amount", value: "1" })
+                : [
+                    label(`${i18n.transfersAmount} (${amountUnitLabel})`),
+                    br(),
+                    input({ type: "number", name: "amount", step: "0.000001", required: true, min: "0.000001", value: transferToEdit.amount || "" }),
+                    br(), br()
+                  ],
               label(i18n.transfersDeadline),
               br(),
               input({ type: "datetime-local", name: "deadline", required: true, min: moment().format("YYYY-MM-DDTHH:mm"), value: transferToEdit.deadline ? moment(transferToEdit.deadline).format("YYYY-MM-DDTHH:mm") : "" }),
@@ -330,9 +424,9 @@ exports.transferView = async (transfers, filter, transferId, params = {}) => {
                   ),
                   select(
                     { name: "sort", class: "filter-box__select" },
-                    option({ value: "recent", selected: sort === "recent" }, i18n.transfersSortRecent),
-                    option({ value: "amount", selected: sort === "amount" }, i18n.transfersSortAmount),
-                    option({ value: "deadline", selected: sort === "deadline" }, i18n.transfersSortDeadline)
+                    option({ value: "recent", selected: sort === "recent" ? "selected" : undefined }, i18n.transfersSortRecent),
+                    option({ value: "amount", selected: sort === "amount" ? "selected" : undefined }, i18n.transfersSortAmount),
+                    option({ value: "deadline", selected: sort === "deadline" ? "selected" : undefined }, i18n.transfersSortDeadline)
                   ),
                   button({ type: "submit", class: "filter-box__button" }, i18n.transfersSearchButton)
                 )
@@ -383,6 +477,9 @@ exports.singleTransferView = async (transfer, filter, params = {}) => {
           button({ type: "submit", name: "filter", value: "all", class: normalizedFilter === "all" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterAll),
           button({ type: "submit", name: "filter", value: "mine", class: normalizedFilter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterMine),
           button({ type: "submit", name: "filter", value: "ubi", class: normalizedFilter === "ubi" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterUBI),
+          button({ type: "submit", name: "filter", value: "economic", class: normalizedFilter === "economic" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterEconomic || (i18n.transfersCategoryEconomic || "ECONOMIC")),
+          button({ type: "submit", name: "filter", value: "time", class: normalizedFilter === "time" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterTime || (i18n.transfersCategoryTime || "TIME")),
+          button({ type: "submit", name: "filter", value: "trust", class: normalizedFilter === "trust" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterTrust || (i18n.transfersCategoryTrust || "TRUST")),
           button({ type: "submit", name: "filter", value: "market", class: normalizedFilter === "market" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterMarket),
           button({ type: "submit", name: "filter", value: "pending", class: normalizedFilter === "pending" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterPending),
           button({ type: "submit", name: "filter", value: "unconfirmed", class: normalizedFilter === "unconfirmed" ? "filter-btn active" : "filter-btn" }, i18n.transfersFilterUnconfirmed),
@@ -392,6 +489,7 @@ exports.singleTransferView = async (transfer, filter, params = {}) => {
           button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.transfersCreateButton)
         )
       ),
+      renderBlockInfoCard(params.block),
       div(
         { class: "transfer-item" },
         div(
@@ -400,7 +498,8 @@ exports.singleTransferView = async (transfer, filter, params = {}) => {
           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`)),
+          categoryOf(transfer) === "TRUST" ? null : div({ class: "transfer-amount-highlight" }, renderCardField(`${i18n.transfersAmount}:`, fmtAmountWithUnit(transfer))),
+          renderCardField(`${i18n.transfersCategory || "Category"}:`, categoryLabel(categoryOf(transfer))),
           renderCardField(`${i18n.transfersConcept}:`, transfer.concept || ""),
           isUbi ? null : renderCardField(`${i18n.transfersDeadline}:`, dl && dl.isValid() ? dl.format("YYYY-MM-DD HH:mm") : ""),
           renderCardField(`${i18n.transfersStatus}:`, i18n[statusKey(transfer.status)] || String(transfer.status || "")),
@@ -417,6 +516,11 @@ exports.singleTransferView = async (transfer, filter, params = {}) => {
             : null,
           tagsNode ? tagsNode : null,
           tagsNode ? br() : null,
+          form(
+            { method: "GET", action: `/transfers/contract/${encodeURIComponent(transfer.id)}`, class: "transfer-contract-form" },
+            button({ type: "submit", class: "filter-btn" }, i18n.transfersExportContract || 'Create Contract')
+          ),
+          br(),
           p(
             { class: "card-footer" },
             span({ class: "date-link" }, `${moment(transfer.createdAt).format("YYYY-MM-DD HH:mm")} ${i18n.performed} `),

+ 5 - 2
src/views/tribes_view.js

@@ -101,7 +101,10 @@ const renderLightbox = (sortedTribes) => {
 
 exports.renderInvitePage = (inviteCode) => {
   const pageContent = div({ class: 'invite-page' },
-    h2(i18n.tribeInviteCodeText, inviteCode),
+    h2(i18n.tribeInviteCodeText.replace(/:\s*$/, '')),
+    p(i18n.tribeInviteCodeHint || 'Share this code with someone you want to invite. They can join via /invites → Tribes.'),
+    input({ type: 'text', value: inviteCode, readonly: 'readonly', class: 'tribe-invite-code-input' }),
+    br(),
     form({ method: "GET", action: `/tribes` },
       button({ type: "submit", class: "filter-btn" }, i18n.walletBack)
     ),
@@ -1545,7 +1548,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' }, userLink(tribe.author))
+          td({ class: 'tribe-info-value tribe-author-cell', colspan: '4' }, userLink(tribe.author))
         ),
         tribe.location ? tr(
           td({ class: 'tribe-info-label' }, i18n.tribeLocationLabel || 'LOCATION'),

+ 17 - 3
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, userLink} = require("./main_views");
+const { template, i18n, userLink, renderSpreadButton} = require("./main_views");
 const { config } = require("../server/SSB_server.js");
 const { renderUrl } = require("../backend/renderUrl")
 const { renderMapLocationVisitLabel } = require("./maps_view");
@@ -179,7 +179,7 @@ const renderVideoCommentsSection = (videoId, comments = [], returnTo = null) =>
   );
 };
 
-const renderVideoList = (videos, filter, params = {}) => {
+const renderVideoList = exports.renderVideoList = (videos, filter, params = {}) => {
   const returnTo = buildReturnTo(filter, params);
 
   return videos.length
@@ -317,7 +317,15 @@ exports.videoView = async (videos, filter = "all", videoId = null, params = {})
   return template(
     title,
     section(
-      div({ class: "tags-header" }, h2(title), p(i18n.videoDescription)),
+      div({ class: "tags-header" },
+        h2(title),
+        p(i18n.videoDescription),
+        (() => {
+          const { renderReachChip } = require('./clearnet_view');
+          const isClearnet = !!(params.viewerPrefs && params.viewerPrefs.clearnetVideos);
+          return div({ class: "shop-title-row" }, renderReachChip(isClearnet, i18n));
+        })()
+      ),
       div(
         { class: "filters" },
         form(
@@ -414,6 +422,11 @@ exports.singleVideoView = async (videoObj, filter = "all", comments = [], params
         title ? h2(title) : null,
         renderVideoPlayer(videoObj),
         safeText(videoObj.description) ? p(...renderUrl(videoObj.description)) : null,
+        (() => {
+          const { renderReachChip } = require('./clearnet_view');
+          const isClearnet = !!(params.authorPrefs && params.authorPrefs.clearnetVideos);
+          return div({ class: 'shop-title-row' }, renderReachChip(isClearnet, i18n));
+        })(),
         renderTags(videoObj.tags),
         br(),
         renderMapLocationVisitLabel(videoObj.mapUrl),
@@ -435,6 +448,7 @@ exports.singleVideoView = async (videoObj, filter = "all", comments = [], params
               : null
           );
         })(),
+        div({ class: "spread-row" }, renderSpreadButton(videoObj.key, params.spreads)),
         div(
           { class: "voting-buttons" },
           opinionCategories.map((category) =>

+ 151 - 0
src/views/welcome_view.js

@@ -0,0 +1,151 @@
+const { a, br, button, div, form, h1, h2, h3, img, input, p, section, span, strong } = require("../server/node_modules/hyperaxe");
+const { template, i18n } = require("./main_views");
+
+const STEPS = [
+  {
+    id: 'welcome',
+    title: () => i18n.welcomeStep1Title || 'Hi, I am Oasis-42',
+    body: () => [
+      i18n.welcomeStep1L1 || "Welcome to Oasis: a libre, peer-to-peer, federated social network built on Secure Scuttlebutt.",
+      i18n.welcomeStep1L2 || "There is no central server. Your messages are stored as an append-only feed on your machine and shared directly with peers you trust.",
+      i18n.welcomeStep1L3 || "Let me walk you through the basics in a few quick steps."
+    ],
+    actions: () => ([{ href: '/welcome?step=2', label: i18n.welcomeNext || 'Continue', primary: true }])
+  },
+  {
+    id: 'profile',
+    title: () => i18n.welcomeStep2Title || 'Your identity',
+    body: () => [
+      i18n.welcomeStep2L1 || "You are identified by your OasisID (an @-prefixed public key). Anyone with your OasisID can find your profile.",
+      i18n.welcomeStep2L2 || "Open /profile/edit to set your name, description, and avatar. Without it, others see only your raw ID."
+    ],
+    actions: () => ([
+      { href: '/profile/edit', label: i18n.welcomeStep2Action || 'Edit my profile', primary: true },
+      { href: '/welcome?step=3', label: i18n.welcomeNext || 'Continue' }
+    ])
+  },
+  {
+    id: 'visibility',
+    title: () => i18n.welcomeStep3Title || 'Visibility controls',
+    body: () => [
+      i18n.welcomeStep3L1 || "By default only your Karma score is shown on your profile. Activity Level, Device, UBI and ECOIN Wallet stay hidden until you opt in.",
+      i18n.welcomeStep3L2 || "Toggle each in /profile/edit — under 'Public profile visibility'."
+    ],
+    actions: () => ([
+      { href: '/profile/edit', label: i18n.welcomeStep3Action || 'Adjust visibility', primary: true },
+      { href: '/welcome?step=4', label: i18n.welcomeNext || 'Continue' }
+    ])
+  },
+  {
+    id: 'find',
+    title: () => i18n.welcomeStep4Title || 'Find people',
+    body: () => [
+      i18n.welcomeStep4L1 || "Browse /inhabitants to discover users your peers know about.",
+      i18n.welcomeStep4L2 || "Already have someone's OasisID? Go to /invites > Inhabitants and paste it to follow them directly.",
+      i18n.welcomeStep4L3 || "Want to connect to a specific peer by IP? Use /peers > Direct Connect."
+    ],
+    actions: () => ([
+      { href: '/inhabitants', label: i18n.welcomeStep4Action1 || 'Browse inhabitants', primary: true },
+      { href: '/invites', label: i18n.welcomeStep4Action2 || 'Follow by OasisID' },
+      { href: '/welcome?step=5', label: i18n.welcomeNext || 'Continue' }
+    ])
+  },
+  {
+    id: 'tribes',
+    title: () => i18n.welcomeStep5Title || 'Tribes',
+    body: () => [
+      i18n.welcomeStep5L1 || "Tribes are private groups with their own symmetric encryption layered over SSB. Outsiders cannot see members or content.",
+      i18n.welcomeStep5L2 || "Each tribe has its own feed for posts, events, votations, tasks, etc. Join with an invite code (32 hex) or create your own from /tribes."
+    ],
+    actions: () => ([
+      { href: '/tribes', label: i18n.welcomeStep5Action || 'Explore tribes', primary: true },
+      { href: '/welcome?step=6', label: i18n.welcomeNext || 'Continue' }
+    ])
+  },
+  {
+    id: 'modules',
+    title: () => i18n.welcomeStep6Title || 'Modules: ~40 ways to share',
+    body: () => [
+      i18n.welcomeStep6L1 || "Oasis includes audio, video, image, document, bookmark, torrent, forum, market, jobs, projects, events, tasks, calendars, maps, banking (UBI), parliament, courts, pixelia, AI navigation, and many more.",
+      i18n.welcomeStep6L2 || "Enable / disable each in /modules. The sidebar reflects what is on."
+    ],
+    actions: () => ([
+      { href: '/modules', label: i18n.welcomeStep6Action || 'Manage modules', primary: true },
+      { href: '/welcome?step=7', label: i18n.welcomeNext || 'Continue' }
+    ])
+  },
+  {
+    id: 'done',
+    title: () => i18n.welcomeStep7Title || 'You are set',
+    body: () => [
+      i18n.welcomeStep7L1 || "That's all for the tour. Everything you publish lives on your own feed — you keep the keys, you keep control.",
+      i18n.welcomeStep7L2 || "Remember: there is no client-side JavaScript, so everything is a plain form POST + page reload. It is slow on purpose: privacy and reproducibility over speed."
+    ],
+    actions: () => ([
+      { href: '/', label: i18n.welcomeStep7Action || 'Enter Oasis', primary: true }
+    ])
+  }
+];
+
+const renderBubble = (kind, content) => div(
+  { class: `welcome-bubble welcome-bubble-${kind}` },
+  ...content
+);
+
+exports.welcomeView = (stepIndex = 0) => {
+  const total = STEPS.length;
+  const idx = Math.max(0, Math.min(total - 1, parseInt(stepIndex, 10) || 0));
+  const step = STEPS[idx];
+  const title = i18n.welcomeTitle || 'Welcome to Oasis';
+
+  const aiHeader = div({ class: 'welcome-ai-header' },
+    span({ class: 'welcome-ai-avatar' }, '🌀'),
+    span({ class: 'welcome-ai-name' }, 'Oasis-42'),
+    span({ class: 'welcome-ai-step' }, `${idx + 1} / ${total}`)
+  );
+
+  const bodyLines = (typeof step.body === 'function' ? step.body() : []).filter(Boolean);
+
+  const aiBubble = renderBubble('ai',
+    [ h3({ class: 'welcome-bubble-title' }, step.title()) ]
+      .concat(bodyLines.map(line => p(line)))
+  );
+
+  const actions = (typeof step.actions === 'function' ? step.actions() : []);
+  const actionsBlock = actions.length
+    ? div({ class: 'welcome-actions' },
+        actions.map(act =>
+          a({ href: act.href, class: act.primary ? 'filter-btn welcome-action-primary' : 'filter-btn' }, act.label)
+        )
+      )
+    : null;
+
+  const progressDots = div({ class: 'welcome-progress' },
+    STEPS.map((s, i) =>
+      a({
+        href: `/welcome?step=${i + 1}`,
+        class: i === idx ? 'welcome-dot welcome-dot-active' : 'welcome-dot',
+        title: s.id
+      }, '●')
+    )
+  );
+
+  return template(
+    title,
+    section(
+      div({ class: 'tags-header welcome-header' },
+        h1(title),
+        p(i18n.welcomeIntro || 'A short, friendly tour of what Oasis is and how to make it yours.')
+      ),
+      div({ class: 'welcome-chat' },
+        aiHeader,
+        aiBubble,
+        actionsBlock
+      ),
+      progressDots,
+      div({ class: 'welcome-skip' },
+        a({ href: '/', class: 'welcome-skip-link' }, i18n.welcomeSkip || 'Skip the tour')
+      )
+    )
+  );
+};

+ 243 - 0
test/README.md

@@ -0,0 +1,243 @@
+# Oasis Tests
+
+Per-module unit/integration tests covering all publishing actions across the network.
+
+**Current status:** 40 modules / 149 tests passing.
+
+Module tests live under `test/mods/` to keep them grouped and the top-level
+`test/` directory clean (so `results/`, the runner, and the README are easy
+to find).
+
+## Quick start
+
+From the `oasis/` directory:
+
+```sh
+# Run everything (subprocess per module + safe ~/.ssb isolation):
+bash test/run.sh
+
+# Skip the prompt:
+bash test/run.sh --yes
+
+# Run all in a single Node process (no isolation):
+node test/run.js
+
+# Run a single module:
+node test/run.js mods/tribes
+node test/run.js mods/media/audios
+
+# Or use the per-module run.sh (no isolation, fast iteration):
+bash test/mods/tribes/run.sh
+bash test/mods/forum/run.sh
+bash test/mods/media/audios/run.sh
+
+# Run all + seed dummy content (so you can boot oasis after and inspect):
+bash test/run.sh --seed
+
+# Show stack traces on failure:
+STACK=1 node test/run.js
+```
+
+## ~/.ssb isolation
+
+`bash test/run.sh` (the aggregate runner) protects your real `~/.ssb`:
+
+1. Asks for confirmation before touching anything.
+2. Moves your current `~/.ssb` to `~/.ssb-bak-<timestamp>`.
+3. Creates a fresh empty `~/.ssb` for the tests.
+4. Runs all tests.
+5. **On exit, the test `~/.ssb` is KEPT** so you can boot oasis and visually inspect what the tests produced.
+6. Your original `~/.ssb` stays at the backup path for you to restore manually.
+
+After tests, the runner prints exactly how to restore:
+```
+Test ~/.ssb left in place for visual inspection.
+  test data:          /home/<you>/.ssb
+  your original:      /home/<you>/.ssb-bak-<ts>
+To boot oasis against the test data:  sh oasis.sh
+To restore your original later:       rm -rf /home/<you>/.ssb && mv /home/<you>/.ssb-bak-<ts> /home/<you>/.ssb
+```
+
+Flags:
+- `-y` / `--yes` — skip the confirmation prompt (CI use).
+- `--restore` — restore your original `~/.ssb` automatically on exit (destroys test data).
+- `--no-isolation` — run against the current `~/.ssb` (DANGEROUS, may LOCK-conflict).
+- `clean-all` — delete every report in `test/results/`, restore your real `~/.ssb` from the latest backup, and remove all stale backups. Useful when you want to wipe traces of testing entirely.
+- `-h` / `--help` — show usage.
+
+If oasis is currently running, **STOP IT FIRST** (the LOCK on `~/.ssb` will conflict).
+
+Examples:
+```sh
+bash test/run.sh                  # run, prompt, keep test ~/.ssb for inspection
+bash test/run.sh --yes            # skip prompt
+bash test/run.sh --yes --restore  # CI-friendly: run + auto-restore original
+bash test/run.sh clean-all        # wipe reports + restore original ~/.ssb
+bash test/run.sh clean-all --yes  # wipe without prompting
+```
+
+## Layout
+
+```
+test/
+  run.sh                       Aggregate runner (subprocess per module + ~/.ssb isolation)
+  run.js                       Single-process Node test runner
+  README.md                    This file
+  results/                     Generated reports (unit_test_<timestamp>.md)
+  helpers/
+    assert.js                  eq, ok, notOk, deepEq, throwsAsync, arrEq
+    mock-ssb.js                In-memory SSB network (multi-peer + box1 + private msgs)
+    setup.js                   makePeer / makeNetwork helpers per module
+
+  crypto/         primitives.test.js   Keyring, fingerprint, wrap/unwrap, AAD, invites, AES-GCM
+  tribes/         basic.test.js        Create, list, invite, join, content
+  sub-tribes/     basic.test.js        Hierarchy, invite scoping, cycles, tombstone cascade
+                  content.test.js      Publishing inside sub-tribes (feed, event, votation)
+                                       and parent vs sub key isolation
+  media/
+    audios/       audios.test.js       createAudio, opinion, list, delete
+    videos/       videos.test.js       createVideo, opinion, delete
+    images/       images.test.js       createImage (meme + non-meme), opinion
+    documents/    documents.test.js    createDocument, opinion
+    bookmarks/    bookmarks.test.js    createBookmark, opinion
+
+  forum/          forum.test.js        createForum, addMessageToForum, voteContent
+  transfers/      transfers.test.js    createTransfer, confirmTransferById, opinion
+  votes/          votes.test.js        createVote, voteOnVote, opinion
+  events/         events.test.js       createEvent, toggleAttendee (multi-user), delete
+  tasks/          tasks.test.js        createTask, toggleAssignee, updateTaskStatus
+  chats/          chats.test.js        createChat (standalone), close, delete
+  pads/           pads.test.js         createPad, close, delete
+  maps/           maps.test.js         createMap (SINGLE), delete
+  torrents/       torrents.test.js     createTorrent, opinion, delete
+  calendars/      calendars.test.js    createCalendar, addDate, listAll
+  reports/        reports.test.js      createReport, confirmReportById (multi-user), delete
+  market/         market.test.js       createItem (exchange/auction), addBidToAuction
+  jobs/           jobs.test.js         createJob, subscribeToJob, deleteJob
+  projects/       projects.test.js     createProject, followProject, pledgeToProject
+  inhabitants/    inhabitants.test.js  listInhabitants
+  parliament/     parliament.test.js   proposeCandidature, createProposal
+  courts/         courts.test.js       openCase, nominateJudge
+  opinions/       opinions.test.js     createVote (opinion), listOpinions
+  shops/          shops.test.js        createShop, createProduct, update, delete
+  pixelia/        pixelia.test.js      paintPixel, repaint (replace), cross-peer visibility
+  pm/             pm.test.js           sendMessage, listAllPrivate (private box1)
+  feed/           feed.test.js         createFeed, createRefeed, addComment, opinion
+  tags/           tags.test.js         listTags (aggregate)
+  search/         search.test.js      search() across modules
+  trending/       trending.test.js     listTrending, createVote (opinion)
+  agenda/         agenda.test.js       listAgenda
+  cv/             cv.test.js           createCV
+  favorites/      favorites.test.js    listAll
+  banking/        banking.test.js      addAddress, getUserAddress, hasClaimedThisMonth,
+                                       getUbiClaimHistory, listBanking, getBankingData,
+                                       isPubNode, DEFAULT_RULES, listAddressesMerged
+  activity/       activity.test.js     listFeed (member vs non-member visibility)
+  stats/          stats.test.js        getStats (member sees own tribes, non-member doesn't)
+  blockchain/     blockchain.test.js   listBlockchain (member decrypts tribe content)
+```
+
+Each module directory has its own `run.sh`:
+```sh
+bash test/<module>/run.sh
+```
+
+## Test pattern
+
+```js
+const { eq, ok, notOk, deepEq, throwsAsync } = require('../helpers/assert');
+const { makeNetwork, makePeer } = require('../helpers/setup');
+
+describe('<module>: <flow>', (t) => {
+  t('A does X', async () => {
+    const net = makeNetwork();
+    const A = makePeer(net);
+    A.setActor();
+    const r = await A.use('<modelName>').<method>(...args);
+    ok(r);
+  });
+});
+```
+
+For multi-peer scenarios:
+
+```js
+const A = makePeer(net); const B = makePeer(net);
+A.setActor();
+const r = await A.use('tribes').createTribe(...);
+B.setActor();   // switch identity
+await B.use('tribes').joinByInvite(code);
+```
+
+`A.use(modelName)` resolves the factory from `FACTORIES` in `helpers/setup.js` and instantiates with shared deps. Models are cached per peer.
+
+## Mock SSB
+
+`helpers/mock-ssb.js`:
+- `makeNetwork()` — shared in-memory log (simulates SSB replication).
+- `makeNode(network, keypair)` — peer with `publish`, `createLogStream` (live + old), `createUserStream`, `get`, `private.unbox/publish` (real `ssb-keys.box`/`unbox`), `links`, `messagesByType`, `whoami`, `blobs.has`, `replicate.upto`, `conn.hub`.
+- `makeCooler(node)` — wraps node into the cooler `{open: async () => node}` interface.
+- `generateKeypair()` — real ed25519 via `ssb-keys`.
+
+When `content.recps` is set, `ssb-keys.box(content, recps)` is invoked and the message is published as a `.box` string. `private.unbox` decrypts using the receiver's keypair.
+
+## Generated report
+
+Every `bash test/run.sh` generates `test/results/unit_test_<YYYY-MM-DD_HH-MM-SS>.md` with:
+1. **Summary** — tests passed / total, modules passed / total.
+2. **✅ Passing modules** — every module with timing and individual test names.
+3. **❌ Failing modules** (only if any) — full output including stack traces.
+
+## Adding a new module
+
+1. Create `test/<module>/<name>.test.js` following the pattern.
+2. If the model isn't registered, add it to `FACTORIES` in `helpers/setup.js`. If it has unusual deps (services, cipher, etc.), add a branch in `requireOnce`.
+3. Create `test/<module>/run.sh`:
+   ```bash
+   #!/usr/bin/env bash
+   export NODE_NO_WARNINGS=1
+   cd "$(dirname "$0")/../.."
+   node test/run.js <module> "$@"
+   ```
+4. Add `<module>` to the `MODULES` array in `test/run.sh`.
+5. `chmod +x test/<module>/run.sh && bash test/<module>/run.sh`.
+
+## What's covered
+
+- All major content publish actions: `createX`, `updateX`, `deleteX`
+- Voting / opinion casting / attending / assigning
+- Multi-user flows (A creates → B interacts)
+- Privacy / opacity (member vs non-member visibility)
+- Tribe cryptography (wrap/unwrap, AAD, invites, sub-tribes)
+- Sub-tribe content publishing + parent/sub key isolation
+- Banking address management + epoch / claim history (no RPC parts)
+
+## Out of scope
+
+These models are deliberately not tested as unit tests:
+
+- **`legacy`** — broken crypto (audit found); disable in production.
+- **`panicmode`** / **`exportmode`** — destructive operations.
+- **`wallet`** — requires external `localhost:7474` RPC; tested via `banking` mock.
+- **`logs`** / **`cipher`** — internal utilities, no publish actions.
+- **`games`** — each minigame independent, gameplay-specific.
+- **`tribes_content`** — covered by `tribes` and `sub-tribes` test suites.
+
+## Bugs caught by these tests
+
+During development, these tests caught real bugs that have been fixed:
+
+1. `events_model.js` captured `userId` at module load (broke multi-user tests). Now reads per-call.
+2. `stats_model.getFolderSize` had no try/catch (crashed on clean `~/.ssb`). Wrapped.
+3. `activity_model.isAllowedTribeActivity` excluded `isAnonymous=true` even for members. Now allows when `_decrypted=true`.
+4. `blockchain_model` eagerly required `SSB_server.js` (started ssb-server on every test). Switched to `ssb_config`.
+5. Public tribes wrapped same as private (invisible to non-members). Fixed with dual format (plaintext public, wrapped private).
+6. `joinByInvite` returned tip ID instead of root ID. Fixed.
+7. Invite tombstone only respected if authored by invite-author (couldn't be invalidated by joiner). Now any author's tombstone invalidates.
+8. `backend.js` reference-before-init on `blockchainModelInit` (regression from refactor). Fixed.
+
+The recommended workflow for any future model change is: write a test that reproduces the issue first, fix the model until it passes, keep both. The test becomes a regression net.
+
+## CI
+
+Add a job that runs `bash test/run.sh --yes --restore` from the `oasis/` directory on Linux + Node ≥20. The `--restore` flag is appropriate for CI (no need to inspect test data visually).

+ 43 - 0
test/helpers/assert.js

@@ -0,0 +1,43 @@
+function eq(actual, expected, msg) {
+  if (actual !== expected) {
+    throw new Error(`${msg || 'eq'}: expected ${JSON.stringify(expected)} got ${JSON.stringify(actual)}`);
+  }
+}
+
+function ok(value, msg) {
+  if (!value) throw new Error(`${msg || 'ok'}: expected truthy, got ${JSON.stringify(value)}`);
+}
+
+function notOk(value, msg) {
+  if (value) throw new Error(`${msg || 'notOk'}: expected falsy, got ${JSON.stringify(value)}`);
+}
+
+function deepEq(actual, expected, msg) {
+  const a = JSON.stringify(actual);
+  const b = JSON.stringify(expected);
+  if (a !== b) throw new Error(`${msg || 'deepEq'}: expected ${b} got ${a}`);
+}
+
+function arrEq(actual, expected, msg) {
+  if (!Array.isArray(actual) || !Array.isArray(expected)) throw new Error(`${msg || 'arrEq'}: not arrays`);
+  if (actual.length !== expected.length) throw new Error(`${msg || 'arrEq'}: length ${actual.length} !== ${expected.length}`);
+  for (let i = 0; i < expected.length; i++) {
+    if (actual[i] !== expected[i]) throw new Error(`${msg || 'arrEq'}: at ${i} expected ${expected[i]} got ${actual[i]}`);
+  }
+}
+
+async function throwsAsync(fn, msgMatch) {
+  try {
+    await fn();
+  } catch (e) {
+    if (msgMatch instanceof RegExp) {
+      if (!msgMatch.test(e.message)) throw new Error(`throwsAsync: expected ${msgMatch} to match, got "${e.message}"`);
+    } else if (msgMatch && !String(e.message).includes(msgMatch)) {
+      throw new Error(`throwsAsync: expected "${msgMatch}" in error, got "${e.message}"`);
+    }
+    return e;
+  }
+  throw new Error(`throwsAsync: expected throw${msgMatch ? ' with ' + msgMatch : ''}, none thrown`);
+}
+
+module.exports = { eq, ok, notOk, deepEq, arrEq, throwsAsync };

+ 167 - 0
test/helpers/mock-ssb.js

@@ -0,0 +1,167 @@
+const crypto = require('crypto');
+const pull = require('../../src/server/node_modules/pull-stream');
+const ssbKeys = require('../../src/server/node_modules/ssb-keys');
+
+let pullPushable;
+try {
+  pullPushable = require('../../src/server/node_modules/pull-pushable');
+} catch (_) {
+  try { pullPushable = require('../../src/server/node_modules/@krakenslab/pull-pushable'); } catch (__) {}
+}
+
+const generateMsgKey = () => '%' + crypto.randomBytes(32).toString('base64').replace(/=+$/, '') + '.sha256';
+
+function makeNetwork() {
+  const log = [];
+  const liveListeners = new Set();
+  return {
+    log,
+    publish(msg) {
+      log.push(msg);
+      for (const cb of liveListeners) {
+        try { cb(msg); } catch (_) {}
+      }
+    },
+    listen(cb) {
+      liveListeners.add(cb);
+      return () => liveListeners.delete(cb);
+    },
+    reset() {
+      log.length = 0;
+      liveListeners.clear();
+    }
+  };
+}
+
+function makeNode(network, keypair, opts = {}) {
+  const seqByAuthor = new Map();
+  const node = {
+    id: keypair.id,
+    keys: keypair,
+    publish(content, cb) {
+      let actualContent = content;
+      if (content && typeof content === 'object' && Array.isArray(content.recps) && content.recps.length) {
+        try {
+          actualContent = ssbKeys.box(content, content.recps);
+        } catch (e) {
+          if (cb) cb(e);
+          return;
+        }
+      }
+      const key = generateMsgKey();
+      const prev = seqByAuthor.get(keypair.id) || 0;
+      const sequence = prev + 1;
+      seqByAuthor.set(keypair.id, sequence);
+      const ts = Date.now();
+      const msg = {
+        key,
+        value: {
+          previous: null,
+          sequence,
+          author: keypair.id,
+          timestamp: ts,
+          hash: 'sha256',
+          content: actualContent,
+          signature: 'mock-sig'
+        },
+        timestamp: ts
+      };
+      network.publish(msg);
+      if (cb) cb(null, { key, value: msg.value });
+    },
+    createLogStream(opt = {}) {
+      const { limit, reverse, live, old } = opt;
+      const items = network.log.slice();
+      const baseItems = old !== false ? items : [];
+      let prepared = baseItems;
+      if (reverse) prepared = prepared.slice().reverse();
+      if (limit) prepared = prepared.slice(0, limit);
+      if (!live) return pull.values(prepared);
+      if (!pullPushable) {
+        const initial = pull.values(prepared);
+        return initial;
+      }
+      const p = pullPushable();
+      for (const m of prepared) p.push(m);
+      const off = network.listen(m => p.push(m));
+      const origAbort = p.end;
+      p.end = (err) => { off(); if (origAbort) origAbort.call(p, err); };
+      return p;
+    },
+    createUserStream(opt = {}) {
+      const { id, reverse, limit } = opt;
+      let items = network.log.filter(m => m.value && m.value.author === id);
+      if (reverse) items = items.slice().reverse();
+      if (limit) items = items.slice(0, limit);
+      return pull.values(items);
+    },
+    get(key, cb) {
+      const m = network.log.find(x => x.key === key);
+      if (!m) return cb(new Error('not found'));
+      cb(null, m.value);
+    },
+    private: {
+      publish(content, recps, cb) {
+        let actualContent;
+        try {
+          actualContent = ssbKeys.box(content, recps);
+        } catch (e) { if (cb) cb(e); return; }
+        const key = generateMsgKey();
+        const prev = seqByAuthor.get(keypair.id) || 0;
+        const sequence = prev + 1;
+        seqByAuthor.set(keypair.id, sequence);
+        const ts = Date.now();
+        const msg = { key, value: { previous: null, sequence, author: keypair.id, timestamp: ts, hash: 'sha256', content: actualContent, signature: 'mock-sig' }, timestamp: ts };
+        network.publish(msg);
+        if (cb) cb(null, { key, value: msg.value });
+      },
+      unbox(arg) {
+        const c = arg && arg.value ? arg.value.content : arg;
+        if (typeof c !== 'string' || !c.endsWith('.box')) return null;
+        try {
+          const decoded = ssbKeys.unbox(c, keypair);
+          if (!decoded) return null;
+          if (arg && arg.value) {
+            return { key: arg.key, value: { ...arg.value, content: decoded }, timestamp: arg.timestamp };
+          }
+          return decoded;
+        } catch (_) { return null; }
+      }
+    },
+    blobs: { has(_url, cb) { cb(null, true); } },
+    conn: { hub() { return { listen: () => null }; } },
+    replicate: { upto(cb) { if (cb) cb(null, {}); } },
+    whoami(cb) { cb(null, { id: keypair.id }); },
+    links(opts = {}) {
+      const out = [];
+      for (const m of network.log) {
+        const c = m.value && m.value.content;
+        if (!c) continue;
+        if (opts.dest && c.target !== opts.dest && c.root !== opts.dest && (!c.branch || (Array.isArray(c.branch) ? !c.branch.includes(opts.dest) : c.branch !== opts.dest))) continue;
+        if (opts.rel === 'target' && c.target !== opts.dest) continue;
+        if (opts.values) out.push(m);
+        else out.push({ source: m.value.author, dest: opts.dest, key: m.key });
+      }
+      return pull.values(out);
+    },
+    messagesByType(opts = {}) {
+      const wantedType = typeof opts === 'string' ? opts : opts.type;
+      const items = network.log.filter(m => {
+        const c = m.value && m.value.content;
+        return c && typeof c === 'object' && c.type === wantedType;
+      });
+      return pull.values(items);
+    }
+  };
+  return node;
+}
+
+function makeCooler(node) {
+  return { open: async () => node };
+}
+
+function generateKeypair() {
+  return ssbKeys.generate();
+}
+
+module.exports = { makeNetwork, makeNode, makeCooler, generateKeypair };

+ 156 - 0
test/helpers/setup.js

@@ -0,0 +1,156 @@
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { makeNetwork, makeNode, makeCooler, generateKeypair } = require('./mock-ssb');
+
+const tmpRoot = path.join(os.tmpdir(), 'oasis-tests-' + process.pid);
+let counter = 0;
+const fresh = () => {
+  counter++;
+  const dir = path.join(tmpRoot, 'd-' + counter + '-' + Math.random().toString(36).slice(2));
+  fs.mkdirSync(dir, { recursive: true });
+  return dir;
+};
+
+const tribeCryptoFactory = require('../../src/models/crypto');
+const realConfig = require('../../src/server/ssb_config');
+
+const FACTORIES = {
+  tribes: '../../src/models/tribes_model',
+  tribesContent: '../../src/models/tribes_content_model',
+  audios: '../../src/models/audios_model',
+  videos: '../../src/models/videos_model',
+  images: '../../src/models/images_model',
+  documents: '../../src/models/documents_model',
+  bookmarks: '../../src/models/bookmarking_model',
+  forum: '../../src/models/forum_model',
+  transfers: '../../src/models/transfers_model',
+  votes: '../../src/models/votes_model',
+  events: '../../src/models/events_model',
+  tasks: '../../src/models/tasks_model',
+  chats: '../../src/models/chats_model',
+  pads: '../../src/models/pads_model',
+  maps: '../../src/models/maps_model',
+  torrents: '../../src/models/torrents_model',
+  calendars: '../../src/models/calendars_model',
+  reports: '../../src/models/reports_model',
+  market: '../../src/models/market_model',
+  jobs: '../../src/models/jobs_model',
+  projects: '../../src/models/projects_model',
+  opinions: '../../src/models/opinions_model',
+  inhabitants: '../../src/models/inhabitants_model',
+  parliament: '../../src/models/parliament_model',
+  courts: '../../src/models/courts_model',
+  shops: '../../src/models/shops_model',
+  pixelia: '../../src/models/pixelia_model',
+  pm: '../../src/models/pm_model',
+  feed: '../../src/models/feed_model',
+  tags: '../../src/models/tags_model',
+  search: '../../src/models/search_model',
+  trending: '../../src/models/trending_model',
+  agenda: '../../src/models/agenda_model',
+  cv: '../../src/models/cv_model',
+  favorites: '../../src/models/favorites_model',
+  banking: '../../src/models/banking_model',
+  activity: '../../src/models/activity_model',
+  stats: '../../src/models/stats_model',
+  blockchain: '../../src/models/blockchain_model'
+};
+
+function loadFactory(name) {
+  const p = FACTORIES[name];
+  if (!p) throw new Error('Unknown factory: ' + name);
+  return require(p);
+}
+
+/**
+ * Create a fresh peer with their own SSB log presence and tribe keyring.
+ * @param {object} network - shared network (use makeNetwork())
+ * @param {object} [keypair] - optional keypair (will be generated if missing)
+ * @returns {object} { keypair, node, cooler, tribeCrypto, models, configDir, setActor }
+ */
+function makePeer(network, keypair) {
+  const kp = keypair || generateKeypair();
+  const node = makeNode(network, kp);
+  const cooler = makeCooler(node);
+  const configDir = fresh();
+  const tribeCrypto = tribeCryptoFactory(configDir, 'tribes');
+  const chatCrypto = tribeCryptoFactory(configDir, 'chats');
+  const padCrypto = tribeCryptoFactory(configDir, 'pads');
+  const mapCrypto = tribeCryptoFactory(configDir, 'maps');
+  const calendarCrypto = tribeCryptoFactory(configDir, 'calendars');
+  const baseDeps = { cooler, isPublic: false, tribeCrypto };
+  const models = {};
+  const requireOnce = (name) => {
+    if (models[name]) return models[name];
+    const f = loadFactory(name);
+    let deps;
+    if (name === 'tribesContent' || name === 'torrents') {
+      deps = { ...baseDeps, tribesModel: requireOnce('tribes') };
+    } else if (name === 'chats') {
+      deps = { ...baseDeps, chatCrypto, tribesModel: requireOnce('tribes') };
+    } else if (name === 'pads') {
+      deps = { ...baseDeps, padCrypto, tribesModel: requireOnce('tribes') };
+    } else if (name === 'maps') {
+      deps = { ...baseDeps, mapCrypto, tribesModel: requireOnce('tribes') };
+    } else if (name === 'calendars') {
+      deps = { ...baseDeps, calendarCrypto, tribesModel: requireOnce('tribes') };
+    } else if (name === 'activity' || name === 'stats' || name === 'blockchain') {
+      deps = { ...baseDeps, tribesModel: requireOnce('tribes') };
+    } else if (name === 'search') {
+      deps = { ...baseDeps, padsModel: requireOnce('pads'), tribesModel: requireOnce('tribes') };
+    } else if (name === 'tags') {
+      deps = { ...baseDeps, padsModel: requireOnce('pads'), tribesModel: requireOnce('tribes') };
+    } else if (name === 'favorites') {
+      deps = {
+        audiosModel: requireOnce('audios'),
+        bookmarksModel: requireOnce('bookmarks'),
+        documentsModel: requireOnce('documents'),
+        imagesModel: requireOnce('images'),
+        videosModel: requireOnce('videos'),
+        mapsModel: requireOnce('maps'),
+        padsModel: requireOnce('pads'),
+        chatsModel: requireOnce('chats'),
+        calendarsModel: requireOnce('calendars'),
+        torrentsModel: requireOnce('torrents')
+      };
+    } else if (name === 'banking') {
+      deps = { services: { cooler, feed: { listAll: async () => [] }, activity: { list: async () => [] } } };
+    } else if (name === 'parliament' || name === 'courts') {
+      const svc = {
+        tribes: requireOnce('tribes'),
+        votes: requireOnce('votes'),
+        inhabitants: { listInhabitants: async () => [], getLastKarmaScore: async () => 0 },
+        banking: { getBankingData: async () => ({ karmaScore: 0 }) }
+      };
+      deps = { ...baseDeps, services: svc };
+    } else if (name === 'reports') {
+      deps = baseDeps;
+    } else if (name === 'forum') {
+      deps = { cooler, isPublic: false };
+    } else {
+      deps = baseDeps;
+    }
+    if (name === 'pads') deps.cipherModel = { encryptECB: x => x, decryptECB: x => x };
+    models[name] = f(deps);
+    return models[name];
+  };
+  return {
+    keypair: kp,
+    node,
+    cooler,
+    tribeCrypto,
+    configDir,
+    use(name) { return requireOnce(name); },
+    setActor() { realConfig.keys = kp; }
+  };
+}
+
+function makeNetworkAndPeer() {
+  const network = makeNetwork();
+  const peer = makePeer(network);
+  peer.setActor();
+  return { network, peer };
+}
+
+module.exports = { makePeer, makeNetworkAndPeer, makeNetwork, generateKeypair, fresh, realConfig };

+ 31 - 0
test/mods/activity/activity.test.js

@@ -0,0 +1,31 @@
+const { eq, ok } = require('../../helpers/assert');
+const { makeNetwork, makePeer } = require('../../helpers/setup');
+
+describe('activity: feed', (t) => {
+  t('A creates a public tribe → A sees it in activity', async () => {
+    const net = makeNetwork(); const A = makePeer(net); A.setActor();
+    await A.use('tribes').createTribe('Pub', '', null, '', [], false, false, 'strict', null, 'OPEN', '');
+    const feed = await A.use('activity').listFeed('all');
+    ok(Array.isArray(feed));
+    const tribe = feed.find(a => a.type === 'tribe');
+    ok(tribe);
+  });
+
+  t('A creates a private tribe → A (member) sees its create in activity', async () => {
+    const net = makeNetwork(); const A = makePeer(net); A.setActor();
+    await A.use('tribes').createTribe('Hidden', '', null, '', [], false, true, 'strict', null, 'OPEN', '');
+    const feed = await A.use('activity').listFeed('all');
+    const tribe = feed.find(a => a.type === 'tribe');
+    ok(tribe, 'A as member sees own private tribe creation');
+  });
+
+  t('B (non-member) does NOT see private tribe creation in activity', async () => {
+    const net = makeNetwork(); const A = makePeer(net); const B = makePeer(net);
+    A.setActor();
+    await A.use('tribes').createTribe('Secret', '', null, '', [], false, true, 'strict', null, 'OPEN', '');
+    B.setActor();
+    const feed = await B.use('activity').listFeed('all');
+    const tribe = feed.find(a => a.type === 'tribe');
+    ok(!tribe, 'B sees no private tribe activity');
+  });
+});

+ 4 - 0
test/mods/activity/run.sh

@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+export NODE_NO_WARNINGS=1
+cd "$(dirname "$0")/../../.."
+node test/run.js mods/activity "$@"

+ 0 - 0
test/mods/agenda/agenda.test.js


Неке датотеке нису приказане због велике количине промена