Bladeren bron

Oasis release 0.7.3

psy 5 dagen geleden
bovenliggende
commit
557ec86821
37 gewijzigde bestanden met toevoegingen van 1746 en 466 verwijderingen
  1. 16 0
      docs/CHANGELOG.md
  2. 432 49
      src/backend/backend.js
  3. 1 1
      src/client/assets/styles/style.css
  4. 40 3
      src/client/assets/translations/oasis_ar.js
  5. 40 3
      src/client/assets/translations/oasis_de.js
  6. 40 3
      src/client/assets/translations/oasis_en.js
  7. 40 3
      src/client/assets/translations/oasis_es.js
  8. 40 3
      src/client/assets/translations/oasis_eu.js
  9. 40 3
      src/client/assets/translations/oasis_fr.js
  10. 40 3
      src/client/assets/translations/oasis_hi.js
  11. 40 3
      src/client/assets/translations/oasis_it.js
  12. 40 3
      src/client/assets/translations/oasis_pt.js
  13. 40 3
      src/client/assets/translations/oasis_ru.js
  14. 40 3
      src/client/assets/translations/oasis_zh.js
  15. 1 31
      src/configs/banking-epochs.json
  16. 7 5
      src/configs/config-manager.js
  17. 1 0
      src/configs/follow_state.json
  18. 1 1
      src/configs/media-favorites.json
  19. 4 5
      src/configs/oasis-config.json
  20. 14 147
      src/models/activity_model.js
  21. 87 74
      src/models/banking_model.js
  22. 44 27
      src/models/calendars_model.js
  23. 116 1
      src/models/parliament_model.js
  24. 6 2
      src/models/pm_model.js
  25. 72 2
      src/models/transfers_model.js
  26. 64 0
      src/models/tribes_model.js
  27. 130 0
      src/models/viewer_filters.js
  28. 1 1
      src/server/package-lock.json
  29. 1 1
      src/server/package.json
  30. 41 56
      src/views/activity_view.js
  31. 24 12
      src/views/banking_views.js
  32. 7 1
      src/views/blockchain_view.js
  33. 17 0
      src/views/main_views.js
  34. 5 1
      src/views/pm_view.js
  35. 32 5
      src/views/settings_view.js
  36. 6 2
      src/views/transfer_view.js
  37. 176 9
      src/views/tribes_view.js

+ 16 - 0
docs/CHANGELOG.md

@@ -13,6 +13,22 @@ All notable changes to this project will be documented in this file.
 ### Security
 -->
 
+## v0.7.3 - 2026-04-20
+
+### Added
+
+- Wish settings: determines how to access the whole content and your level of experiences into the network (Core plugin).
+- PM settings: configure the level of exposition to private messages in the network (Core plugin).
+
+### Changed
+
+- More layers of privacy/encryption applied to sensitive content at different places (Core plugin).
+
+### Fixed
+
+- Pads ACL (Pads plugin).
+- PUB UBI claim (Banking plugin).
+
 ## v0.7.2 - 2026-04-16
 
 ### Added

+ 432 - 49
src/backend/backend.js

@@ -291,8 +291,167 @@ const projectsModel = require("../models/projects_model")({ cooler, isPublic: co
 const mapsModel = require("../models/maps_model")({ cooler, isPublic: config.public });
 const gamesModel = require('../models/games_model')({ cooler });
 const bankingModel = require("../models/banking_model")({ services: { cooler }, isPublic: config.public });
+let pubBalanceTimer = null;
+if (bankingModel.isPubNode()) {
+  const tick = () => { bankingModel.publishPubBalance().catch(() => {}); };
+  setTimeout(tick, 30 * 1000);
+  pubBalanceTimer = setInterval(tick, 24 * 60 * 60 * 1000);
+}
 const favoritesModel = require("../models/favorites_model")({ services: { cooler }, audiosModel, bookmarksModel, documentsModel, imagesModel, videosModel, mapsModel, padsModel, chatsModel, calendarsModel, torrentsModel });
 const parliamentModel = require('../models/parliament_model')({ cooler, services: { tribes: tribesModel, votes: votesModel, inhabitants: inhabitantsModel, banking: bankingModel } });
+const { renderGovernance: renderTribeGovernance } = require('../views/tribes_view');
+const viewerFilters = require('../models/viewer_filters');
+
+const scanPendingFollows = async (viewerId) => {
+  if (!viewerId) return;
+  if (!viewerFilters.isFrictionActive()) return;
+  const pullStream = require('../server/node_modules/pull-stream');
+  const ssbClient = await cooler.open();
+  const limit = getConfig().ssbLogStream?.limit || 1000;
+  const rows = await new Promise((res, rej) => {
+    pullStream(
+      ssbClient.createLogStream({ reverse: true, limit }),
+      pullStream.collect((err, arr) => err ? rej(err) : res(arr || []))
+    );
+  });
+  const accepted = new Set(viewerFilters.loadAccepted());
+  const pendingIds = new Set(viewerFilters.listPending().map(x => x.followerId));
+  for (const msg of rows) {
+    const c = msg.value?.content;
+    if (!c || c.type !== 'contact') continue;
+    if (c.contact !== viewerId) continue;
+    if (c.following !== true) continue;
+    const author = msg.value?.author;
+    if (!author || author === viewerId) continue;
+    if (accepted.has(author)) continue;
+    if (pendingIds.has(author)) continue;
+    viewerFilters.enqueuePending(author);
+    pendingIds.add(author);
+  }
+};
+
+const { section: hSection } = require('../server/node_modules/hyperaxe');
+
+const renderPendingFollows = (items) => {
+  const { template: tpl, i18n: i18nLocal } = require('../views/main_views');
+  const { div, h2, p, form, button, input, ul, li, span, a } = require('../server/node_modules/hyperaxe');
+  return tpl(
+    i18nLocal.inhabitantsPendingFollowsTitle || 'Pending follow requests',
+    hSection(
+      div({ class: 'tags-header' },
+        h2(i18nLocal.inhabitantsPendingFollowsTitle || 'Pending follow requests'),
+        p(i18nLocal.pmMutualNotice || '')
+      ),
+      (!Array.isArray(items) || items.length === 0)
+        ? p('—')
+        : ul({}, items.map(it =>
+            li({},
+              span({ style: 'font-weight:bold' }, it.name || it.followerId),
+              ' — ',
+              span({ class: 'muted' }, it.followerId.slice(0, 14) + '…'),
+              ' ',
+              form({ method: 'POST', action: '/inhabitants/follow/accept', style: 'display:inline' },
+                input({ type: 'hidden', name: 'followerId', value: it.followerId }),
+                button({ type: 'submit', class: 'filter-btn' }, i18nLocal.inhabitantsPendingAccept || 'Accept')
+              ),
+              ' ',
+              form({ method: 'POST', action: '/inhabitants/follow/reject', style: 'display:inline' },
+                input({ type: 'hidden', name: 'followerId', value: it.followerId }),
+                button({ type: 'submit', class: 'filter-btn' }, i18nLocal.inhabitantsPendingReject || 'Reject')
+              )
+            )
+          ))
+    )
+  );
+};
+
+const makeCtxMutualCache = () => {
+  const cache = new Map();
+  const frictionActive = viewerFilters.isFrictionActive();
+  return async (otherId) => {
+    if (!otherId) return false;
+    if (cache.has(otherId)) return cache.get(otherId);
+    let rel;
+    try { rel = await friend.getRelationship(otherId); } catch (e) { rel = null; }
+    const basic = !!(rel && rel.following && rel.followsMe);
+    const mutual = frictionActive ? (basic && viewerFilters.isAccepted(otherId)) : basic;
+    cache.set(otherId, mutual);
+    return mutual;
+  };
+};
+
+const extractItemAuthor = (item) => {
+  if (!item) return null;
+  if (typeof item === 'string') return null;
+  if (item.value && item.value.author) return item.value.author;
+  if (item.author) return item.author;
+  if (item.feed) return item.feed;
+  if (item.organizer) return item.organizer;
+  if (item.proposer) return item.proposer;
+  if (item.owner) return item.owner;
+  if (item.id && typeof item.id === 'string' && item.id.startsWith('@')) return item.id;
+  return null;
+};
+
+const extractItemTribeId = (item) => {
+  if (!item || typeof item !== 'object') return null;
+  if (item.tribeId) return item.tribeId;
+  if (item.value && item.value.content && item.value.content.tribeId) return item.value.content.tribeId;
+  if (item.content && item.content.tribeId) return item.content.tribeId;
+  return null;
+};
+
+const getViewerTribeAccessSets = async (userId) => {
+  if (!userId) return { memberOf: new Set(), createdBy: new Set(), privateNotAccessible: new Set() };
+  try {
+    const all = await tribesModel.listAll();
+    const memberOf = new Set();
+    const createdBy = new Set();
+    const privateNotAccessible = new Set();
+    for (const t of all) {
+      const isMember = Array.isArray(t.members) && t.members.includes(userId);
+      const isCreator = t.author === userId;
+      if (isCreator) { createdBy.add(t.id); memberOf.add(t.id); }
+      else if (isMember) memberOf.add(t.id);
+      const ancestryPrivate = await (async () => {
+        try { const eff = await tribesModel.getEffectiveStatus(t.id); return eff.isPrivate; } catch (e) { return !!t.isAnonymous; }
+      })();
+      if (ancestryPrivate && !isMember && !isCreator) privateNotAccessible.add(t.id);
+    }
+    return { memberOf, createdBy, privateNotAccessible };
+  } catch (e) {
+    return { memberOf: new Set(), createdBy: new Set(), privateNotAccessible: new Set() };
+  }
+};
+
+const applyListFilters = async (items, ctx, opts = {}) => {
+  if (!Array.isArray(items)) return items;
+  const cfg = getConfig();
+  const viewer = getViewerId();
+  const wishMutuals = cfg.wish === 'mutuals';
+  let out = items;
+  if (!opts.skipTribeAccess) {
+    const { memberOf, createdBy, privateNotAccessible } = await getViewerTribeAccessSets(viewer);
+    out = out.filter(it => {
+      const tid = extractItemTribeId(it);
+      if (!tid) return true;
+      if (memberOf.has(tid) || createdBy.has(tid)) return true;
+      if (privateNotAccessible.has(tid)) return false;
+      return true;
+    });
+  }
+  if (wishMutuals && !opts.skipMutual) {
+    const isMutual = makeCtxMutualCache();
+    const filtered = [];
+    for (const it of out) {
+      const a = extractItemAuthor(it);
+      if (!a || a === viewer) { filtered.push(it); continue; }
+      if (await isMutual(a)) filtered.push(it);
+    }
+    out = filtered;
+  }
+  return out;
+};
 const courtsModel = require('../models/courts_model')({ cooler, services: { votes: votesModel, inhabitants: inhabitantsModel, tribes: tribesModel, banking: bankingModel }, tribeCrypto });
 tribesModel.processIncomingKeys().catch(err => {
   if (config.debug) console.error('tribe-keys scan error:', err.message);
@@ -656,7 +815,7 @@ const resolveCommentComponents = async function (ctx) {
   }
   return { messages, myFeedId, parentMessage, contentWarning };
 };
-const { authorView, previewCommentView, commentView, editProfileView, extendedView, latestView, likesView, threadView, hashtagView, mentionsView, popularView, previewView, privateView, publishCustomView, publishView, previewSubtopicView, subtopicView, imageSearchView, setLanguage, topicsView, summaryView, threadsView, tribeAccessDeniedView } = require("../views/main_views");
+const { authorView, previewCommentView, commentView, editProfileView, extendedView, latestView, likesView, threadView, hashtagView, mentionsView, popularView, previewView, privateView, publishCustomView, publishView, previewSubtopicView, subtopicView, imageSearchView, setLanguage, topicsView, summaryView, threadsView, tribeAccessDeniedView, inviteRequiredView } = require("../views/main_views");
 const { activityView } = require("../views/activity_view");
 const { cvView, createCVView } = require("../views/cv_view");
 const { indexingView } = require("../views/indexing_view");
@@ -938,11 +1097,31 @@ router
       return true;
     });
     const results = await searchModel.search({ query, types: [] });
-    ctx.body = await searchView({ results: Object.entries(results).reduce((acc, [type, msgs]) => {
-      const filtered = applySearchPrivacy(msgs).map(msg => (!msg.value?.content) ? {} : { ...msg, content: msg.value.content, author: msg.value.content.author || 'Unknown' });
-      if (filtered.length > 0) acc[type] = filtered;
-      return acc;
-    }, {}), query, types: [] });
+    const cfgNow = getConfig();
+    const wishMutuals = cfgNow.wish === 'mutuals';
+    const mutualCache = wishMutuals ? makeCtxMutualCache() : null;
+    const accessSets = await getViewerTribeAccessSets(userId);
+    const finalResults = {};
+    for (const [type, msgs] of Object.entries(results)) {
+      const privacyFiltered = applySearchPrivacy(msgs).filter(msg => {
+        const c = msg.value?.content;
+        if (c && c.tribeId && accessSets.privateNotAccessible.has(c.tribeId)) return false;
+        return true;
+      });
+      let after = privacyFiltered;
+      if (wishMutuals) {
+        const out = [];
+        for (const m of privacyFiltered) {
+          const a = m.value?.author || m.value?.content?.author;
+          if (!a || a === userId) { out.push(m); continue; }
+          if (await mutualCache(a)) out.push(m);
+        }
+        after = out;
+      }
+      const mapped = after.map(msg => (!msg.value?.content) ? {} : { ...msg, content: msg.value.content, author: msg.value.content.author || 'Unknown' });
+      if (mapped.length > 0) finalResults[type] = mapped;
+    }
+    ctx.body = await searchView({ results: finalResults, query, types: [] });
   })
   .get("/images", async (ctx) => {
     if (!checkMod(ctx, 'imagesMod')) { ctx.redirect('/modules'); return; }
@@ -951,6 +1130,7 @@ router
     const fav = await mediaFavorites.getFavoriteSet('images');
     let enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) }));
     if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite);
+    enriched = await applyListFilters(enriched, ctx);
     await Promise.all(enriched.map(async x => { x.commentCount = (await getVoteComments(x.key)).length; }));
     ctx.body = await imageView(enriched, filter, null, { q, sort });
   })
@@ -978,6 +1158,7 @@ router
     if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite);
     const myTribeIds = await getUserTribeIds(uid);
     enriched = enriched.filter(x => !x.tribeId || myTribeIds.has(x.tribeId));
+    enriched = await applyListFilters(enriched, ctx);
     try {
       ctx.body = await mapsView(enriched, filter, null, { q, lat, lng, zoom, title, description, markerLabel, tags, mapType, ...(tribeId ? { tribeId } : {}) });
     } catch (e) {
@@ -998,13 +1179,17 @@ router
     const mapItem = await mapsModel.getMapById(mapId, uid);
     const fav = await mediaFavorites.getFavoriteSet('maps');
     let tribeMembers = [];
+    let parentTribe = null;
     if (mapItem.tribeId) {
       try {
-        const t = await tribesModel.getTribeById(mapItem.tribeId);
-        if (!t.members.includes(uid)) { ctx.body = tribeAccessDeniedView(t); return; }
-        tribeMembers = t.members;
+        parentTribe = await tribesModel.getTribeById(mapItem.tribeId);
+        if (!parentTribe.members.includes(uid)) { ctx.body = tribeAccessDeniedView(parentTribe); return; }
+        tribeMembers = parentTribe.members;
       } catch { ctx.redirect('/tribes'); return; }
     }
+    if (String(mapItem.mapType || '').toUpperCase() === 'CLOSED' && mapItem.author !== uid && mapItem.tribeId) {
+      ctx.body = tribeAccessDeniedView(parentTribe); return;
+    }
     ctx.body = await singleMapView({ ...mapItem, isFavorite: fav.has(String(mapItem.rootId || mapItem.key)) }, filter, { q, zoom, mkLat, mkLng, mkMarkerLabel, tribeMembers, returnTo: safeReturnTo(ctx, `/maps?filter=${encodeURIComponent(filter)}`, ['/maps']) });
   })
   .get("/audios", async (ctx) => {
@@ -1014,6 +1199,7 @@ router
     const fav = await mediaFavorites.getFavoriteSet('audios');
     let enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) }));
     if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite);
+    enriched = await applyListFilters(enriched, ctx);
     await Promise.all(enriched.map(async x => { x.commentCount = (await getVoteComments(x.key)).length; }));
     ctx.body = await audioView(enriched, filter, null, { q, sort });
   })
@@ -1038,6 +1224,7 @@ router
     const fav = await mediaFavorites.getFavoriteSet('torrents');
     let enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) }));
     if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite);
+    enriched = await applyListFilters(enriched, ctx);
     ctx.body = await torrentsView(enriched, filter, null, { q, sort });
   })
   .get("/torrents/edit/:id", async (ctx) => {
@@ -1061,6 +1248,7 @@ router
     const fav = await mediaFavorites.getFavoriteSet('videos');
     let enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) }));
     if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite);
+    enriched = await applyListFilters(enriched, ctx);
     await Promise.all(enriched.map(async x => { x.commentCount = (await getVoteComments(x.key)).length; }));
     ctx.body = await videoView(enriched, filter, null, { q, sort });
   })
@@ -1084,6 +1272,7 @@ router
     const fav = await mediaFavorites.getFavoriteSet('documents');
     let enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) }));
     if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite);
+    enriched = await applyListFilters(enriched, ctx);
     await Promise.all(enriched.map(async x => { x.commentCount = (await getVoteComments(x.rootId || x.key)).length; }));
     ctx.body = await documentView(enriched, filter, null, { q, sort });
   })
@@ -1122,7 +1311,28 @@ router
   })
   .get('/inbox', async ctx => {
     if (!checkMod(ctx, 'inboxMod')) { ctx.redirect('/modules'); return; }
-    const messages = sanitizeMessages(await pmModel.listAllPrivate());
+    let messages = sanitizeMessages(await pmModel.listAllPrivate());
+    const cfgNow = getConfig();
+    if (cfgNow.pmVisibility === 'mutuals') {
+      const viewer = getViewerId();
+      const mutualCache = new Map();
+      const isMutual = async (id) => {
+        if (id === viewer) return true;
+        if (mutualCache.has(id)) return mutualCache.get(id);
+        let rel;
+        try { rel = await friend.getRelationship(id); } catch (e) { rel = null; }
+        const m = !!(rel && rel.following && rel.followsMe);
+        mutualCache.set(id, m);
+        return m;
+      };
+      const filtered = [];
+      for (const msg of messages) {
+        const author = msg?.value?.author || msg?.author;
+        if (author === viewer) { filtered.push(msg); continue; }
+        if (await isMutual(author)) filtered.push(msg);
+      }
+      messages = filtered;
+    }
     await refreshInboxCount(messages);
     ctx.body = await privateView({ messages }, ctx.query.filter || undefined);
   })
@@ -1131,7 +1341,9 @@ router
     ctx.body = await tagsView(tags, filter);
   })
   .get('/reports', async ctx => {
-    const filter = qf(ctx), reports = await enrichWithComments(await reportsModel.listAll());
+    const filter = qf(ctx);
+    let reports = await enrichWithComments(await reportsModel.listAll());
+    reports = await applyListFilters(reports, ctx);
     ctx.body = await reportView(reports, filter, null, ctx.query.category || '');
   })
   .get('/reports/edit/:id', async ctx => {
@@ -1144,11 +1356,15 @@ router
     ctx.body = await singleReportView(withCount(report, comments), filter, comments);
   })
   .get('/trending', async (ctx) => {
-    const filter = qf(ctx, 'RECENT'), { filtered = [] } = await trendingModel.listTrending(filter);
+    const filter = qf(ctx, 'RECENT');
+    let { filtered = [] } = await trendingModel.listTrending(filter);
+    filtered = await applyListFilters(filtered, ctx);
     ctx.body = await trendingView(filtered, filter, trendingModel.categories);
   })
   .get('/agenda', async (ctx) => {
-    const filter = qf(ctx), data = await agendaModel.listAgenda(filter);
+    const filter = qf(ctx);
+    let data = await agendaModel.listAgenda(filter);
+    if (Array.isArray(data)) data = await applyListFilters(data, ctx);
     ctx.body = await agendaView(data, filter);
   })
   .get("/hashtag/:hashtag", async (ctx) => {
@@ -1160,6 +1376,17 @@ router
     const filter = qf(ctx);
     const query = { search: ctx.query.search || '' };
     const userId = getViewerId();
+    if (filter === 'pending') {
+      try { await scanPendingFollows(userId); } catch (e) {}
+      const pending = viewerFilters.listPending();
+      const enriched = await Promise.all(pending.map(async (p) => {
+        let name = p.followerId;
+        try { name = await about.name(p.followerId); } catch (_) {}
+        return { ...p, name };
+      }));
+      ctx.body = renderPendingFollows(enriched);
+      return;
+    }
     if (['CVs', 'MATCHSKILLS'].includes(filter)) {
       Object.assign(query, {
         location: ctx.query.location || '',
@@ -1463,6 +1690,21 @@ router
       const tasks = await listByTribeAllChain(tribe.id, 'task').catch(() => []);
       const feed = await listByTribeAllChain(tribe.id, 'feed').catch(() => []);
       sectionData = { events, tasks, feed };
+    } else if (section === 'governance') {
+      const gf = String(ctx.query.filter || 'government');
+      const isCreator = tribe.author === uid;
+      const isMember = Array.isArray(tribe.members) && tribe.members.includes(uid);
+      const [term, candidatures, rules, globalTermBase] = await Promise.all([
+        parliamentModel.tribe.getCurrentTerm(tribe.id).catch(() => null),
+        parliamentModel.tribe.listCandidatures(tribe.id).catch(() => []),
+        parliamentModel.tribe.listRules(tribe.id).catch(() => []),
+        parliamentModel.getCurrentTerm().catch(() => null)
+      ]);
+      const globalStart = globalTermBase?.startAt || null;
+      const alreadyPublishedThisGlobalCycle = await parliamentModel.tribe.hasCandidatureInGlobalCycle(tribe.id, globalStart).catch(() => false);
+      const leaders = Array.isArray(term?.leaders) ? term.leaders : [];
+      const hasElectedCandidate = Array.isArray(candidatures) && candidatures.some(c => (c.status || 'OPEN') === 'OPEN' && Number(c.votes || 0) > 0);
+      sectionData = { filter: gf, term, candidatures, rules, leaders, isCreator, isMember, canPublishToGlobal: isMember || isCreator, alreadyPublishedThisGlobalCycle, hasElectedCandidate };
     }
     const subTribes = await tribesModel.listSubTribes(tribe.id);
     tribe.subTribes = subTribes;
@@ -1493,7 +1735,7 @@ router
     const q = String((ctx.query && ctx.query.q) || '');
     try { await bankingModel.ensureSelfAddressPublished(); } catch (_) {}
     try { await bankingModel.getUserEngagementScore(userId); } catch (_) {}
-    const allActions = await activityModel.listFeed('all');
+    let allActions = await activityModel.listFeed('all');
     for (const action of allActions) {
       if (action.type === 'pad') {
         const c = action.value?.content || action.content || {};
@@ -1505,6 +1747,7 @@ router
         }
       }
     }
+    allActions = await applyListFilters(allActions, ctx);
     ctx.body = activityView(allActions, filter, userId, q);
   })
   .get("/profile", async (ctx) => {
@@ -1603,7 +1846,7 @@ router
   })
   .get("/settings", async (ctx) => {
     const cfg = getConfig(), theme = ctx.cookies.get("theme") || "Dark-SNH";
-    ctx.body = await settingsView({ theme, version: version.toString(), aiPrompt: cfg.ai?.prompt || "", pubWalletUrl: cfg.walletPub?.url || '', pubWalletUser: cfg.walletPub?.user || '', pubWalletPass: cfg.walletPub?.pass || '' });
+    ctx.body = await settingsView({ theme, version: version.toString(), aiPrompt: cfg.ai?.prompt || "" });
   })
   .get("/peers", async (ctx) => {
     const { discoveredPeers, unknownPeers } = await meta.discovered();
@@ -1662,7 +1905,9 @@ router
     ctx.body = await mentionsView({ messages: combined, myFeedId });
   })
   .get('/opinions', async (ctx) => {
-    const filter = qf(ctx, 'RECENT'), opinions = await opinionsModel.listOpinions(filter);
+    const filter = qf(ctx, 'RECENT');
+    let opinions = await opinionsModel.listOpinions(filter);
+    if (Array.isArray(opinions)) opinions = await applyListFilters(opinions, ctx);
     ctx.body = await opinionsView(opinions, filter);
   })
   .get("/feed", async (ctx) => {
@@ -1670,7 +1915,8 @@ router
     const q = typeof ctx.query.q === "string" ? ctx.query.q : "";
     const tag = typeof ctx.query.tag === "string" ? ctx.query.tag : "";
     const msg = typeof ctx.query.msg === "string" ? ctx.query.msg : "";
-    const feeds = await feedModel.listFeeds({ filter, q, tag });
+    let feeds = await feedModel.listFeeds({ filter, q, tag });
+    feeds = await applyListFilters(feeds, ctx);
     ctx.body = feedView(feeds, { filter, q, tag, msg });
   })
   .get("/feed/create", async (ctx) => {
@@ -1686,7 +1932,9 @@ router
   })
   .get('/forum', async ctx => {
     if (!checkMod(ctx, 'forumMod')) { ctx.redirect('/modules'); return; }
-    const filter = qf(ctx, 'hot'), forums = await forumModel.listAll(filter);
+    const filter = qf(ctx, 'hot');
+    let forums = await forumModel.listAll(filter);
+    forums = await applyListFilters(forums, ctx);
     ctx.body = await forumView(forums, filter);
   })
   .get('/forum/:forumId', async ctx => {
@@ -1703,6 +1951,7 @@ router
     const favs = await mediaFavorites.getFavoriteSet("bookmarks");
     let bookmarks = (await bookmarksModel.listAll({ viewerId, filter: filter === "favorites" ? "all" : filter, q, sort })).map(b => ({ ...b, isFavorite: favs.has(String(b.rootId || b.id)) }));
     if (filter === "favorites") bookmarks = bookmarks.filter(b => b.isFavorite);
+    bookmarks = await applyListFilters(bookmarks, ctx);
     await enrichWithComments(bookmarks, 'rootId');
     ctx.body = await bookmarkView(bookmarks, filter, null, { q, sort });
   })
@@ -1718,7 +1967,9 @@ router
     ctx.body = await singleBookmarkView({ ...bookmark, commentCount: comments.length, isFavorite: favs.has(String(root)) }, filter, comments, { q, sort, returnTo: safeReturnTo(ctx, `/bookmarks?filter=${encodeURIComponent(filter)}`, ['/bookmarks']) });
   })
   .get('/tasks', async ctx => {
-    const filter = qf(ctx), tasks = await enrichWithComments(await tasksModel.listAll());
+    const filter = qf(ctx);
+    let tasks = await enrichWithComments(await tasksModel.listAll());
+    tasks = await applyListFilters(tasks, ctx);
     ctx.body = await taskView(tasks, filter, null, ctx.query.returnTo);
   })
   .get('/tasks/edit/:id', async ctx => {
@@ -1733,7 +1984,9 @@ router
   })
   .get('/events', async (ctx) => {
     if (!checkMod(ctx, 'eventsMod')) { ctx.redirect('/modules'); return; }
-    const filter = qf(ctx), events = await enrichWithComments(await eventsModel.listAll(null, filter));
+    const filter = qf(ctx);
+    let events = await enrichWithComments(await eventsModel.listAll(null, filter));
+    events = await applyListFilters(events, ctx);
     ctx.body = await eventView(events, filter, null, ctx.query.returnTo);
   })
   .get('/events/edit/:id', async (ctx) => {
@@ -1748,7 +2001,9 @@ router
     ctx.body = await singleEventView(withCount(event, comments), filter, comments, { mapData });
   })
   .get('/votes', async ctx => {
-    const filter = qf(ctx), voteList = await enrichWithComments(await votesModel.listAll(filter));
+    const filter = qf(ctx);
+    let voteList = await enrichWithComments(await votesModel.listAll(filter));
+    voteList = await applyListFilters(voteList, ctx);
     ctx.body = await voteView(voteList, filter, null, [], filter);
   })
   .get('/votes/edit/:id', async ctx => {
@@ -1769,6 +2024,7 @@ router
     await marketModel.checkAuctionItemsStatus(marketItems);
     marketItems = await marketModel.listAllItems("all");
     await enrichWithComments(marketItems);
+    marketItems = await applyListFilters(marketItems, ctx);
     ctx.body = await marketView(marketItems, filter, null, { q, minPrice, maxPrice, sort });
   })
   .get("/market/edit/:id", async (ctx) => {
@@ -1828,8 +2084,9 @@ router
       return
     }
     const viewerId = getViewerId()
-    const jobs = await jobsModel.listJobs(filter, viewerId, query)
+    let jobs = await jobsModel.listJobs(filter, viewerId, query)
     await enrichWithComments(jobs)
+    jobs = await applyListFilters(jobs, ctx)
     ctx.body = await jobsView(jobs, filter, query)
   })
   .get('/jobs/edit/:id', async (ctx) => {
@@ -1874,6 +2131,7 @@ router
     const fav = await mediaFavorites.getFavoriteSet('shops');
     let enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) }));
     if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite);
+    enriched = await applyListFilters(enriched, ctx);
     const withFeatured = await Promise.all(enriched.map(async (shop) => {
       shop.featuredProducts = await shopsModel.listFeaturedProducts(shop.rootId || shop.key);
       return shop;
@@ -1923,7 +2181,8 @@ router
     const fav = await mediaFavorites.getFavoriteSet('chats');
     const myTribeIds = await getUserTribeIds(viewerId);
     const enriched = items.filter(x => !x.tribeId || myTribeIds.has(x.tribeId)).map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) }));
-    const finalList = filter === "favorites" ? enriched.filter(x => x.isFavorite) : enriched;
+    let finalList = filter === "favorites" ? enriched.filter(x => x.isFavorite) : enriched;
+    finalList = await applyListFilters(finalList, ctx);
     ctx.body = await chatsView(finalList, filter, null, { q });
   })
   .get("/chats/edit/:id", async (ctx) => {
@@ -1938,12 +2197,17 @@ router
     const uid = getViewerId();
     const chat = await chatsModel.getChatById(ctx.params.chatId);
     if (!chat) { ctx.redirect('/chats'); return; }
+    let parentTribe = null;
     if (chat.tribeId) {
       try {
-        const t = await tribesModel.getTribeById(chat.tribeId);
-        if (!t.members.includes(uid)) { ctx.body = tribeAccessDeniedView(t); return; }
+        parentTribe = await tribesModel.getTribeById(chat.tribeId);
+        if (!parentTribe.members.includes(uid)) { ctx.body = tribeAccessDeniedView(parentTribe); return; }
       } catch { ctx.redirect('/tribes'); return; }
     }
+    if (String(chat.status || '').toUpperCase() === 'INVITE-ONLY' && chat.author !== uid) {
+      const invited = Array.isArray(chat.invites) && chat.invites.includes(uid);
+      if (!invited) { ctx.body = inviteRequiredView('chat', parentTribe); return; }
+    }
     const fav = await mediaFavorites.getFavoriteSet('chats');
     const messages = await chatsModel.listMessages(chat.rootId || chat.key);
     ctx.body = await singleChatView({ ...chat, isFavorite: fav.has(String(chat.rootId || chat.key)) }, filter, messages, { q, returnTo: safeReturnTo(ctx, `/chats?filter=${encodeURIComponent(filter)}`, ['/chats']) });
@@ -1965,7 +2229,8 @@ router
     const pads = await padsModel.listAll({ filter, viewerId: uid });
     const fav = await mediaFavorites.getFavoriteSet('pads');
     const myTribeIds = await getUserTribeIds(uid);
-    const enriched = pads.filter(p => !p.tribeId || myTribeIds.has(p.tribeId)).map(p => ({ ...p, isFavorite: fav.has(String(p.rootId)) }));
+    let enriched = pads.filter(p => !p.tribeId || myTribeIds.has(p.tribeId)).map(p => ({ ...p, isFavorite: fav.has(String(p.rootId)) }));
+    enriched = await applyListFilters(enriched, ctx);
     ctx.body = await padsView(enriched, filter, null, { q, ...(tribeId ? { tribeId } : {}) });
   })
   .get("/pads/:padId", async (ctx) => {
@@ -1973,12 +2238,17 @@ router
     const uid = getViewerId();
     const pad = await padsModel.getPadById(ctx.params.padId);
     if (!pad) { ctx.redirect('/pads'); return; }
+    let parentTribe = null;
     if (pad.tribeId) {
       try {
-        const t = await tribesModel.getTribeById(pad.tribeId);
-        if (!t.members.includes(uid)) { ctx.body = tribeAccessDeniedView(t); return; }
+        parentTribe = await tribesModel.getTribeById(pad.tribeId);
+        if (!parentTribe.members.includes(uid)) { ctx.body = tribeAccessDeniedView(parentTribe); return; }
       } catch { ctx.redirect('/tribes'); return; }
     }
+    if (String(pad.status || '').toUpperCase() === 'INVITE-ONLY' && pad.author !== uid) {
+      const invited = Array.isArray(pad.invites) && pad.invites.includes(uid);
+      if (!invited) { ctx.body = inviteRequiredView('pad', parentTribe); return; }
+    }
     const fav = await mediaFavorites.getFavoriteSet('pads');
     const entries = await padsModel.getEntries(pad.rootId);
     const versionKey = ctx.query.version || null;
@@ -2007,7 +2277,8 @@ router
     const fav = await mediaFavorites.getFavoriteSet('calendars');
     const myTribeIds = await getUserTribeIds(uid);
     const enriched = calendars.filter(c => !c.tribeId || myTribeIds.has(c.tribeId)).map(c => ({ ...c, isFavorite: fav.has(String(c.rootId)) }));
-    const finalList = filter === "favorites" ? enriched.filter(c => c.isFavorite) : enriched;
+    let finalList = filter === "favorites" ? enriched.filter(c => c.isFavorite) : enriched;
+    finalList = await applyListFilters(finalList, ctx);
     ctx.body = await calendarsView(finalList, filter, null, { q, ...(tribeId ? { tribeId } : {}) });
   })
   .get("/calendars/:calId", async (ctx) => {
@@ -2015,12 +2286,16 @@ router
     const uid = getViewerId();
     const cal = await calendarsModel.getCalendarById(ctx.params.calId);
     if (!cal) { ctx.redirect('/calendars'); return; }
+    let parentTribe = null;
     if (cal.tribeId) {
       try {
-        const t = await tribesModel.getTribeById(cal.tribeId);
-        if (!t.members.includes(uid)) { ctx.body = tribeAccessDeniedView(t); return; }
+        parentTribe = await tribesModel.getTribeById(cal.tribeId);
+        if (!parentTribe.members.includes(uid)) { ctx.body = tribeAccessDeniedView(parentTribe); return; }
       } catch { ctx.redirect('/tribes'); return; }
     }
+    if (String(cal.status || '').toUpperCase() === 'CLOSED' && cal.author !== uid) {
+      ctx.body = tribeAccessDeniedView(parentTribe); return;
+    }
     const dates = await calendarsModel.getDatesForCalendar(cal.rootId);
     const notesByDate = {};
     for (const d of dates) {
@@ -2039,8 +2314,9 @@ router
       return
     }
     const modelFilter = filter === "BACKERS" ? "ALL" : filter
-    const projects = await projectsModel.listProjects(modelFilter)
+    let projects = await projectsModel.listProjects(modelFilter)
     await enrichWithComments(projects)
+    projects = await applyListFilters(projects, ctx)
     ctx.body = await projectsView(projects, filter)
   })
   .get("/projects/edit/:id", async (ctx) => {
@@ -2066,10 +2342,12 @@ router
     const q = (query.q || '').trim();
     const msg = (query.msg || '').trim();
     await bankingModel.ensureSelfAddressPublished();
-    if (filter === 'overview' && bankingModel.isPubNode()) {
-      try { await bankingModel.executeEpoch({}); } catch (_) {}
+    if (bankingModel.isPubNode()) {
       try { await bankingModel.publishPubBalance(); } catch (_) {}
-      try { await bankingModel.processPendingClaims(); } catch (_) {}
+      if (filter === 'overview') {
+        try { await bankingModel.executeEpoch({}); } catch (_) {}
+        try { await bankingModel.processPendingClaims(); } catch (_) {}
+      }
     }
     const data = await bankingModel.listBanking(filter, userId);
     data.isPub = bankingModel.isPubNode();
@@ -2086,14 +2364,15 @@ router
       data.search = q;
     }
     data.flash = msg || '';
-    const { ecoValue, inflationFactor, ecoInHours, currentSupply, isSynced } = await bankingModel.calculateEcoinValue();
+    const { ecoValue, inflationFactor, inflationMonthly, ecoTimeMs, currentSupply, isSynced } = await bankingModel.calculateEcoinValue();
     data.exchange = {
-      ecoValue: ecoValue,
+      ecoValue,
       inflationFactor,
-      ecoInHours,
-      currentSupply: currentSupply,
+      inflationMonthly,
+      ecoTimeMs,
+      currentSupply,
       totalSupply: 25500000,
-      isSynced: isSynced
+      isSynced
     };
     ctx.body = renderBankingView(data, filter, userId, data.isPub);
   })
@@ -2387,6 +2666,17 @@ router
     const { recipients, subject, text } = ctx.request.body;
     const recipientsArr = (recipients || '').split(',').map(s => s.trim()).filter(Boolean).filter(id => ssbRef.isFeedId(id));
     if (recipientsArr.length === 0) { ctx.throw(400, 'No valid recipients'); return; }
+    const cfgNow = getConfig();
+    if (cfgNow.pmVisibility === 'mutuals') {
+      const viewer = getViewerId();
+      for (const rid of recipientsArr) {
+        if (rid === viewer) continue;
+        let rel;
+        try { rel = await friend.getRelationship(rid); } catch (e) { rel = null; }
+        const mutual = !!(rel && rel.following && rel.followsMe);
+        if (!mutual) ctx.throw(403, 'You can only send private messages to habitants with mutual support.');
+      }
+    }
     await pmModel.sendMessage(recipientsArr, stripDangerousTags(subject), stripDangerousTags(text));
     await refreshInboxCount();
     ctx.redirect('/inbox?filter=sent');
@@ -2803,7 +3093,10 @@ router
     const b = ctx.request.body;
     if (tooLong(ctx, b.title, MAX_TITLE_LENGTH, 'Title') || tooLong(ctx, b.description, MAX_TEXT_LENGTH, 'Description')) return;
     const image = await handleBlobUpload(ctx, 'image');
-    await tribesModel.createTribe(stripDangerousTags(b.title), stripDangerousTags(b.description), image, stripDangerousTags(b.location), b.tags, b.isLARP === 'true', b.isAnonymous === 'true', b.inviteMode || 'open', ctx.params.id, 'OPEN', stripDangerousTags(b.mapUrl));
+    const parentEffective = await tribesModel.getEffectiveStatus(ctx.params.id).catch(() => ({ isPrivate: false }));
+    const requestedAnonymous = b.isAnonymous === 'true';
+    const effectiveAnonymous = parentEffective.isPrivate ? true : requestedAnonymous;
+    await tribesModel.createTribe(stripDangerousTags(b.title), stripDangerousTags(b.description), image, stripDangerousTags(b.location), b.tags, b.isLARP === 'true', effectiveAnonymous, b.inviteMode || 'open', ctx.params.id, 'OPEN', stripDangerousTags(b.mapUrl));
     ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=subtribes`);
   })
   .post('/tribes/update/:id', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => {
@@ -3133,6 +3426,71 @@ router
     await parliamentModel.proposeCandidature({ candidateId: id, method: m }).catch(e => ctx.throw(400, String(e?.message || e)));
     ctx.redirect('/parliament?filter=candidatures');
   })
+  .post('/tribe/:id/governance/publish-candidature', koaBody(), async (ctx) => {
+    const tribeId = ctx.params.id;
+    const uid = getViewerId();
+    const tribe = await tribesModel.getTribeById(tribeId).catch(() => null);
+    if (!tribe) ctx.throw(404, 'Tribe not found');
+    const isCreator = tribe.author === uid;
+    const isMember = Array.isArray(tribe.members) && tribe.members.includes(uid);
+    if (!isCreator && !isMember) ctx.throw(403, 'Not a tribe member');
+    const globalTerm = await parliamentModel.getCurrentTerm().catch(() => null);
+    const already = await parliamentModel.tribe.hasCandidatureInGlobalCycle(tribeId, globalTerm?.startAt).catch(() => false);
+    if (already) ctx.throw(400, 'This tribe already has an open candidature in the current global parliament cycle.');
+    const term = await parliamentModel.tribe.getCurrentTerm(tribeId).catch(() => null);
+    const method = (term?.method && String(term.method).toUpperCase()) || 'DEMOCRACY';
+    await parliamentModel.proposeCandidature({ candidateId: tribeId, method }).catch(e => ctx.throw(400, String(e?.message || e)));
+    ctx.redirect('/parliament?filter=candidatures');
+  })
+  .post('/tribe/:id/governance/candidature/propose', koaBody(), async (ctx) => {
+    const tribeId = ctx.params.id;
+    const uid = getViewerId();
+    const tribe = await tribesModel.getTribeById(tribeId).catch(() => null);
+    if (!tribe) ctx.throw(404, 'Tribe not found');
+    const isCreator = tribe.author === uid;
+    const isMember = Array.isArray(tribe.members) && tribe.members.includes(uid);
+    if (!isCreator && !isMember) ctx.throw(403, 'Not a tribe member');
+    const b = ctx.request.body || {};
+    const candidateId = String(b.candidateId || '').trim();
+    const method = String(b.method || '').trim().toUpperCase();
+    if (!candidateId) ctx.throw(400, 'Candidate required');
+    await parliamentModel.tribe.publishTribeCandidature({ tribeId, candidateId, method }).catch(e => ctx.throw(400, String(e?.message || e)));
+    ctx.redirect(`/tribe/${encodeURIComponent(tribeId)}?section=governance&filter=candidatures`);
+  })
+  .post('/tribe/:id/governance/candidature/vote', koaBody(), async (ctx) => {
+    const tribeId = ctx.params.id;
+    const uid = getViewerId();
+    const tribe = await tribesModel.getTribeById(tribeId).catch(() => null);
+    if (!tribe) ctx.throw(404, 'Tribe not found');
+    const isCreator = tribe.author === uid;
+    const isMember = Array.isArray(tribe.members) && tribe.members.includes(uid);
+    if (!isCreator && !isMember) ctx.throw(403, 'Not a tribe member');
+    const candidatureId = String(ctx.request.body?.candidatureId || '').trim();
+    if (!candidatureId) ctx.throw(400, 'Missing candidatureId');
+    await parliamentModel.tribe.voteTribeCandidature({ tribeId, candidatureId }).catch(e => ctx.throw(400, String(e?.message || e)));
+    ctx.redirect(`/tribe/${encodeURIComponent(tribeId)}?section=governance&filter=candidatures`);
+  })
+  .post('/tribe/:id/governance/rule/add', koaBody(), async (ctx) => {
+    const tribeId = ctx.params.id;
+    const uid = getViewerId();
+    const tribe = await tribesModel.getTribeById(tribeId).catch(() => null);
+    if (!tribe) ctx.throw(404, 'Tribe not found');
+    if (tribe.author !== uid) ctx.throw(403, 'Only tribe creator can add rules');
+    const b = ctx.request.body || {};
+    await parliamentModel.tribe.publishTribeRule({ tribeId, title: stripDangerousTags(String(b.title || '')), body: stripDangerousTags(String(b.body || '')) }).catch(e => ctx.throw(400, String(e?.message || e)));
+    ctx.redirect(`/tribe/${encodeURIComponent(tribeId)}?section=governance&filter=rules`);
+  })
+  .post('/tribe/:id/governance/rule/delete', koaBody(), async (ctx) => {
+    const tribeId = ctx.params.id;
+    const uid = getViewerId();
+    const tribe = await tribesModel.getTribeById(tribeId).catch(() => null);
+    if (!tribe) ctx.throw(404, 'Tribe not found');
+    if (tribe.author !== uid) ctx.throw(403, 'Only tribe creator can delete rules');
+    const ruleId = String(ctx.request.body?.ruleId || '').trim();
+    if (!ruleId) ctx.throw(400, 'Missing ruleId');
+    await parliamentModel.tribe.deleteTribeRule(ruleId).catch(e => ctx.throw(400, String(e?.message || e)));
+    ctx.redirect(`/tribe/${encodeURIComponent(tribeId)}?section=governance&filter=rules`);
+  })
   .post('/parliament/candidatures/:id/vote', koaBody(), async (ctx) => {
     await parliamentModel.voteCandidature(ctx.params.id).catch(e => ctx.throw(400, String(e?.message || e)));
     ctx.redirect('/parliament?filter=candidatures');
@@ -3964,6 +4322,37 @@ router
     saveConfig(cfg);
     ctx.redirect("/settings");
   })
+  .post("/inhabitants/follow/accept", koaBody(), async (ctx) => {
+    const b = ctx.request.body || {};
+    const followerId = String(b.followerId || '').trim();
+    if (!followerId) { ctx.redirect('/inhabitants?filter=pending'); return; }
+    if (viewerFilters.canAutoAcceptNow()) viewerFilters.markAutoAccept();
+    viewerFilters.addAccepted(followerId);
+    viewerFilters.removePending(followerId);
+    ctx.redirect('/inhabitants?filter=pending');
+  })
+  .post("/inhabitants/follow/reject", koaBody(), async (ctx) => {
+    const b = ctx.request.body || {};
+    const followerId = String(b.followerId || '').trim();
+    if (!followerId) { ctx.redirect('/inhabitants?filter=pending'); return; }
+    viewerFilters.removeAccepted(followerId);
+    viewerFilters.removePending(followerId);
+    ctx.redirect('/inhabitants?filter=pending');
+  })
+  .post("/settings/wish", koaBody(), async (ctx) => {
+    const cfg = getConfig();
+    const v = String(ctx.request.body.wish || '').trim();
+    cfg.wish = v === 'mutuals' ? 'mutuals' : 'whole';
+    saveConfig(cfg);
+    ctx.redirect("/settings");
+  })
+  .post("/settings/pm-visibility", koaBody(), async (ctx) => {
+    const cfg = getConfig();
+    const v = String(ctx.request.body.pmVisibility || '').trim();
+    cfg.pmVisibility = v === 'mutuals' ? 'mutuals' : 'whole';
+    saveConfig(cfg);
+    ctx.redirect("/settings");
+  })
   .post("/settings/rebuild", async ctx => { meta.rebuild(); ctx.redirect("/settings"); })
   .post("/modules/preset", koaBody(), async (ctx) => {
     const ALL_MODULES = ['popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'invites', 'wallet', 'legacy', 'cipher', 'bookmarks', 'calendars', 'chats', 'videos', 'docs', 'audios', 'tags', 'images', 'trending', 'events', 'tasks', 'market', 'tribes', 'votes', 'reports', 'opinions', 'pads', 'transfers', 'feed', 'pixelia', 'agenda', 'favorites', 'ai', 'forum', 'games', 'jobs', 'projects', 'shops', 'banking', 'parliament', 'courts'];
@@ -3996,15 +4385,9 @@ router
     saveConfig(cfg);
     ctx.redirect("/settings");
   })
-  .post("/settings/pub-wallet", koaBody(), async (ctx) => {
-    const b = ctx.request.body, cfg = getConfig();
-    cfg.walletPub = { url: String(b.wallet_url || "").trim(), user: String(b.wallet_user || "").trim(), pass: String(b.wallet_pass || "").trim() };
-    fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2));
-    ctx.redirect("/settings");
-  })
   .post("/settings/pub-id", koaBody(), async (ctx) => {
     const b = ctx.request.body, cfg = getConfig();
-    cfg.pubId = String(b.pub_id || "").trim();
+    cfg.walletPub = { pubId: String(b.pub_id || "").trim() };
     saveConfig(cfg);
     ctx.redirect("/settings");
   })
@@ -4113,6 +4496,6 @@ const middleware = [
   routes,
 ];
 const app = http({ host, port, middleware, allowHost: config.allowHost });
-app._close = () => { nameWarmup.close(); cooler.close(); };
+app._close = () => { nameWarmup.close(); cooler.close(); if (pubBalanceTimer) clearInterval(pubBalanceTimer); };
 module.exports = app;
 if (config.open === true) open(url);

+ 1 - 1
src/client/assets/styles/style.css

@@ -4943,7 +4943,7 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 .activity-filter-grid{display:grid;grid-template-columns:repeat(6,1fr);gap:16px;margin-bottom:24px}
 .activity-filter-col{display:flex;flex-direction:column;gap:8px}
 
-.visit-btn-centered{display:flex;justify-content:center;margin-top:10px}
+.visit-btn-centered{display:flex;margin-top:10px;border:0px}
 
 .pad-editor-white{width:100%;box-sizing:border-box;background:#fff;color:#111;border-radius:4px;padding:10px;resize:vertical;font-family:inherit;font-size:0.95rem}
 .pad-readonly-text{background:#fff;color:#111;border:1px solid #aaa;border-radius:4px;padding:10px;white-space:pre-wrap;word-break:break-word;font-size:0.95rem}

+ 40 - 3
src/client/assets/translations/oasis_ar.js

@@ -2160,7 +2160,7 @@ module.exports = {
     bankNotRemovableOasis: 'لا يمكن إزالة العناوين محليًا',
     bankingFutureUBI: "UBI",
     pubIdTitle: "PUB Wallet",
-    pubIdDescription: "Set the PUB wallet URL. This will be used for PUB transactions (including the UBI).",
+    pubIdDescription: "Set the PUB OASIS ID. This will be used for PUB transactions (including the UBI).",
     pubIdLabel: "PUB ID",
     pubIdSave: "Save configuration",
     pubIdPlaceholder: "@PUB_ID.ed25519",
@@ -2246,9 +2246,14 @@ module.exports = {
     bankTotalSupply: 'إجمالي معروض ECOin',
     bankEcoinHours: "معادلة ECOin بالوقت",
     bankHoursOfWork: 'ساعات',
+    bankUnitMs: "مللي ثانية",
+    bankUnitSeconds: "ثواني",
+    bankUnitMinutes: "دقائق",
+    bankUnitDays: "أيام",
     bankExchangeNoData: 'لا توجد بيانات متاحة',
     bankExchangeIndex: 'قيمة ECOin (1 ساعة)',
-    bankInflation: 'تضخم ECOin',
+    bankInflation: 'تضخم ECOin (anual)',
+    bankInflationMonthly: 'تضخم ECOin (mensual)',
     bankCurrentSupply: 'المعروض الحالي من ECOin',
     bankingSyncStatus: 'حالة ECOin',
     bankingSyncStatusSynced: 'متزامن',
@@ -3100,6 +3105,38 @@ module.exports = {
     favoritesFilterTorrents: "التورنت",
     tribeSectionTorrents: "التورنت",
     tribeCreateTorrent: "رفع تورنت",
-    tribeMediaTypeTorrent: "تورنت"
+    tribeMediaTypeTorrent: "تورنت",
+    settingsWishTitle: "الرغبة",
+    settingsWishDesc: "قم بتكوين رغبة الأفاتار الخاص بك.",
+    settingsWishWhole: "Multiverse",
+    settingsWishMutuals: "Only mutual-support",
+    settingsPmVisibilityTitle: "الرسائل الخاصة",
+    settingsPmVisibilityDesc: "قم بتكوين مستوى التعرض للرسائل الخاصة في الشبكة.",
+    settingsPmVisibilityWhole: "Multiverse",
+    settingsPmVisibilityMutuals: "Only mutual-support",
+    pmMutualNotice: "الإرسال حاجز UX. الاستقبال فلتر انتباه.",
+    pmBlockedNonMutual: "يمكنك إرسال الرسائل الخاصة فقط للسكان ذوي الدعم المتبادل.",
+    inhabitantsPendingFollowsTitle: "طلبات الدعم المعلقة",
+    inhabitantsPendingAccept: "قبول",
+    inhabitantsPendingReject: "رفض",
+    bxEncrypted: "مشفر",
+    bxEncryptedHexLabel: "النص المشفر (معاينة)",
+    tribeSectionGovernance: "الحوكمة",
+    tribeSubStatusPublic: "عامة",
+    tribeSubStatusPrivate: "خاصة",
+    tribeSubInheritedPrivate: "خاصة (موروثة من القبيلة الرئيسية)",
+    tribePadInviteRequired: "لا يمكنك الوصول. اطلب دعوة.",
+    tribeChatInviteRequired: "لا يمكنك الوصول للدردشة. اطلب دعوة.",
+    tribeGovernanceDesc: "الحوكمة الداخلية لهذه القبيلة.",
+    tribeGovernanceNoGov: "لا توجد حكومة نشطة",
+    tribeGovernanceNoGovDesc: "لم تنتخب هذه القبيلة حكومة بعد.",
+    tribeGovernanceAlreadyPublished: "لدى هذه القبيلة ترشح مفتوح.",
+    tribeGovernanceProposeInternal: "اقتراح ترشح داخلي",
+    tribeGovernanceInternalCandidatures: "الترشيحات الداخلية",
+    tribeGovernanceNoCandidatures: "لا توجد ترشيحات مفتوحة.",
+    tribeGovernanceAddRule: "إضافة قاعدة",
+    tribeGovernanceNoRules: "لا توجد قواعد بعد.",
+    tribeGovernanceNoLeaders: "لم يُنتخب أي قائد بعد.",
+    tribeGovernanceComingSoon: "قريبًا في وحدة الحوكمة."
     }
 };

+ 40 - 3
src/client/assets/translations/oasis_de.js

@@ -2159,7 +2159,7 @@ module.exports = {
     bankNotRemovableOasis: 'Adressen können nicht lokal entfernt werden',
     bankingFutureUBI: "UBI",
     pubIdTitle: "PUB Wallet",
-    pubIdDescription: "Set the PUB wallet URL. This will be used for PUB transactions (including the UBI).",
+    pubIdDescription: "Set the PUB OASIS ID. This will be used for PUB transactions (including the UBI).",
     pubIdLabel: "PUB ID",
     pubIdSave: "Save configuration",
     pubIdPlaceholder: "@PUB_ID.ed25519",
@@ -2245,9 +2245,14 @@ module.exports = {
     bankTotalSupply: 'ECOin Gesamtmenge',
     bankEcoinHours: "ECOin-Äquivalent in Zeit",
     bankHoursOfWork: 'Stunden',
+    bankUnitMs: "ms",
+    bankUnitSeconds: "Sekunden",
+    bankUnitMinutes: "Minuten",
+    bankUnitDays: "Tage",
     bankExchangeNoData: 'Keine Daten verfügbar',
     bankExchangeIndex: 'ECOin-Wert (1h)',
-    bankInflation: 'ECOin-Inflation',
+    bankInflation: 'ECOin-Inflation (anual)',
+    bankInflationMonthly: 'ECOin-Inflation (mensual)',
     bankCurrentSupply: 'ECOin aktuelle Menge',
     bankingSyncStatus: 'ECOin-Status',
     bankingSyncStatusSynced: 'Synchronisiert',
@@ -3096,6 +3101,38 @@ module.exports = {
     favoritesFilterTorrents: "TORRENTS",
     tribeSectionTorrents: "TORRENTS",
     tribeCreateTorrent: "Torrent Hochladen",
-    tribeMediaTypeTorrent: "Torrent"
+    tribeMediaTypeTorrent: "Torrent",
+    settingsWishTitle: "Wunsch",
+    settingsWishDesc: "Konfiguriere den Wunsch deines Avatars.",
+    settingsWishWhole: "Multiverse",
+    settingsWishMutuals: "Only mutual-support",
+    settingsPmVisibilityTitle: "Private Nachrichten",
+    settingsPmVisibilityDesc: "Konfiguriere dein Niveau der Offenlegung für private Nachrichten.",
+    settingsPmVisibilityWhole: "Multiverse",
+    settingsPmVisibilityMutuals: "Only mutual-support",
+    pmMutualNotice: "Ausgang ist ein UX-Schutz. Eingang ist ein Aufmerksamkeitsfilter.",
+    pmBlockedNonMutual: "Du kannst PNs nur an Bewohner mit gegenseitiger Unterstützung senden.",
+    inhabitantsPendingFollowsTitle: "Ausstehende Unterstützungsanfragen",
+    inhabitantsPendingAccept: "Akzeptieren",
+    inhabitantsPendingReject: "Ablehnen",
+    bxEncrypted: "VERSCHLÜSSELT",
+    bxEncryptedHexLabel: "Chiffretext (Vorschau)",
+    tribeSectionGovernance: "GOVERNANCE",
+    tribeSubStatusPublic: "ÖFFENTLICH",
+    tribeSubStatusPrivate: "PRIVAT",
+    tribeSubInheritedPrivate: "PRIVAT (vom Haupt-Stamm geerbt)",
+    tribePadInviteRequired: "Kein Zugriff auf das Pad. Bitte um Einladung.",
+    tribeChatInviteRequired: "Kein Zugriff auf den Chat. Bitte um Einladung.",
+    tribeGovernanceDesc: "Interne Governance dieses Stammes.",
+    tribeGovernanceNoGov: "Keine aktive Regierung",
+    tribeGovernanceNoGovDesc: "Dieser Stamm hat noch keine Regierung gewählt.",
+    tribeGovernanceAlreadyPublished: "Dieser Stamm hat bereits eine offene Kandidatur.",
+    tribeGovernanceProposeInternal: "Interne Kandidatur vorschlagen",
+    tribeGovernanceInternalCandidatures: "Interne Kandidaturen",
+    tribeGovernanceNoCandidatures: "Keine offenen Kandidaturen.",
+    tribeGovernanceAddRule: "Regel hinzufügen",
+    tribeGovernanceNoRules: "Noch keine Regeln.",
+    tribeGovernanceNoLeaders: "Noch keine Führungskräfte gewählt.",
+    tribeGovernanceComingSoon: "Bald im Governance-Modul."
     }
 }

+ 40 - 3
src/client/assets/translations/oasis_en.js

@@ -2165,7 +2165,7 @@ module.exports = {
     bankNotRemovableOasis: 'Addresses cannot be removed locally',
     bankingFutureUBI: "UBI",
     pubIdTitle: "PUB Wallet",
-    pubIdDescription: "Set the PUB wallet URL. This will be used for PUB transactions (including the UBI).",
+    pubIdDescription: "Set the PUB OASIS ID. This will be used for PUB transactions (including the UBI).",
     pubIdLabel: "PUB ID",
     pubIdSave: "Save configuration",
     pubIdPlaceholder: "@PUB_ID.ed25519",
@@ -2251,9 +2251,14 @@ module.exports = {
     bankTotalSupply: 'ECOin Total Supply',
     bankEcoinHours: "ECOin Equivalence in Time",
     bankHoursOfWork: 'hours',
+    bankUnitMs: "ms",
+    bankUnitSeconds: "seconds",
+    bankUnitMinutes: "minutes",
+    bankUnitDays: "days",
     bankExchangeNoData: 'No data available',
     bankExchangeIndex: 'ECOin Value (1h)',
-    bankInflation: 'ECOin Inflation',
+    bankInflation: 'ECOin Inflation (anual)',
+    bankInflationMonthly: 'ECOin Inflation (mensual)',
     bankCurrentSupply: 'ECOin Current Supply',
     bankingSyncStatus: 'ECOin Status',
     bankingSyncStatusSynced: 'Synced',
@@ -3119,7 +3124,39 @@ module.exports = {
     favoritesFilterTorrents: "TORRENTS",
     tribeSectionTorrents: "TORRENTS",
     tribeCreateTorrent: "Upload Torrent",
-    tribeMediaTypeTorrent: "Torrent"
+    tribeMediaTypeTorrent: "Torrent",
+    settingsWishTitle: "Wish",
+    settingsWishDesc: "Configure the wish of your Avatar. This determines how to access the whole content and your level of experiences into the network.",
+    settingsWishWhole: "Multiverse",
+    settingsWishMutuals: "Only mutual-support",
+    settingsPmVisibilityTitle: "Private Messages",
+    settingsPmVisibilityDesc: "Configure the level of exposition to private messages that you want to have in the network.",
+    settingsPmVisibilityWhole: "Multiverse",
+    settingsPmVisibilityMutuals: "Only mutual-support",
+    pmMutualNotice: "Outbound is a UX guardrail. Inbound is an attention filter (ciphertext is still replicated).",
+    pmBlockedNonMutual: "You can only send private messages to habitants with mutual support.",
+    inhabitantsPendingFollowsTitle: "Pending follow requests",
+    inhabitantsPendingAccept: "Accept",
+    inhabitantsPendingReject: "Reject",
+    bxEncrypted: "ENCRYPTED",
+    bxEncryptedHexLabel: "Ciphertext (preview)",
+    tribeSectionGovernance: "GOVERNANCE",
+    tribeSubStatusPublic: "PUBLIC",
+    tribeSubStatusPrivate: "PRIVATE",
+    tribeSubInheritedPrivate: "PRIVATE (inherited from main tribe)",
+    tribePadInviteRequired: "You do not have access to the pad. Ask for an invitation to access the content.",
+    tribeChatInviteRequired: "You do not have access to the chat. Ask for an invitation to access the content.",
+    tribeGovernanceDesc: "Internal governance for this tribe. Propose candidatures, debate rules, elect leaders.",
+    tribeGovernanceNoGov: "No active government",
+    tribeGovernanceNoGovDesc: "This tribe has not yet elected a government. Propose candidatures to start the process.",
+    tribeGovernanceAlreadyPublished: "This tribe already has an open candidature in the current global parliament cycle.",
+    tribeGovernanceProposeInternal: "Propose internal candidature",
+    tribeGovernanceInternalCandidatures: "Internal candidatures",
+    tribeGovernanceNoCandidatures: "No open candidatures.",
+    tribeGovernanceAddRule: "Add rule",
+    tribeGovernanceNoRules: "No rules yet.",
+    tribeGovernanceNoLeaders: "No leaders elected yet.",
+    tribeGovernanceComingSoon: "Coming soon in this tribe's governance module."
 
     }
 };

+ 40 - 3
src/client/assets/translations/oasis_es.js

@@ -2163,7 +2163,7 @@ module.exports = {
     bankNotRemovableOasis: 'Las direcciones no se pueden eliminar localmente',
     bankingFutureUBI: "UBI",
     pubIdTitle: "PUB Wallet",
-    pubIdDescription: "Set the PUB wallet URL. This will be used for PUB transactions (including the UBI).",
+    pubIdDescription: "Set the PUB OASIS ID. This will be used for PUB transactions (including the UBI).",
     pubIdLabel: "PUB ID",
     pubIdSave: "Save configuration",
     pubIdPlaceholder: "@PUB_ID.ed25519",
@@ -2249,9 +2249,14 @@ module.exports = {
     bankTotalSupply: 'Suministro Total de ECOin',
     bankEcoinHours: 'Horas ECOin',
     bankHoursOfWork: 'horas',
+    bankUnitMs: "ms",
+    bankUnitSeconds: "segundos",
+    bankUnitMinutes: "minutos",
+    bankUnitDays: "días",
     bankExchangeNoData: 'No hay datos disponibles',
     bankExchangeIndex: 'Valor de ECOin (1h)',
-    bankInflation: 'Inflación de ECOin',
+    bankInflation: 'Inflación de ECOin (anual)',
+    bankInflationMonthly: 'Inflación de ECOin (mensual)',
     bankCurrentSupply: 'Suministro Actual de ECOin',
     bankingSyncStatus: 'Estado de ECOin',
     bankingSyncStatusSynced: 'Sincronizado',
@@ -3110,6 +3115,38 @@ module.exports = {
     favoritesFilterTorrents: "TORRENTS",
     tribeSectionTorrents: "TORRENTS",
     tribeCreateTorrent: "Subir Torrent",
-    tribeMediaTypeTorrent: "Torrent"
+    tribeMediaTypeTorrent: "Torrent",
+    settingsWishTitle: "Deseo",
+    settingsWishDesc: "Configura el deseo de tu Avatar. Determina cómo accedes a todo el contenido y tu experiencia en la red Oasis.",
+    settingsWishWhole: "Multiverse",
+    settingsWishMutuals: "Only mutual-support",
+    settingsPmVisibilityTitle: "Mensajes Privados",
+    settingsPmVisibilityDesc: "Configura el nivel de exposición a mensajes privados que deseas tener en la red.",
+    settingsPmVisibilityWhole: "Multiverse",
+    settingsPmVisibilityMutuals: "Only mutual-support",
+    pmMutualNotice: "El envío es una barrera de UX. La recepción es un filtro de atención (el cifrado ya está replicado).",
+    pmBlockedNonMutual: "Solo puedes enviar mensajes privados a habitantes con apoyo mutuo.",
+    inhabitantsPendingFollowsTitle: "Solicitudes de apoyo pendientes",
+    inhabitantsPendingAccept: "Aceptar",
+    inhabitantsPendingReject: "Rechazar",
+    bxEncrypted: "CIFRADO",
+    bxEncryptedHexLabel: "Texto cifrado (vista previa)",
+    tribeSectionGovernance: "GOBIERNO",
+    tribeSubStatusPublic: "PÚBLICA",
+    tribeSubStatusPrivate: "PRIVADA",
+    tribeSubInheritedPrivate: "PRIVADA (heredada de la tribu principal)",
+    tribePadInviteRequired: "No tienes acceso al pad. Pide una invitación para acceder al contenido.",
+    tribeChatInviteRequired: "No tienes acceso al chat. Pide una invitación para acceder al contenido.",
+    tribeGovernanceDesc: "Gobierno interno de esta tribu.",
+    tribeGovernanceNoGov: "Sin gobierno activo",
+    tribeGovernanceNoGovDesc: "Esta tribu aún no tiene gobierno. Propón candidaturas para iniciar el proceso.",
+    tribeGovernanceAlreadyPublished: "Esta tribu ya tiene una candidatura abierta en el ciclo actual del parlamento global.",
+    tribeGovernanceProposeInternal: "Proponer candidatura interna",
+    tribeGovernanceInternalCandidatures: "Candidaturas internas",
+    tribeGovernanceNoCandidatures: "Sin candidaturas abiertas.",
+    tribeGovernanceAddRule: "Añadir regla",
+    tribeGovernanceNoRules: "Sin reglas todavía.",
+    tribeGovernanceNoLeaders: "Sin líderes elegidos todavía.",
+    tribeGovernanceComingSoon: "Próximamente en el módulo de gobierno de esta tribu."
     }
 };

+ 40 - 3
src/client/assets/translations/oasis_eu.js

@@ -2131,7 +2131,7 @@ module.exports = {
     bankingUserEngagementScore: "KARMA puntuazioa",
     bankingFutureUBI: "UBI",
     pubIdTitle: "PUB Wallet",
-    pubIdDescription: "Set the PUB wallet URL. This will be used for PUB transactions (including the UBI).",
+    pubIdDescription: "Set the PUB OASIS ID. This will be used for PUB transactions (including the UBI).",
     pubIdLabel: "PUB ID",
     pubIdSave: "Save configuration",
     pubIdPlaceholder: "@PUB_ID.ed25519",
@@ -2217,9 +2217,14 @@ module.exports = {
     bankTotalSupply: 'ECOin Hornidura Guztizkoa',
     bankEcoinHours: 'ECOin Orduak',
     bankHoursOfWork: 'lan orduak',
+    bankUnitMs: "ms",
+    bankUnitSeconds: "segundo",
+    bankUnitMinutes: "minutu",
+    bankUnitDays: "egun",
     bankExchangeNoData: 'Ez dago daturik eskuragarri',
     bankExchangeIndex: 'ECOin Balioa (1h)',
-    bankInflation: 'ECOin Inflazioa',
+    bankInflation: 'ECOin Inflazioa (anual)',
+    bankInflationMonthly: 'ECOin Inflazioa (mensual)',
     bankCurrentSupply: 'ECOin Oraingo Hornidura',
     bankingSyncStatus: 'ECOin Egoera',
     bankingSyncStatusSynced: 'Sinkronizatuta',
@@ -3070,6 +3075,38 @@ module.exports = {
     favoritesFilterTorrents: "TORRENTAK",
     tribeSectionTorrents: "TORRENTAK",
     tribeCreateTorrent: "Torrenta Igo",
-    tribeMediaTypeTorrent: "Torrenta"
+    tribeMediaTypeTorrent: "Torrenta",
+    settingsWishTitle: "Nahia",
+    settingsWishDesc: "Konfiguratu zure Avatarraren nahia.",
+    settingsWishWhole: "Multiverse",
+    settingsWishMutuals: "Only mutual-support",
+    settingsPmVisibilityTitle: "Mezu Pribatuak",
+    settingsPmVisibilityDesc: "Konfiguratu sarean izan nahi duzun mezu pribatuen esposizio maila.",
+    settingsPmVisibilityWhole: "Multiverse",
+    settingsPmVisibilityMutuals: "Only mutual-support",
+    pmMutualNotice: "Bidalketa UX babes bat da. Jasotzea arreta-iragazkia.",
+    pmBlockedNonMutual: "Elkarrekiko laguntza duten biztanleei bakarrik bidal diezaiekezu.",
+    inhabitantsPendingFollowsTitle: "Onespen zain dauden eskaerak",
+    inhabitantsPendingAccept: "Onartu",
+    inhabitantsPendingReject: "Ukatu",
+    bxEncrypted: "ZIFRATUA",
+    bxEncryptedHexLabel: "Testu zifratua (aurrebista)",
+    tribeSectionGovernance: "GOBERNANTZA",
+    tribeSubStatusPublic: "PUBLIKOA",
+    tribeSubStatusPrivate: "PRIBATUA",
+    tribeSubInheritedPrivate: "PRIBATUA (tribu nagusitik heredatua)",
+    tribePadInviteRequired: "Ez duzu pada atzipena. Eskatu gonbidapena.",
+    tribeChatInviteRequired: "Ez duzu txata atzipena. Eskatu gonbidapena.",
+    tribeGovernanceDesc: "Tribu honen barne gobernantza.",
+    tribeGovernanceNoGov: "Ez dago gobernu aktiborik",
+    tribeGovernanceNoGovDesc: "Tribu honek ez du oraindik gobernurik aukeratu.",
+    tribeGovernanceAlreadyPublished: "Tribu honek jada hautagai ireki bat du.",
+    tribeGovernanceProposeInternal: "Proposatu barne hautagaia",
+    tribeGovernanceInternalCandidatures: "Barne hautagaiak",
+    tribeGovernanceNoCandidatures: "Ez dago hautagai irekirik.",
+    tribeGovernanceAddRule: "Gehitu araua",
+    tribeGovernanceNoRules: "Arauerik ez oraindik.",
+    tribeGovernanceNoLeaders: "Liderrik ez oraindik aukeratu.",
+    tribeGovernanceComingSoon: "Laster gobernantza moduluan."
   }
 };

+ 40 - 3
src/client/assets/translations/oasis_fr.js

@@ -2156,7 +2156,7 @@ module.exports = {
     bankingUserEngagementScore: "Score KARMA",
     bankingFutureUBI: "UBI",
     pubIdTitle: "PUB Wallet",
-    pubIdDescription: "Set the PUB wallet URL. This will be used for PUB transactions (including the UBI).",
+    pubIdDescription: "Set the PUB OASIS ID. This will be used for PUB transactions (including the UBI).",
     pubIdLabel: "PUB ID",
     pubIdSave: "Save configuration",
     pubIdPlaceholder: "@PUB_ID.ed25519",
@@ -2242,9 +2242,14 @@ module.exports = {
     bankTotalSupply: 'Offre totale d’ECOin',
     bankEcoinHours: 'Équivalence d’ECOin en temps',
     bankHoursOfWork: 'heures',
+    bankUnitMs: "ms",
+    bankUnitSeconds: "secondes",
+    bankUnitMinutes: "minutes",
+    bankUnitDays: "jours",
     bankExchangeNoData: 'Aucune donnée disponible',
     bankExchangeIndex: 'Valeur d’ECOin (1h)',
-    bankInflation: 'Inflation d’ECOin',
+    bankInflation: 'Inflation d’ECOin (anual)',
+    bankInflationMonthly: 'Inflation d’ECOin (mensual)',
     bankCurrentSupply: 'Offre actuelle d’ECOin',
     bankingSyncStatus: 'État d’ECOin',
     bankingSyncStatusSynced: 'Synchronisé',
@@ -3098,6 +3103,38 @@ module.exports = {
     favoritesFilterTorrents: "TORRENTS",
     tribeSectionTorrents: "TORRENTS",
     tribeCreateTorrent: "Téléverser un Torrent",
-    tribeMediaTypeTorrent: "Torrent"
+    tribeMediaTypeTorrent: "Torrent",
+    settingsWishTitle: "Souhait",
+    settingsWishDesc: "Configurez le souhait de votre Avatar.",
+    settingsWishWhole: "Multiverse",
+    settingsWishMutuals: "Only mutual-support",
+    settingsPmVisibilityTitle: "Messages Privés",
+    settingsPmVisibilityDesc: "Configurez votre niveau d'exposition aux messages privés.",
+    settingsPmVisibilityWhole: "Multiverse",
+    settingsPmVisibilityMutuals: "Only mutual-support",
+    pmMutualNotice: "L'envoi est un garde-fou UX. La réception est un filtre d'attention.",
+    pmBlockedNonMutual: "Vous ne pouvez envoyer des MP qu'aux habitants en soutien mutuel.",
+    inhabitantsPendingFollowsTitle: "Demandes de soutien en attente",
+    inhabitantsPendingAccept: "Accepter",
+    inhabitantsPendingReject: "Refuser",
+    bxEncrypted: "CHIFFRÉ",
+    bxEncryptedHexLabel: "Texte chiffré (aperçu)",
+    tribeSectionGovernance: "GOUVERNANCE",
+    tribeSubStatusPublic: "PUBLIQUE",
+    tribeSubStatusPrivate: "PRIVÉE",
+    tribeSubInheritedPrivate: "PRIVÉE (héritée de la tribu principale)",
+    tribePadInviteRequired: "Vous n'avez pas accès au pad. Demandez une invitation.",
+    tribeChatInviteRequired: "Vous n'avez pas accès au chat. Demandez une invitation.",
+    tribeGovernanceDesc: "Gouvernance interne de cette tribu.",
+    tribeGovernanceNoGov: "Pas de gouvernement actif",
+    tribeGovernanceNoGovDesc: "Cette tribu n'a pas encore de gouvernement.",
+    tribeGovernanceAlreadyPublished: "Cette tribu a déjà une candidature ouverte dans le cycle actuel.",
+    tribeGovernanceProposeInternal: "Proposer une candidature interne",
+    tribeGovernanceInternalCandidatures: "Candidatures internes",
+    tribeGovernanceNoCandidatures: "Aucune candidature ouverte.",
+    tribeGovernanceAddRule: "Ajouter une règle",
+    tribeGovernanceNoRules: "Aucune règle pour le moment.",
+    tribeGovernanceNoLeaders: "Aucun leader élu.",
+    tribeGovernanceComingSoon: "Bientôt dans le module de gouvernance."
     }
 };

+ 40 - 3
src/client/assets/translations/oasis_hi.js

@@ -2160,7 +2160,7 @@ module.exports = {
     bankNotRemovableOasis: 'पते स्थानीय रूप से नहीं हटाए जा सकते',
     bankingFutureUBI: "UBI",
     pubIdTitle: "PUB Wallet",
-    pubIdDescription: "Set the PUB wallet URL. This will be used for PUB transactions (including the UBI).",
+    pubIdDescription: "Set the PUB OASIS ID. This will be used for PUB transactions (including the UBI).",
     pubIdLabel: "PUB ID",
     pubIdSave: "Save configuration",
     pubIdPlaceholder: "@PUB_ID.ed25519",
@@ -2246,9 +2246,14 @@ module.exports = {
     bankTotalSupply: 'ECOin कुल आपूर्ति',
     bankEcoinHours: "ECOin समय समतुल्यता",
     bankHoursOfWork: 'घंटे',
+    bankUnitMs: "मि.से.",
+    bankUnitSeconds: "सेकंड",
+    bankUnitMinutes: "मिनट",
+    bankUnitDays: "दिन",
     bankExchangeNoData: 'कोई डेटा उपलब्ध नहीं',
     bankExchangeIndex: 'ECOin मूल्य (1h)',
-    bankInflation: 'ECOin मुद्रास्फीति',
+    bankInflation: 'ECOin मुद्रास्फीति (anual)',
+    bankInflationMonthly: 'ECOin मुद्रास्फीति (mensual)',
     bankCurrentSupply: 'ECOin वर्तमान आपूर्ति',
     bankingSyncStatus: 'ECOin स्थिति',
     bankingSyncStatusSynced: 'सिंक किया गया',
@@ -3100,6 +3105,38 @@ module.exports = {
     favoritesFilterTorrents: "टॉरेंट",
     tribeSectionTorrents: "टॉरेंट",
     tribeCreateTorrent: "टॉरेंट अपलोड करें",
-    tribeMediaTypeTorrent: "टॉरेंट"
+    tribeMediaTypeTorrent: "टॉरेंट",
+    settingsWishTitle: "इच्छा",
+    settingsWishDesc: "अपने अवतार की इच्छा कॉन्फ़िगर करें।",
+    settingsWishWhole: "Multiverse",
+    settingsWishMutuals: "Only mutual-support",
+    settingsPmVisibilityTitle: "निजी संदेश",
+    settingsPmVisibilityDesc: "नेटवर्क में निजी संदेश एक्सपोज़र स्तर कॉन्फ़िगर करें।",
+    settingsPmVisibilityWhole: "Multiverse",
+    settingsPmVisibilityMutuals: "Only mutual-support",
+    pmMutualNotice: "आउटबाउंड UX गार्डरेल है। इनबाउंड ध्यान फ़िल्टर है।",
+    pmBlockedNonMutual: "केवल पारस्परिक समर्थन वाले निवासियों को PM भेज सकते हैं।",
+    inhabitantsPendingFollowsTitle: "लंबित समर्थन अनुरोध",
+    inhabitantsPendingAccept: "स्वीकार",
+    inhabitantsPendingReject: "अस्वीकार",
+    bxEncrypted: "एन्क्रिप्टेड",
+    bxEncryptedHexLabel: "सिफरटेक्स्ट (पूर्वावलोकन)",
+    tribeSectionGovernance: "शासन",
+    tribeSubStatusPublic: "सार्वजनिक",
+    tribeSubStatusPrivate: "निजी",
+    tribeSubInheritedPrivate: "निजी (मुख्य जनजाति से विरासत)",
+    tribePadInviteRequired: "पैड तक पहुंच नहीं। निमंत्रण मांगें।",
+    tribeChatInviteRequired: "चैट तक पहुंच नहीं। निमंत्रण मांगें।",
+    tribeGovernanceDesc: "इस जनजाति का आंतरिक शासन।",
+    tribeGovernanceNoGov: "कोई सक्रिय सरकार नहीं",
+    tribeGovernanceNoGovDesc: "इस जनजाति ने अभी सरकार नहीं चुनी।",
+    tribeGovernanceAlreadyPublished: "इस जनजाति की पहले से ही एक खुली उम्मीदवारी है।",
+    tribeGovernanceProposeInternal: "आंतरिक उम्मीदवारी प्रस्तावित करें",
+    tribeGovernanceInternalCandidatures: "आंतरिक उम्मीदवारियां",
+    tribeGovernanceNoCandidatures: "कोई खुली उम्मीदवारी नहीं।",
+    tribeGovernanceAddRule: "नियम जोड़ें",
+    tribeGovernanceNoRules: "अभी कोई नियम नहीं।",
+    tribeGovernanceNoLeaders: "कोई नेता निर्वाचित नहीं।",
+    tribeGovernanceComingSoon: "शासन मॉड्यूल में जल्द ही।"
     }
 };

+ 40 - 3
src/client/assets/translations/oasis_it.js

@@ -2160,7 +2160,7 @@ module.exports = {
     bankNotRemovableOasis: 'Gli indirizzi non possono essere rimossi localmente',
     bankingFutureUBI: "UBI",
     pubIdTitle: "PUB Wallet",
-    pubIdDescription: "Set the PUB wallet URL. This will be used for PUB transactions (including the UBI).",
+    pubIdDescription: "Set the PUB OASIS ID. This will be used for PUB transactions (including the UBI).",
     pubIdLabel: "PUB ID",
     pubIdSave: "Save configuration",
     pubIdPlaceholder: "@PUB_ID.ed25519",
@@ -2246,9 +2246,14 @@ module.exports = {
     bankTotalSupply: 'Offerta totale ECOin',
     bankEcoinHours: "Equivalenza ECOin in tempo",
     bankHoursOfWork: 'ore',
+    bankUnitMs: "ms",
+    bankUnitSeconds: "secondi",
+    bankUnitMinutes: "minuti",
+    bankUnitDays: "giorni",
     bankExchangeNoData: 'Nessun dato disponibile',
     bankExchangeIndex: 'Valore ECOin (1h)',
-    bankInflation: 'Inflazione ECOin',
+    bankInflation: 'Inflazione ECOin (anual)',
+    bankInflationMonthly: 'Inflazione ECOin (mensual)',
     bankCurrentSupply: 'Offerta attuale ECOin',
     bankingSyncStatus: 'Stato ECOin',
     bankingSyncStatusSynced: 'Sincronizzato',
@@ -3101,6 +3106,38 @@ module.exports = {
     favoritesFilterTorrents: "TORRENT",
     tribeSectionTorrents: "TORRENT",
     tribeCreateTorrent: "Carica Torrent",
-    tribeMediaTypeTorrent: "Torrent"
+    tribeMediaTypeTorrent: "Torrent",
+    settingsWishTitle: "Desiderio",
+    settingsWishDesc: "Configura il desiderio del tuo Avatar.",
+    settingsWishWhole: "Multiverse",
+    settingsWishMutuals: "Only mutual-support",
+    settingsPmVisibilityTitle: "Messaggi Privati",
+    settingsPmVisibilityDesc: "Configura il livello di esposizione ai messaggi privati.",
+    settingsPmVisibilityWhole: "Multiverse",
+    settingsPmVisibilityMutuals: "Only mutual-support",
+    pmMutualNotice: "L'invio è un guardrail UX. La ricezione è un filtro di attenzione.",
+    pmBlockedNonMutual: "Puoi inviare MP solo ad abitanti in supporto reciproco.",
+    inhabitantsPendingFollowsTitle: "Richieste di supporto in attesa",
+    inhabitantsPendingAccept: "Accetta",
+    inhabitantsPendingReject: "Rifiuta",
+    bxEncrypted: "CRITTOGRAFATO",
+    bxEncryptedHexLabel: "Testo cifrato (anteprima)",
+    tribeSectionGovernance: "GOVERNANCE",
+    tribeSubStatusPublic: "PUBBLICA",
+    tribeSubStatusPrivate: "PRIVATA",
+    tribeSubInheritedPrivate: "PRIVATA (ereditata dalla tribù principale)",
+    tribePadInviteRequired: "Non hai accesso al pad. Richiedi un invito.",
+    tribeChatInviteRequired: "Non hai accesso alla chat. Richiedi un invito.",
+    tribeGovernanceDesc: "Governance interna di questa tribù.",
+    tribeGovernanceNoGov: "Nessun governo attivo",
+    tribeGovernanceNoGovDesc: "Questa tribù non ha ancora eletto un governo.",
+    tribeGovernanceAlreadyPublished: "Questa tribù ha già una candidatura aperta.",
+    tribeGovernanceProposeInternal: "Proponi candidatura interna",
+    tribeGovernanceInternalCandidatures: "Candidature interne",
+    tribeGovernanceNoCandidatures: "Nessuna candidatura aperta.",
+    tribeGovernanceAddRule: "Aggiungi regola",
+    tribeGovernanceNoRules: "Nessuna regola.",
+    tribeGovernanceNoLeaders: "Nessun leader eletto.",
+    tribeGovernanceComingSoon: "Presto nel modulo governance."
     }
 };

+ 40 - 3
src/client/assets/translations/oasis_pt.js

@@ -2160,7 +2160,7 @@ module.exports = {
     bankNotRemovableOasis: 'Addresses cannot be removed locally',
     bankingFutureUBI: "UBI",
     pubIdTitle: "PUB Wallet",
-    pubIdDescription: "Set the PUB wallet URL. This will be used for PUB transactions (including the UBI).",
+    pubIdDescription: "Set the PUB OASIS ID. This will be used for PUB transactions (including the UBI).",
     pubIdLabel: "PUB ID",
     pubIdSave: "Save configuration",
     pubIdPlaceholder: "@PUB_ID.ed25519",
@@ -2246,9 +2246,14 @@ module.exports = {
     bankTotalSupply: 'ECOin Total Supply',
     bankEcoinHours: "Equivalência ECOin em tempo",
     bankHoursOfWork: 'hours',
+    bankUnitMs: "ms",
+    bankUnitSeconds: "segundos",
+    bankUnitMinutes: "minutos",
+    bankUnitDays: "dias",
     bankExchangeNoData: 'No data available',
     bankExchangeIndex: 'ECOin Value (1h)',
-    bankInflation: 'ECOin Inflation',
+    bankInflation: 'ECOin Inflation (anual)',
+    bankInflationMonthly: 'ECOin Inflation (mensual)',
     bankCurrentSupply: 'ECOin Current Supply',
     bankingSyncStatus: 'ECOin Status',
     bankingSyncStatusSynced: 'Synced',
@@ -3101,6 +3106,38 @@ module.exports = {
     favoritesFilterTorrents: "TORRENTS",
     tribeSectionTorrents: "TORRENTS",
     tribeCreateTorrent: "Enviar Torrent",
-    tribeMediaTypeTorrent: "Torrent"
+    tribeMediaTypeTorrent: "Torrent",
+    settingsWishTitle: "Desejo",
+    settingsWishDesc: "Configure o desejo do seu Avatar.",
+    settingsWishWhole: "Multiverse",
+    settingsWishMutuals: "Only mutual-support",
+    settingsPmVisibilityTitle: "Mensagens Privadas",
+    settingsPmVisibilityDesc: "Configure seu nível de exposição a mensagens privadas.",
+    settingsPmVisibilityWhole: "Multiverse",
+    settingsPmVisibilityMutuals: "Only mutual-support",
+    pmMutualNotice: "O envio é uma barreira de UX. O recebimento é um filtro de atenção.",
+    pmBlockedNonMutual: "Só pode enviar MPs a habitantes em apoio mútuo.",
+    inhabitantsPendingFollowsTitle: "Pedidos de apoio pendentes",
+    inhabitantsPendingAccept: "Aceitar",
+    inhabitantsPendingReject: "Rejeitar",
+    bxEncrypted: "CRIPTOGRAFADO",
+    bxEncryptedHexLabel: "Texto cifrado (pré-visualização)",
+    tribeSectionGovernance: "GOVERNANÇA",
+    tribeSubStatusPublic: "PÚBLICA",
+    tribeSubStatusPrivate: "PRIVADA",
+    tribeSubInheritedPrivate: "PRIVADA (herdada da tribo principal)",
+    tribePadInviteRequired: "Sem acesso ao pad. Peça um convite.",
+    tribeChatInviteRequired: "Sem acesso ao chat. Peça um convite.",
+    tribeGovernanceDesc: "Governança interna desta tribo.",
+    tribeGovernanceNoGov: "Sem governo ativo",
+    tribeGovernanceNoGovDesc: "Esta tribo ainda não elegeu governo.",
+    tribeGovernanceAlreadyPublished: "Esta tribo já tem candidatura aberta.",
+    tribeGovernanceProposeInternal: "Propor candidatura interna",
+    tribeGovernanceInternalCandidatures: "Candidaturas internas",
+    tribeGovernanceNoCandidatures: "Sem candidaturas abertas.",
+    tribeGovernanceAddRule: "Adicionar regra",
+    tribeGovernanceNoRules: "Sem regras.",
+    tribeGovernanceNoLeaders: "Sem líderes eleitos.",
+    tribeGovernanceComingSoon: "Em breve no módulo de governança."
     }
 };

+ 40 - 3
src/client/assets/translations/oasis_ru.js

@@ -2124,7 +2124,7 @@ module.exports = {
     bankNotRemovableOasis: "Адреса нельзя удалить локально",
     bankingFutureUBI: "UBI",
     pubIdTitle: "PUB Wallet",
-    pubIdDescription: "Set the PUB wallet URL. This will be used for PUB transactions (including the UBI).",
+    pubIdDescription: "Set the PUB OASIS ID. This will be used for PUB transactions (including the UBI).",
     pubIdLabel: "PUB ID",
     pubIdSave: "Save configuration",
     pubIdPlaceholder: "@PUB_ID.ed25519",
@@ -2210,9 +2210,14 @@ module.exports = {
     bankTotalSupply: "Общий объём ECOin",
     bankEcoinHours: "Эквивалент ECOin во времени",
     bankHoursOfWork: "часов",
+    bankUnitMs: "мс",
+    bankUnitSeconds: "секунды",
+    bankUnitMinutes: "минуты",
+    bankUnitDays: "дни",
     bankExchangeNoData: "Данные недоступны",
     bankExchangeIndex: "Стоимость ECOin (1ч)",
-    bankInflation: "Инфляция ECOin",
+    bankInflation: "Инфляция ECOin (anual)",
+    bankInflationMonthly: "Инфляция ECOin (mensual)",
     bankCurrentSupply: "Текущий объём ECOin",
     bankingSyncStatus: "Статус ECOin",
     bankingSyncStatusSynced: "Синхронизирован",
@@ -3063,6 +3068,38 @@ module.exports = {
     favoritesFilterTorrents: "ТОРРЕНТЫ",
     tribeSectionTorrents: "ТОРРЕНТЫ",
     tribeCreateTorrent: "Загрузить Торрент",
-    tribeMediaTypeTorrent: "Торрент"
+    tribeMediaTypeTorrent: "Торрент",
+    settingsWishTitle: "Желание",
+    settingsWishDesc: "Настройте желание вашего Аватара.",
+    settingsWishWhole: "Multiverse",
+    settingsWishMutuals: "Only mutual-support",
+    settingsPmVisibilityTitle: "Личные сообщения",
+    settingsPmVisibilityDesc: "Настройте уровень открытости личных сообщений.",
+    settingsPmVisibilityWhole: "Multiverse",
+    settingsPmVisibilityMutuals: "Only mutual-support",
+    pmMutualNotice: "Исходящие — защита UX. Входящие — фильтр внимания.",
+    pmBlockedNonMutual: "Личные сообщения можно отправлять только жителям со взаимной поддержкой.",
+    inhabitantsPendingFollowsTitle: "Ожидающие запросы на поддержку",
+    inhabitantsPendingAccept: "Принять",
+    inhabitantsPendingReject: "Отклонить",
+    bxEncrypted: "ЗАШИФРОВАНО",
+    bxEncryptedHexLabel: "Шифротекст (превью)",
+    tribeSectionGovernance: "УПРАВЛЕНИЕ",
+    tribeSubStatusPublic: "ПУБЛИЧНАЯ",
+    tribeSubStatusPrivate: "ЧАСТНАЯ",
+    tribeSubInheritedPrivate: "ЧАСТНАЯ (унаследовано от главного племени)",
+    tribePadInviteRequired: "Нет доступа к паду. Запросите приглашение.",
+    tribeChatInviteRequired: "Нет доступа к чату. Запросите приглашение.",
+    tribeGovernanceDesc: "Внутреннее управление этим племенем.",
+    tribeGovernanceNoGov: "Нет активного правительства",
+    tribeGovernanceNoGovDesc: "Это племя ещё не выбрало правительство.",
+    tribeGovernanceAlreadyPublished: "У этого племени уже открытая кандидатура.",
+    tribeGovernanceProposeInternal: "Предложить внутреннюю кандидатуру",
+    tribeGovernanceInternalCandidatures: "Внутренние кандидатуры",
+    tribeGovernanceNoCandidatures: "Нет открытых кандидатур.",
+    tribeGovernanceAddRule: "Добавить правило",
+    tribeGovernanceNoRules: "Правил пока нет.",
+    tribeGovernanceNoLeaders: "Лидеры не избраны.",
+    tribeGovernanceComingSoon: "Скоро в модуле управления."
     }
 };

+ 40 - 3
src/client/assets/translations/oasis_zh.js

@@ -2161,7 +2161,7 @@ module.exports = {
     bankNotRemovableOasis: '无法在本地移除地址',
     bankingFutureUBI: "UBI",
     pubIdTitle: "PUB Wallet",
-    pubIdDescription: "Set the PUB wallet URL. This will be used for PUB transactions (including the UBI).",
+    pubIdDescription: "Set the PUB OASIS ID. This will be used for PUB transactions (including the UBI).",
     pubIdLabel: "PUB ID",
     pubIdSave: "Save configuration",
     pubIdPlaceholder: "@PUB_ID.ed25519",
@@ -2247,9 +2247,14 @@ module.exports = {
     bankTotalSupply: 'ECOin 总供应量',
     bankEcoinHours: "ECOin 时间等价",
     bankHoursOfWork: '小时',
+    bankUnitMs: "毫秒",
+    bankUnitSeconds: "秒",
+    bankUnitMinutes: "分钟",
+    bankUnitDays: "天",
     bankExchangeNoData: '无可用数据',
     bankExchangeIndex: 'ECOin 价值(1小时)',
-    bankInflation: 'ECOin 通胀率',
+    bankInflation: 'ECOin 通胀率 (anual)',
+    bankInflationMonthly: 'ECOin 通胀率 (mensual)',
     bankCurrentSupply: 'ECOin 当前供应量',
     bankingSyncStatus: 'ECOin 状态',
     bankingSyncStatusSynced: '已同步',
@@ -3101,6 +3106,38 @@ module.exports = {
     favoritesFilterTorrents: "种子",
     tribeSectionTorrents: "种子",
     tribeCreateTorrent: "上传种子",
-    tribeMediaTypeTorrent: "种子"
+    tribeMediaTypeTorrent: "种子",
+    settingsWishTitle: "愿望",
+    settingsWishDesc: "配置您的头像愿望。",
+    settingsWishWhole: "Multiverse",
+    settingsWishMutuals: "Only mutual-support",
+    settingsPmVisibilityTitle: "私信",
+    settingsPmVisibilityDesc: "配置您希望在网络中的私信曝光级别。",
+    settingsPmVisibilityWhole: "Multiverse",
+    settingsPmVisibilityMutuals: "Only mutual-support",
+    pmMutualNotice: "出站是 UX 保护。入站是关注过滤器。",
+    pmBlockedNonMutual: "只能向相互支持的居民发送私信。",
+    inhabitantsPendingFollowsTitle: "待处理的支持请求",
+    inhabitantsPendingAccept: "接受",
+    inhabitantsPendingReject: "拒绝",
+    bxEncrypted: "已加密",
+    bxEncryptedHexLabel: "密文(预览)",
+    tribeSectionGovernance: "治理",
+    tribeSubStatusPublic: "公开",
+    tribeSubStatusPrivate: "私密",
+    tribeSubInheritedPrivate: "私密(继承自主部落)",
+    tribePadInviteRequired: "您无权访问 pad。请求邀请。",
+    tribeChatInviteRequired: "您无权访问聊天。请求邀请。",
+    tribeGovernanceDesc: "此部落的内部治理。",
+    tribeGovernanceNoGov: "无活跃政府",
+    tribeGovernanceNoGovDesc: "此部落尚未选举政府。",
+    tribeGovernanceAlreadyPublished: "此部落在当前周期已有候选人。",
+    tribeGovernanceProposeInternal: "提议内部候选人",
+    tribeGovernanceInternalCandidatures: "内部候选人",
+    tribeGovernanceNoCandidatures: "无开放候选人。",
+    tribeGovernanceAddRule: "添加规则",
+    tribeGovernanceNoRules: "暂无规则。",
+    tribeGovernanceNoLeaders: "尚无领导人被选出。",
+    tribeGovernanceComingSoon: "治理模块即将推出。"
     }
 };

+ 1 - 31
src/configs/banking-epochs.json

@@ -1,31 +1 @@
-[
-  {
-    "id": "2026-14",
-    "pool": 0,
-    "weightsSum": 1.91,
-    "rules": {
-      "epochKind": "WEEKLY",
-      "alpha": 0.2,
-      "reserveMin": 500,
-      "capPerEpoch": 2000,
-      "caps": {
-        "M_max": 3,
-        "T_max": 1.5,
-        "P_max": 2,
-        "cap_user_epoch": 50,
-        "w_min": 0.2,
-        "w_max": 6
-      },
-      "coeffs": {
-        "a1": 0.6,
-        "a2": 0.4,
-        "a3": 0.3,
-        "a4": 0.5,
-        "b1": 0.5,
-        "b2": 1
-      },
-      "graceDays": 14
-    },
-    "hash": "60b41f21ce26af434a87ab74a006d607805661c00f6740610d68a97e972a1720"
-  }
-]
+[]

+ 7 - 5
src/configs/config-manager.js

@@ -58,9 +58,7 @@ if (!fs.existsSync(configFilePath)) {
       "fee": "5"
     },
     "walletPub": {
-      "url": "",
-      "user": "",
-      "pass": ""
+      "pubId": ""
     },
     "ai": {
       "prompt": "Provide an informative and precise response."
@@ -70,14 +68,18 @@ if (!fs.existsSync(configFilePath)) {
     },
     "homePage": "activity",
     "language": "en",
-    "pubId": ""
+    "wish": "whole",
+    "pmVisibility": "whole"
   };
   fs.writeFileSync(configFilePath, JSON.stringify(defaultConfig, null, 2));
 }
 
 const getConfig = () => {
   const configData = fs.readFileSync(configFilePath);
-  return JSON.parse(configData);
+  const cfg = JSON.parse(configData);
+  if (cfg.wish !== 'whole' && cfg.wish !== 'mutuals') cfg.wish = 'whole';
+  if (cfg.pmVisibility !== 'whole' && cfg.pmVisibility !== 'mutuals') cfg.pmVisibility = 'whole';
+  return cfg;
 };
 
 const saveConfig = (newConfig) => {

+ 1 - 0
src/configs/follow_state.json

@@ -0,0 +1 @@
+{"pending":[],"accepted":[],"lastAcceptMs":0}

+ 1 - 1
src/configs/media-favorites.json

@@ -10,4 +10,4 @@
   "shops": [],
   "torrents": [],
   "videos": []
-}
+}

+ 4 - 5
src/configs/oasis-config.json

@@ -54,9 +54,7 @@
     "fee": "5"
   },
   "walletPub": {
-    "url": "",
-    "user": "",
-    "pass": ""
+    "pubId": ""
   },
   "ai": {
     "prompt": "Provide an informative and precise response."
@@ -66,5 +64,6 @@
   },
   "homePage": "activity",
   "language": "en",
-  "pubId": ""
-}
+  "wish": "whole",
+  "pmVisibility": "whole"
+}

+ 14 - 147
src/models/activity_model.js

@@ -224,149 +224,6 @@ module.exports = ({ cooler }) => {
             }
           }
 
-          if (type === 'tribe') {
-            const baseId = tip.id;
-            const baseTitle = (tip.content && tip.content.title) || '';
-            const isAnonymous = tip.content && typeof tip.content.isAnonymous === 'boolean' ? tip.content.isAnonymous : false;
-            if (isAnonymous) continue;
-
-            const uniq = (xs) => Array.from(new Set((Array.isArray(xs) ? xs : []).filter(x => typeof x === 'string' && x.trim().length)));
-            const toSet = (xs) => new Set(uniq(xs));
-            const excerpt2 = (s, max = 220) => {
-              const t = String(s || '').replace(/\s+/g, ' ').trim();
-              return t.length > max ? t.slice(0, max - 1) + '…' : t;
-            };
-            const feedMap = (feed) => {
-              const m = new Map();
-              for (const it of (Array.isArray(feed) ? feed : [])) {
-                if (!it || typeof it !== 'object') continue;
-                const id = typeof it.id === 'string' || typeof it.id === 'number' ? String(it.id) : '';
-                if (!id) continue;
-                m.set(id, it);
-              }
-              return m;
-            };
-
-            const sorted = arr
-              .filter(a => a.type === 'tribe' && a.content && typeof a.content === 'object')
-              .sort((a, b) => (a.ts || 0) - (b.ts || 0));
-
-            let prev = null;
-
-            for (const ev of sorted) {
-              if (!prev) { prev = ev; continue; }
-
-              const prevMembers = toSet(prev.content.members);
-              const curMembers = toSet(ev.content.members);
-              const added = Array.from(curMembers).filter(x => !prevMembers.has(x));
-              const removed = Array.from(prevMembers).filter(x => !curMembers.has(x));
-
-              for (const member of added) {
-                const overlayId = `${ev.id}:tribeJoin:${member}`;
-                idToAction.set(overlayId, {
-                  id: overlayId,
-                  author: member,
-                  ts: ev.ts,
-                  type: 'tribeJoin',
-                  content: { type: 'tribeJoin', tribeId: baseId, tribeTitle: baseTitle, isAnonymous, member }
-                });
-                idToTipId.set(overlayId, overlayId);
-              }
-
-              for (const member of removed) {
-                const overlayId = `${ev.id}:tribeLeave:${member}`;
-                idToAction.set(overlayId, {
-                  id: overlayId,
-                  author: member,
-                  ts: ev.ts,
-                  type: 'tribeLeave',
-                  content: { type: 'tribeLeave', tribeId: baseId, tribeTitle: baseTitle, isAnonymous, member }
-                });
-                idToTipId.set(overlayId, overlayId);
-              }
-
-              const prevFeed = feedMap(prev.content.feed);
-              const curFeed = feedMap(ev.content.feed);
-
-              for (const [fid, item] of curFeed.entries()) {
-                if (prevFeed.has(fid)) continue;
-                const feedAuthor = (item && typeof item.author === 'string' && item.author.trim().length) ? item.author : ev.author;
-                const overlayId = `${ev.id}:tribeFeedPost:${fid}:${feedAuthor}`;
-                idToAction.set(overlayId, {
-                  id: overlayId,
-                  author: feedAuthor,
-                  ts: ev.ts,
-                  type: 'tribeFeedPost',
-                  content: {
-                    type: 'tribeFeedPost',
-                    tribeId: baseId,
-                    tribeTitle: baseTitle,
-                    isAnonymous,
-                    feedId: fid,
-                    date: item.date || ev.ts,
-                    text: excerpt2(item.message || '')
-                  }
-                });
-                idToTipId.set(overlayId, overlayId);
-              }
-
-              for (const [fid, curItem] of curFeed.entries()) {
-                const prevItem = prevFeed.get(fid);
-                if (!prevItem) continue;
-
-                const pInh = toSet(prevItem.refeeds_inhabitants);
-                const cInh = toSet(curItem.refeeds_inhabitants);
-                const newInh = Array.from(cInh).filter(x => !pInh.has(x));
-
-                const curRefeeds = Number(curItem.refeeds || 0);
-                const prevRefeeds = Number(prevItem.refeeds || 0);
-
-                const postText = excerpt2(curItem.message || '');
-
-                if (newInh.length) {
-                  for (const who of newInh) {
-                    const overlayId = `${ev.id}:tribeFeedRefeed:${fid}:${who}`;
-                    idToAction.set(overlayId, {
-                      id: overlayId,
-                      author: who,
-                      ts: ev.ts,
-                      type: 'tribeFeedRefeed',
-                      content: {
-                        type: 'tribeFeedRefeed',
-                        tribeId: baseId,
-                        tribeTitle: baseTitle,
-                        isAnonymous,
-                        feedId: fid,
-                        text: postText
-                      }
-                    });
-                    idToTipId.set(overlayId, overlayId);
-                  }
-                } else if (curRefeeds > prevRefeeds && ev.author) {
-                  const who = ev.author;
-                  const overlayId = `${ev.id}:tribeFeedRefeed:${fid}:${who}`;
-                  idToAction.set(overlayId, {
-                    id: overlayId,
-                    author: who,
-                    ts: ev.ts,
-                    type: 'tribeFeedRefeed',
-                    content: {
-                      type: 'tribeFeedRefeed',
-                      tribeId: baseId,
-                      tribeTitle: baseTitle,
-                      isAnonymous,
-                      feedId: fid,
-                      text: postText
-                    }
-                  });
-                  idToTipId.set(overlayId, overlayId);
-                }
-              }
-
-              prev = ev;
-            }
-          }
-
           continue;
         }
 
@@ -494,9 +351,19 @@ module.exports = ({ cooler }) => {
 
       deduped = Array.from(byKey.values()).map(x => { delete x.__effTs; delete x.__hasImage; return x });
 
-      const tribeInternalTypes = new Set(['tribeLeave', 'tribeFeedPost', 'tribeFeedRefeed', 'tribe-content']);
-      const hiddenTypes = new Set(['padEntry', 'chatMessage', 'calendarDate', 'calendarNote', 'calendarReminderSent', 'feed-action']);
-      const isAllowedTribeActivity = (a) => !tribeInternalTypes.has(a.type);
+      const tribeInternalTypes = new Set(['tribe-content', 'tribeParliamentCandidature', 'tribeParliamentTerm', 'tribeParliamentProposal', 'tribeParliamentRule', 'tribeParliamentLaw', 'tribeParliamentRevocation']);
+      const hiddenTypes = new Set(['padEntry', 'chatMessage', 'calendarDate', 'calendarNote', 'calendarReminderSent', 'feed-action', 'pubBalance']);
+      const isAllowedTribeActivity = (a) => {
+        if (tribeInternalTypes.has(a.type)) return false;
+        const c = a.content || {};
+        if (c.tribeId) return false;
+        if (a.type === 'tribe') {
+          if (c.isAnonymous === true) return false;
+          const isInitial = !c.replaces;
+          if (!isInitial) return false;
+        }
+        return true;
+      };
       const isVisible = (a) => {
         if (hiddenTypes.has(a.type)) return false;
         if (a.type === 'pad' && (a.content || {}).status !== 'OPEN') return false;
@@ -511,7 +378,7 @@ module.exports = ({ cooler }) => {
       if (filter === 'mine') out = deduped.filter(a => a.author === userId && isAllowedTribeActivity(a) && isVisible(a));
       else if (filter === 'recent') { const cutoff = Date.now() - 24 * 60 * 60 * 1000; out = deduped.filter(a => (a.ts || 0) >= cutoff && isAllowedTribeActivity(a) && isVisible(a)) }
       else if (filter === 'all') out = deduped.filter(a => isAllowedTribeActivity(a) && isVisible(a));
-      else if (filter === 'banking') out = deduped.filter(a => a.type === 'bankWallet' || a.type === 'bankClaim');
+      else if (filter === 'banking') out = deduped.filter(a => a.type === 'bankWallet' || a.type === 'bankClaim' || a.type === 'ubiClaim' || a.type === 'ubiclaimresult');
       else if (filter === 'karma') out = deduped.filter(a => a.type === 'karmaScore');
       else if (filter === 'tribe') out = deduped.filter(a => a.type === 'tribe' || String(a.type || '').startsWith('tribe'));
       else if (filter === 'spread') out = deduped.filter(a => a.type === 'spread');

+ 87 - 74
src/models/banking_model.js

@@ -169,18 +169,20 @@ function isValidEcoinAddress(addr) {
 function getWalletCfg(kind) {
   const cfg = getConfig() || {};
   if (kind === "pub") {
-    return cfg.walletPub || cfg.pubWallet || (cfg.pub && cfg.pub.wallet) || null;
+    if (!isPubNode()) return null;
+    return cfg.wallet || null;
   }
   return cfg.wallet || null;
 }
 
 function isPubNode() {
-  const cfg = getWalletCfg("pub");
-  return !!(cfg && cfg.url);
+  const pubId = (getConfig() || {}).walletPub?.pubId || "";
+  const myId = config?.keys?.id || "";
+  return !!pubId && !!myId && pubId === myId;
 }
 
 function getConfiguredPubId() {
-  return (getConfig() || {}).pubId || "";
+  return (getConfig() || {}).walletPub?.pubId || "";
 }
 
 function resolveUserId(maybeId) {
@@ -228,7 +230,7 @@ module.exports = ({ services } = {}) => {
     if (!ssb) return [];
     return new Promise((resolve, reject) =>
       pull(
-        ssb.createLogStream({ limit: getLogLimit() }),
+        ssb.createLogStream({ limit: getLogLimit(), reverse: true }),
         pull.collect((err, arr) => err ? reject(err) : resolve(arr))
       )
     );
@@ -236,8 +238,8 @@ module.exports = ({ services } = {}) => {
 
   async function getWalletFromSSB(userId) {
     const msgs = await scanLogStream();
-    for (let i = msgs.length - 1; i >= 0; i--) {
-      const v = msgs[i].value || {};
+    for (const m of msgs) {
+      const v = m.value || {};
       const c = v.content || {};
       if (v.author === userId && c && c.type === "wallet" && c.coin === "ECO" && typeof c.address === "string") {
         return c.address;
@@ -249,8 +251,8 @@ module.exports = ({ services } = {}) => {
   async function scanAllWalletsSSB() {
     const latest = {};
     const msgs = await scanLogStream();
-    for (let i = msgs.length - 1; i >= 0; i--) {
-      const v = msgs[i].value || {};
+    for (const m of msgs) {
+      const v = m.value || {};
       const c = v.content || {};
       if (c && c.type === "wallet" && c.coin === "ECO" && typeof c.address === "string") {
         if (!latest[v.author]) latest[v.author] = c.address;
@@ -725,8 +727,26 @@ async function getLastPublishedTimestamp(userId) {
     const amount = Number(Math.max(1, Math.min(pv.pool * userW / totalW, capUser)).toFixed(6));
     const ssb = await openSsb();
     if (!ssb || !ssb.publish) throw new Error("ssb_unavailable");
-    const content = { type: "ubiClaim", pubId, amount, epochId, claimedAt: new Date().toISOString() };
-    await new Promise((resolve, reject) => ssb.publish(content, (err, res) => err ? reject(err) : resolve(res)));
+    const now = new Date().toISOString();
+    const transferContent = {
+      type: "transfer",
+      from: pubId,
+      to: uid,
+      concept: `UBI ${epochId} ${uid}`,
+      amount: String(amount),
+      createdAt: now,
+      updatedAt: now,
+      deadline: null,
+      confirmedBy: [pubId],
+      status: "UNCONFIRMED",
+      tags: ["UBI", "PENDING"],
+      opinions: {},
+      opinions_inhabitants: []
+    };
+    const transferRes = await new Promise((resolve, reject) => ssb.publish(transferContent, (err, res) => err ? reject(err) : resolve(res)));
+    const transferId = transferRes?.key || "";
+    const claimContent = { type: "ubiClaim", pubId, amount, epochId, claimedAt: now, transferId };
+    await new Promise((resolve, reject) => ssb.publish(claimContent, (err, res) => err ? reject(err) : resolve(res)));
     return { status: "claimed_pending", amount, epochId };
   }
 
@@ -749,8 +769,8 @@ async function getLastPublishedTimestamp(userId) {
     const pubId = getConfiguredPubId();
     if (!pubId) return 0;
     const msgs = await scanLogStream();
-    for (let i = msgs.length - 1; i >= 0; i--) {
-      const v = msgs[i].value || {};
+    for (const m of msgs) {
+      const v = m.value || {};
       const c = v.content || {};
       if (v.author === pubId && c && c.type === "pubBalance" && c.coin === "ECO") {
         return Number(c.balance) || 0;
@@ -850,6 +870,9 @@ async function getLastPublishedTimestamp(userId) {
     const addresses = await listAddressesMerged();
     const alreadyClaimed = await hasClaimedThisMonth(uid);
     const pubId = getConfiguredPubId();
+    const userAddress = await getUserAddress(uid);
+    const userWalletCfg = getWalletCfg("user") || {};
+    const hasValidWallet = !!(userAddress && isValidEcoinAddress(userAddress) && userWalletCfg.url);
     const summary = {
       userBalance,
       pubBalance,
@@ -860,7 +883,8 @@ async function getLastPublishedTimestamp(userId) {
       futureUBI,
       ubiAvailability: pubBalance > 0 ? "OK" : "NO_FUNDS",
       alreadyClaimed,
-      pubId
+      pubId,
+      hasValidWallet
     };
     const exchange = await calculateEcoinValue();
     return { summary, allocations, epochs, rules: DEFAULT_RULES, addresses, exchange };
@@ -888,71 +912,60 @@ async function getLastPublishedTimestamp(userId) {
     }));
   }
 
+  let genesisTimeCache = null;
+
+  async function getAvgBlockSeconds(blocks) {
+    if (!blocks || blocks < 2) return 0;
+    try {
+      if (!genesisTimeCache) {
+        const h1 = await rpcCall("getblockhash", [1]);
+        if (!h1) return 0;
+        const b1 = await rpcCall("getblock", [h1]);
+        genesisTimeCache = b1?.time || null;
+        if (!genesisTimeCache) return 0;
+      }
+      const hCur = await rpcCall("getblockhash", [blocks]);
+      if (!hCur) return 0;
+      const bCur = await rpcCall("getblock", [hCur]);
+      const curTime = bCur?.time || 0;
+      if (!curTime) return 0;
+      const elapsed = curTime - genesisTimeCache;
+      return elapsed > 0 ? elapsed / (blocks - 1) : 0;
+    } catch (_) { return 0; }
+  }
+
   async function calculateEcoinValue() {
-    let isSynced = false;
+    const totalSupply = 25500000;
     let circulatingSupply = 0;
+    let blocks = 0;
+    let blockValueEco = 0;
+    let isSynced = false;
     try {
-      circulatingSupply = await getCirculatingSupply();
+      const info = await rpcCall("getinfo", []);
+      circulatingSupply = info?.moneysupply || 0;
+      blocks = info?.blocks || 0;
       isSynced = circulatingSupply > 0;
-    } catch (error) {
-      circulatingSupply = 0;
-      isSynced = false;
-    }
-    const totalSupply = 25500000;
-    const ecoValuePerHour = await calculateEcoValuePerHour(circulatingSupply);
-    const ecoInHours = calculateEcoinHours(circulatingSupply, ecoValuePerHour);
-    const inflationFactor = await calculateInflationFactor(circulatingSupply, totalSupply);
+      const mining = await rpcCall("getmininginfo", []);
+      blockValueEco = (mining?.blockvalue || 0) / 1e8;
+    } catch (_) {}
+    const avgSec = await getAvgBlockSeconds(blocks);
+    const ecoValuePerHour = avgSec > 0 ? (3600 / avgSec) * blockValueEco : 0;
+    const maturity = totalSupply > 0 ? circulatingSupply / totalSupply : 0;
+    const ecoTimeMs = maturity * 3600 * 1000;
+    const annualIssuance = ecoValuePerHour * 24 * 365;
+    const inflationFactor = circulatingSupply > 0 ? (annualIssuance / circulatingSupply) * 100 : 0;
+    const inflationMonthly = inflationFactor / 12;
     return {
-      ecoValue: ecoValuePerHour,
-      ecoInHours: Number(ecoInHours.toFixed(2)),
-      totalSupply: totalSupply,
-      inflationFactor: inflationFactor ? Number(inflationFactor.toFixed(2)) : 0,
+      ecoValue: Number(ecoValuePerHour.toFixed(6)),
+      ecoTimeMs: Number(ecoTimeMs.toFixed(3)),
+      totalSupply,
+      inflationFactor: Number(inflationFactor.toFixed(2)),
+      inflationMonthly: Number(inflationMonthly.toFixed(2)),
       currentSupply: circulatingSupply,
-      isSynced: isSynced
+      isSynced
     };
   }
 
-  async function calculateEcoValuePerHour(circulatingSupply) {
-    const issuanceRate = await getIssuanceRate();
-    const inflation = await calculateInflationFactor(circulatingSupply, 25500000);
-    const ecoValuePerHour = (circulatingSupply / 100000) * (1 + inflation / 100);
-    return ecoValuePerHour;
-  }
-
-  function calculateEcoinHours(circulatingSupply, ecoValuePerHour) {
-    const ecoInHours = circulatingSupply / ecoValuePerHour;
-    return ecoInHours;
-  }
-
-  async function calculateInflationFactor(circulatingSupply, totalSupply) {
-    const issuanceRate = await getIssuanceRate();
-    if (circulatingSupply > 0) {
-      const inflationRate = (issuanceRate / circulatingSupply) * 100;
-      return inflationRate;
-    }
-    return 0;
-  }
-
-  async function getIssuanceRate() {
-    try {
-      const result = await rpcCall("getmininginfo", []);
-      const blockValue = result?.blockvalue || 0;
-      const blocks = result?.blocks || 0;
-      return (blockValue / 1e8) * blocks;
-    } catch (error) {
-      return 0.02;
-    }
-  }
-
-  async function getCirculatingSupply() {
-    try {
-      const result = await rpcCall("getinfo", []);
-      return result?.moneysupply || 0;
-    } catch (error) {
-      return 0;
-    }
-  }
-
   async function getBankingData(userId) {
     const ecoValue = await calculateEcoinValue();
     const karmaScore = await getUserEngagementScore(userId);
@@ -1072,13 +1085,13 @@ async function getLastPublishedTimestamp(userId) {
           type: "transfer",
           from: config.keys.id,
           to: claimantId,
-          concept: `UBI ${claimEpoch}`,
+          concept: `UBI ${claimEpoch} ${claimantId}`,
           amount: String(amount),
           createdAt: now,
           updatedAt: now,
-          deadline: new Date(Date.now() + 30 * 86400000).toISOString(),
-          confirmedBy: [claimantId],
-          status: "CLOSED",
+          deadline: null,
+          confirmedBy: [config.keys.id],
+          status: "UNCONFIRMED",
           tags: ["UBI"],
           opinions: {},
           opinions_inhabitants: [],

+ 44 - 27
src/models/calendars_model.js

@@ -473,45 +473,62 @@ module.exports = ({ cooler, pmModel }) => {
         sentMarkers.add(`${c.calendarId}::${c.dateId}`)
       }
 
-      const dueDates = []
+      const tombstoned = new Set()
+      for (const m of messages) {
+        const c = (m.value || {}).content
+        if (c && c.type === "tombstone" && c.target) tombstoned.add(c.target)
+      }
+
+      const dueByCalendar = new Map()
       for (const m of messages) {
+        if (tombstoned.has(m.key)) continue
         const v = m.value || {}
         const c = v.content
         if (!c || c.type !== "calendarDate") continue
         if (new Date(c.date).getTime() > now) continue
         if (sentMarkers.has(`${c.calendarId}::${m.key}`)) continue
-        dueDates.push({ key: m.key, calendarId: c.calendarId, date: c.date, label: c.label || "" })
+        const entry = { key: m.key, calendarId: c.calendarId, date: c.date, label: c.label || "" }
+        const list = dueByCalendar.get(c.calendarId) || []
+        list.push(entry)
+        dueByCalendar.set(c.calendarId, list)
       }
 
-      for (const dd of dueDates) {
+      const publishMarker = (calendarId, dateId) => new Promise((resolve, reject) => {
+        ssbClient.publish({
+          type: "calendarReminderSent",
+          calendarId,
+          dateId,
+          sentAt: new Date().toISOString()
+        }, (err) => err ? reject(err) : resolve())
+      })
+
+      for (const [calendarId, list] of dueByCalendar.entries()) {
         try {
-          const cal = await this.getCalendarById(dd.calendarId)
+          list.sort((a, b) => new Date(b.date) - new Date(a.date))
+          const primary = list[0]
+          const cal = await this.getCalendarById(calendarId)
           if (!cal) continue
           const participants = cal.participants.filter(p => typeof p === "string" && p.length > 0)
-          if (participants.length === 0) continue
-          const notesForDay = await this.getNotesForDate(dd.calendarId, dd.key)
-          const notesBlock = notesForDay.length > 0
-            ? notesForDay.map(n => `  - ${n.text}`).join("\n\n")
-            : "  (no notes)"
-          const subject = `Calendar Reminder: ${cal.title}`
-          const text =
-            `Reminder from: ${cal.author}\n` +
-            `Title: ${cal.title}\n` +
-            `Date: ${dd.label || dd.date}\n\n` +
-            `Notes for this day:\n\n${notesBlock}\n\n` +
-            `Visit Calendar: /calendars/${cal.rootId}`
-          const chunkSize = 6
-          for (let i = 0; i < participants.length; i += chunkSize) {
-            await pmModel.sendMessage(participants.slice(i, i + chunkSize), subject, text)
+          if (participants.length > 0) {
+            const notesForDay = await this.getNotesForDate(calendarId, primary.key)
+            const notesBlock = notesForDay.length > 0
+              ? notesForDay.map(n => `  - ${n.text}`).join("\n\n")
+              : "  (no notes)"
+            const subject = `Calendar Reminder: ${cal.title}`
+            const text =
+              `Reminder from: ${cal.author}\n` +
+              `Title: ${cal.title}\n` +
+              `Date: ${primary.label || primary.date}\n\n` +
+              `Notes for this day:\n\n${notesBlock}\n\n` +
+              `Visit Calendar: /calendars/${cal.rootId}`
+            const chunkSize = 6
+            for (let i = 0; i < participants.length; i += chunkSize) {
+              await pmModel.sendMessage(participants.slice(i, i + chunkSize), subject, text)
+            }
+          }
+          for (const dd of list) {
+            try { await publishMarker(calendarId, dd.key) } catch (_) {}
           }
-          await new Promise((resolve, reject) => {
-            ssbClient.publish({
-              type: "calendarReminderSent",
-              calendarId: dd.calendarId,
-              dateId: dd.key,
-              sentAt: new Date().toISOString()
-            }, (err) => err ? reject(err) : resolve())
-          })
         } catch (_) {}
       }
     }

+ 116 - 1
src/models/parliament_model.js

@@ -1190,6 +1190,109 @@ module.exports = ({ cooler, services = {} }) => {
     return false;
   }
 
+  const tribeReadLog = async () => {
+    const client = await openSsb();
+    return new Promise((resolve, reject) => {
+      pull(
+        client.createLogStream({ limit: logLimit }),
+        pull.collect((err, msgs) => err ? reject(err) : resolve(msgs || []))
+      );
+    });
+  };
+
+  const tribeListByType = async (type, tribeId) => {
+    const msgs = await tribeReadLog();
+    const tomb = new Set();
+    const replaced = new Set();
+    const items = new Map();
+    for (const m of msgs) {
+      const c = m.value?.content; if (!c) continue;
+      if (c.type === 'tombstone' && c.target) { tomb.add(c.target); continue; }
+      if (c.type !== type) continue;
+      if (c.tribeId !== tribeId) continue;
+      if (c.replaces) replaced.add(c.replaces);
+      items.set(m.key, { ...c, id: m.key, _ts: m.value?.timestamp || 0 });
+    }
+    return [...items.values()].filter(it => !tomb.has(it.id) && !replaced.has(it.id));
+  };
+
+  const tribeGetCurrentTerm = async (tribeId) => {
+    const terms = await tribeListByType('tribeParliamentTerm', tribeId);
+    if (terms.length === 0) return null;
+    const now = moment();
+    const active = terms.find(t => moment(t.startAt).isSameOrBefore(now) && moment(t.endAt).isAfter(now));
+    if (active) return active;
+    terms.sort((a, b) => String(b.startAt).localeCompare(String(a.startAt)));
+    return terms[0] || null;
+  };
+
+  const tribeListCandidatures = (tribeId) => tribeListByType('tribeParliamentCandidature', tribeId);
+  const tribeListRules = (tribeId) => tribeListByType('tribeParliamentRule', tribeId);
+
+  const tribePublishCandidature = async ({ tribeId, candidateId, method }) => {
+    const m = String(method || '').toUpperCase();
+    if (!METHODS.includes(m)) throw new Error('Invalid method');
+    if (!tribeId) throw new Error('Missing tribeId');
+    if (!candidateId) throw new Error('Missing candidateId');
+    const term = await tribeGetCurrentTerm(tribeId);
+    const since = term ? term.startAt : moment().subtract(TERM_DAYS, 'days').toISOString();
+    const existing = await tribeListCandidatures(tribeId);
+    const dupe = existing.find(c => c.candidateId === candidateId && new Date(c.createdAt) >= new Date(since) && (c.status || 'OPEN') === 'OPEN');
+    if (dupe) throw new Error('Candidate already proposed this cycle');
+    const client = await openSsb();
+    const content = {
+      type: 'tribeParliamentCandidature',
+      tribeId, candidateId, method: m,
+      votes: 0, voters: [], proposer: client.id,
+      status: 'OPEN', createdAt: nowISO()
+    };
+    return new Promise((resolve, reject) => client.publish(content, (e, r) => e ? reject(e) : resolve(r)));
+  };
+
+  const tribeVoteCandidature = async ({ tribeId, candidatureId }) => {
+    const client = await openSsb();
+    const all = await tribeListCandidatures(tribeId);
+    const alreadyThisCycle = all.some(c => Array.isArray(c.voters) && c.voters.includes(client.id));
+    if (alreadyThisCycle) throw new Error('Already voted this cycle');
+    const cand = all.find(c => c.id === candidatureId);
+    if (!cand) throw new Error('Candidate not found');
+    const updated = {
+      type: 'tribeParliamentCandidature',
+      tribeId, replaces: candidatureId,
+      candidateId: cand.candidateId, method: cand.method,
+      votes: Number(cand.votes || 0) + 1,
+      voters: [...(cand.voters || []), client.id],
+      proposer: cand.proposer, status: cand.status || 'OPEN',
+      createdAt: cand.createdAt, updatedAt: nowISO()
+    };
+    return new Promise((resolve, reject) => client.publish(updated, (e, r) => e ? reject(e) : resolve(r)));
+  };
+
+  const tribePublishRule = async ({ tribeId, title, body }) => {
+    if (!title || !title.trim()) throw new Error('Title required');
+    const client = await openSsb();
+    const content = {
+      type: 'tribeParliamentRule', tribeId,
+      title: String(title).trim(), body: String(body || '').trim(),
+      author: client.id, createdAt: nowISO()
+    };
+    return new Promise((resolve, reject) => client.publish(content, (e, r) => e ? reject(e) : resolve(r)));
+  };
+
+  const tribeDeleteRule = async (ruleId) => {
+    const client = await openSsb();
+    return new Promise((resolve, reject) => client.publish({ type: 'tombstone', target: ruleId, deletedAt: nowISO(), author: client.id }, (e, r) => e ? reject(e) : resolve(r)));
+  };
+
+  const tribeHasCandidatureInGlobalCycle = async (tribeId, globalTermStart) => {
+    const msgs = await tribeReadLog();
+    const cutoff = globalTermStart ? new Date(globalTermStart) : new Date(Date.now() - TERM_DAYS * 86400000);
+    return msgs.some(m => {
+      const c = m.value?.content; if (!c) return false;
+      return c.type === 'parliamentCandidature' && c.targetType === 'tribe' && c.targetId === tribeId && (c.status || 'OPEN') === 'OPEN' && new Date(c.createdAt) >= cutoff;
+    });
+  };
+
   return {
     proposeCandidature,
     voteCandidature,
@@ -1212,7 +1315,19 @@ module.exports = ({ cooler, services = {} }) => {
     listRevocationsCurrent,
     listFutureRevocationsCurrent,
     closeRevocation,
-    countRevocationsEnacted
+    countRevocationsEnacted,
+    tribe: {
+      METHODS,
+      TERM_DAYS,
+      getCurrentTerm: tribeGetCurrentTerm,
+      listCandidatures: tribeListCandidatures,
+      listRules: tribeListRules,
+      publishTribeCandidature: tribePublishCandidature,
+      voteTribeCandidature: tribeVoteCandidature,
+      publishTribeRule: tribePublishRule,
+      deleteTribeRule: tribeDeleteRule,
+      hasCandidatureInGlobalCycle: tribeHasCandidatureInGlobalCycle
+    }
   };
 };
 

+ 6 - 2
src/models/pm_model.js

@@ -65,7 +65,9 @@ module.exports = ({ cooler }) => {
       const author = decrypted?.value?.author;
       const originalRecps = Array.isArray(content?.to) ? content.to : [];
       if (!content || !author) throw new Error("Malformed message.");
-      if (author !== userId) throw new Error("Not the author.");
+      const isAuthor = author === userId;
+      const isRecipient = originalRecps.includes(userId);
+      if (!isAuthor && !isRecipient) throw new Error("Not authorized.");
       if (content.type === 'tombstone') throw new Error("Message already deleted.");
       const tombstone = {
         type: 'tombstone',
@@ -73,7 +75,9 @@ module.exports = ({ cooler }) => {
         deletedAt: new Date().toISOString(),
         private: true
       };
-      const tombstoneRecps = uniqueRecps([userId, author, ...originalRecps]);
+      const tombstoneRecps = isAuthor
+        ? uniqueRecps([userId, author, ...originalRecps])
+        : uniqueRecps([userId]);
       const publishAsync = util.promisify(ssbClient.private.publish);
       return publishAsync(tombstone, tombstoneRecps);
     },

+ 72 - 2
src/models/transfers_model.js

@@ -39,6 +39,9 @@ module.exports = ({ cooler }) => {
     const nodes = new Map()
     const parent = new Map()
     const child = new Map()
+    const ubiByPub = new Map()
+    const ubiByUser = new Map()
+    const ubiClaimNodes = []
 
     for (const m of messages) {
       const k = m.key
@@ -57,7 +60,46 @@ module.exports = ({ cooler }) => {
           parent.set(k, c.replaces)
           child.set(c.replaces, k)
         }
+        const tags = Array.isArray(c.tags) ? c.tags.map(t => String(t).toUpperCase()) : []
+        if (tags.includes("UBI") && c.to && c.concept) {
+          const key = `${c.to}::${c.concept}`
+          if (v.author === c.from) ubiByPub.set(key, k)
+          else ubiByUser.set(key, k)
+        }
+      }
+
+      if (c.type === "ubiClaim") {
+        ubiClaimNodes.push({ k, v, c, ts: v.timestamp || m.timestamp || 0 })
+      }
+    }
+
+    for (const [key, userMsgKey] of ubiByUser.entries()) {
+      if (ubiByPub.has(key)) tomb.add(userMsgKey)
+    }
+
+    for (const { k, v, c, ts } of ubiClaimNodes) {
+      if (tomb.has(k)) continue
+      const claimantId = v.author
+      const epochId = c.epochId || ""
+      const concept = `UBI ${epochId} ${claimantId}`.trim()
+      const key = `${claimantId}::${concept}`
+      if (ubiByPub.has(key) || ubiByUser.has(key)) continue
+      const synthetic = {
+        type: "transfer",
+        from: c.pubId || "",
+        to: claimantId,
+        concept,
+        amount: String(c.amount || 0),
+        createdAt: c.claimedAt || new Date(ts).toISOString(),
+        updatedAt: c.claimedAt || new Date(ts).toISOString(),
+        deadline: null,
+        confirmedBy: [c.pubId || ""].filter(Boolean),
+        status: "UNCONFIRMED",
+        tags: ["UBI", "PENDING"],
+        opinions: {},
+        opinions_inhabitants: []
       }
+      nodes.set(k, { key: k, ts, c: synthetic, author: claimantId })
     }
 
     const rootOf = (id) => {
@@ -223,10 +265,38 @@ module.exports = ({ cooler }) => {
     async confirmTransferById(id) {
       const ssbClient = await openSsb()
       const userId = ssbClient.id
-      const tipId = await this.resolveCurrentId(id)
+      let tipId
+      try { tipId = await this.resolveCurrentId(id) } catch (_) { tipId = id }
       const msg = await getMsg(ssbClient, tipId)
 
-      if (!msg?.content || msg.content.type !== "transfer") throw new Error("Not found")
+      if (!msg?.content) throw new Error("Not found")
+
+      if (msg.content.type === "ubiClaim") {
+        const c = msg.content
+        const epochId = c.epochId || ""
+        const pubId = c.pubId || ""
+        if (!pubId) throw new Error("Not found")
+        if (pubId === userId) throw new Error("Cannot confirm own claim")
+        const now = new Date().toISOString()
+        const transferContent = {
+          type: "transfer",
+          from: pubId,
+          to: userId,
+          concept: `UBI ${epochId} ${userId}`,
+          amount: String(c.amount || 0),
+          createdAt: c.claimedAt || now,
+          updatedAt: now,
+          deadline: null,
+          confirmedBy: [pubId, userId],
+          status: "CLOSED",
+          tags: ["UBI"],
+          opinions: {},
+          opinions_inhabitants: []
+        }
+        return new Promise((resolve, reject) => ssbClient.publish(transferContent, (e, r) => e ? reject(e) : resolve(r)))
+      }
+
+      if (msg.content.type !== "transfer") throw new Error("Not found")
 
       const t = msg.content
       const status = deriveStatus(t)

+ 64 - 0
src/models/tribes_model.js

@@ -412,6 +412,70 @@ module.exports = ({ cooler, tribeCrypto }) => {
       const parentRoot = rootOf(parentId);
       const all = await this.listAll();
       return all.filter(t => t.parentTribeId && rootOf(t.parentTribeId) === parentRoot);
+    },
+
+    async isTribeMember(userId, tribeId) {
+      if (!userId || !tribeId) return false;
+      try {
+        const tribe = await this.getTribeById(tribeId);
+        if (!tribe) return false;
+        if (tribe.author === userId) return true;
+        return Array.isArray(tribe.members) && tribe.members.includes(userId);
+      } catch (e) {
+        return false;
+      }
+    },
+
+    async canAccessTribe(userId, tribeId) {
+      if (!userId || !tribeId) return false;
+      try {
+        const tribe = await this.getTribeById(tribeId);
+        if (!tribe) return false;
+        if (tribe.author === userId) return true;
+        if (Array.isArray(tribe.members) && tribe.members.includes(userId)) return true;
+        const effective = await this.getEffectiveStatus(tribeId);
+        return !effective.isPrivate;
+      } catch (e) {
+        return false;
+      }
+    },
+
+    async getEffectiveStatus(tribeId) {
+      let current;
+      try { current = await this.getTribeById(tribeId); } catch (e) { return { isPrivate: true, chain: [] }; }
+      const chain = [{ id: current.id, isAnonymous: !!current.isAnonymous, author: current.author }];
+      let cursor = current;
+      const seen = new Set([current.id]);
+      while (cursor.parentTribeId && !seen.has(cursor.parentTribeId)) {
+        seen.add(cursor.parentTribeId);
+        try {
+          cursor = await this.getTribeById(cursor.parentTribeId);
+          chain.push({ id: cursor.id, isAnonymous: !!cursor.isAnonymous, author: cursor.author });
+        } catch (e) { break; }
+      }
+      const isPrivate = chain.some(c => c.isAnonymous);
+      return { isPrivate, chain };
+    },
+
+    async listTribesForViewer(userId) {
+      const all = await this.listAll();
+      const out = [];
+      for (const t of all) {
+        if (!t.isAnonymous) { out.push(t); continue; }
+        if (t.author === userId || (Array.isArray(t.members) && t.members.includes(userId))) out.push(t);
+      }
+      return out;
+    },
+
+    async getViewerTribeScope(userId) {
+      const all = await this.listAll();
+      const memberOf = new Set();
+      const createdBy = new Set();
+      for (const t of all) {
+        if (t.author === userId) { createdBy.add(t.id); memberOf.add(t.id); continue; }
+        if (Array.isArray(t.members) && t.members.includes(userId)) memberOf.add(t.id);
+      }
+      return { memberOf, createdBy, allTribes: all };
     }
   };
 };

+ 130 - 0
src/models/viewer_filters.js

@@ -0,0 +1,130 @@
+const fs = require('fs');
+const path = require('path');
+const { getConfig } = require('../configs/config-manager.js');
+
+const COOLDOWN_MS = 5 * 60 * 1000;
+const statePath = path.join(__dirname, '../configs/follow_state.json');
+
+const readJson = (p, fallback) => {
+  try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch (e) { return fallback; }
+};
+const writeJson = (p, data) => {
+  try { fs.writeFileSync(p, JSON.stringify(data, null, 2)); } catch (e) {}
+};
+
+const loadState = () => {
+  const s = readJson(statePath, { pending: [], accepted: [], lastAcceptMs: 0 });
+  if (!Array.isArray(s.pending)) s.pending = [];
+  if (!Array.isArray(s.accepted)) s.accepted = [];
+  if (typeof s.lastAcceptMs !== 'number') s.lastAcceptMs = 0;
+  return s;
+};
+const saveState = (s) => writeJson(statePath, s);
+
+const wishMutualsOnly = () => getConfig().wish === 'mutuals';
+const pmMutualsOnly = () => getConfig().pmVisibility === 'mutuals';
+const isFrictionActive = () => wishMutualsOnly() || pmMutualsOnly();
+
+const listPending = () => loadState().pending;
+const enqueuePending = (followerId, extra = {}) => {
+  if (!followerId) return false;
+  const s = loadState();
+  if (s.pending.some(x => x.followerId === followerId)) return false;
+  s.pending.push({ followerId, at: new Date().toISOString(), ...extra });
+  saveState(s);
+  return true;
+};
+const removePending = (followerId) => {
+  const s = loadState();
+  s.pending = s.pending.filter(x => x.followerId !== followerId);
+  saveState(s);
+};
+
+const loadAccepted = () => loadState().accepted;
+const isAccepted = (followerId) => loadState().accepted.includes(followerId);
+const addAccepted = (followerId) => {
+  if (!followerId) return;
+  const s = loadState();
+  if (!s.accepted.includes(followerId)) { s.accepted.push(followerId); saveState(s); }
+};
+const removeAccepted = (followerId) => {
+  const s = loadState();
+  s.accepted = s.accepted.filter(x => x !== followerId);
+  saveState(s);
+};
+
+const canAutoAcceptNow = () => (Date.now() - loadState().lastAcceptMs) >= COOLDOWN_MS;
+const markAutoAccept = () => {
+  const s = loadState();
+  s.lastAcceptMs = Date.now();
+  saveState(s);
+};
+
+const makeMutualCache = (friendModel) => {
+  const cache = new Map();
+  const frictionActive = isFrictionActive();
+  return async (otherId) => {
+    if (!otherId) return false;
+    if (cache.has(otherId)) return cache.get(otherId);
+    try {
+      const rel = await friendModel.getRelationship(otherId);
+      const basic = !!(rel && rel.following && rel.followsMe);
+      const mutual = frictionActive ? (basic && isAccepted(otherId)) : basic;
+      cache.set(otherId, mutual);
+      return mutual;
+    } catch (e) {
+      cache.set(otherId, false);
+      return false;
+    }
+  };
+};
+
+const authorOf = (item) => {
+  if (!item) return null;
+  if (item.value && item.value.author) return item.value.author;
+  if (item.author) return item.author;
+  if (item.feed) return item.feed;
+  if (item.id && typeof item.id === 'string' && item.id.startsWith('@')) return item.id;
+  return null;
+};
+
+const applyMutualSupportFilter = async (items, viewerId, friendModel) => {
+  if (!wishMutualsOnly()) return items;
+  if (!Array.isArray(items)) return items;
+  const isMutual = makeMutualCache(friendModel);
+  const out = [];
+  for (const it of items) {
+    const a = authorOf(it);
+    if (!a || a === viewerId) { out.push(it); continue; }
+    if (await isMutual(a)) out.push(it);
+  }
+  return out;
+};
+
+const canSendPmTo = async (viewerId, recipientId, friendModel) => {
+  if (!pmMutualsOnly()) return { allowed: true };
+  if (viewerId === recipientId) return { allowed: true };
+  const isMutual = makeMutualCache(friendModel);
+  if (await isMutual(recipientId)) return { allowed: true };
+  return { allowed: false, reason: 'non-mutual' };
+};
+
+module.exports = {
+  COOLDOWN_MS,
+  wishMutualsOnly,
+  pmMutualsOnly,
+  isFrictionActive,
+  listPending,
+  enqueuePending,
+  removePending,
+  loadAccepted,
+  isAccepted,
+  addAccepted,
+  removeAccepted,
+  canAutoAcceptNow,
+  markAutoAccept,
+  makeMutualCache,
+  applyMutualSupportFilter,
+  canSendPmTo,
+  authorOf,
+};

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

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

+ 1 - 1
src/server/package.json

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

+ 41 - 56
src/views/activity_view.js

@@ -249,8 +249,6 @@ function renderActionCards(actions, userId, allActions) {
       if (!content || typeof content !== 'object') return false;
       if (content.type === 'tombstone') return false;
       if (content.type === 'post' && content.private === true) return false;
-      if (content.type === 'tribe' && content.isAnonymous === true) return false;
-      if (typeof content.type === 'string' && content.type.startsWith('tribe') && content.isAnonymous === true) return false;
       if (content.type === 'task' && content.isPublic === "PRIVATE") return false;
       if (content.type === 'event' && content.isPublic === "private") return false;
       if ((content.type === 'feed' || action.type === 'feed') && !isValidFeedText(content.text)) return false;
@@ -329,20 +327,16 @@ function renderActionCards(actions, userId, allActions) {
       headerText = `[COURTS · ${finalSub.toUpperCase()}]`;
     } else if (type === 'taskAssignment') {
       headerText = `[${String(i18n.typeTask || 'TASK').toUpperCase()} · ASSIGNMENT]`;
-    } else if (type === 'tribeJoin') {
-      headerText = `[TRIBE · ${String(i18n.tribeActivityJoined || 'JOIN').toUpperCase()}]`;
-    } else if (type === 'tribeLeave') {
-      headerText = `[TRIBE · ${String(i18n.tribeActivityLeft || 'LEAVE').toUpperCase()}]`;
-    } else if (type === 'tribeFeedPost') {
-      headerText = `[TRIBE · FEED]`;
-    } else if (type === 'tribeFeedRefeed') {
-      headerText = `[TRIBE · REFEED]`;
     } else if (type === 'shopProduct') {
       headerText = `[SHOP · PRODUCT]`;
     } else if (type === 'chat') {
       headerText = `[CHAT \u00b7 NEW]`;
     } else if (type === 'pad') {
       headerText = `[PAD · ${String(i18n.padNew || 'NEW').toUpperCase()}]`;
+    } else if (type === 'ubiClaim') {
+      headerText = `[UBI · CLAIM]`;
+    } else if (type === 'ubiclaimresult') {
+      headerText = `[UBI · RESULT]`;
     } else {
       const typeLabel = i18n[`type${capitalize(type)}`] || type;
       headerText = `[${String(typeLabel).toUpperCase()}]`;
@@ -441,6 +435,40 @@ function renderActionCards(actions, userId, allActions) {
       );
     }
 
+    if (type === 'ubiClaim') {
+      const { pubId, amount, epochId, claimedAt } = content;
+      const amt = Number(amount || 0);
+      const inhabitantId = action.author || '';
+      cardBody.push(
+        div({ class: 'card-section banking-ubi' },
+          div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.bankUbiInhabitant + ':'),
+            span({ class: 'card-value' }, a({ href: `/author/${encodeURIComponent(inhabitantId)}`, class: 'user-link' }, inhabitantId))
+          ),
+          pubId ? div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.bankUbiPub + ':'),
+            span({ class: 'card-value' }, a({ href: `/author/${encodeURIComponent(pubId)}`, class: 'user-link' }, pubId))
+          ) : "",
+          div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.bankUbiClaimedAmount + ':'),
+            span({ class: 'card-value' }, `${amt.toFixed(6)} ECO`)
+          ),
+          epochId ? div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.bankEpochShort + ':'),
+            span({ class: 'card-value' }, epochId)
+          ) : "",
+          div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.status + ':'),
+            span({ class: 'card-value' }, 'UNCONFIRMED')
+          ),
+          claimedAt ? div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.date + ':'),
+            span({ class: 'card-value' }, moment(claimedAt).format('YYYY-MM-DD HH:mm:ss'))
+          ) : ""
+        )
+      );
+    }
+
     if (type === 'ubiclaimresult') {
       const { txid, userId: inhabitantId, amount, epochId } = content;
       const pubAuthor = action.author || action.value?.author || '';
@@ -504,41 +532,6 @@ function renderActionCards(actions, userId, allActions) {
         )
       );
     }
-    if (type === 'tribeJoin' || type === 'tribeLeave') {
-      const { tribeId, tribeTitle } = content || {};
-      cardBody.push(
-        div({ class: 'card-section tribe' },
-          h2({ class: 'tribe-title' },
-            a({ href: `/tribe/${encodeURIComponent(tribeId || '')}`, class: 'user-link' }, tribeTitle || tribeId || '')
-          )
-        )
-      );
-    }
-
-    if (type === 'tribeFeedPost') {
-      const { tribeId, tribeTitle, text } = content || {};
-      cardBody.push(
-        div({ class: 'card-section tribe' },
-          h2({ class: 'tribe-title' },
-            a({ href: `/tribe/${encodeURIComponent(tribeId || '')}`, class: 'user-link' }, tribeTitle || tribeId || '')
-          ),
-          text ? p({ class: 'post-text' }, ...renderUrl(text)) : ''
-        )
-      );
-    }
-
-    if (type === 'tribeFeedRefeed') {
-      const { tribeId, tribeTitle, text } = content || {};
-      cardBody.push(
-        div({ class: 'card-section tribe' },
-          h2({ class: 'tribe-title' },
-            a({ href: `/tribe/${encodeURIComponent(tribeId || '')}`, class: 'user-link' }, tribeTitle || tribeId || '')
-          ),
-          text ? p({ class: 'post-text' }, ...renderUrl(text)) : ''
-        )
-      );
-    }
-
     if (type === 'curriculum') {
       const { author, name, description, photo, personalSkills, oasisSkills, educationalSkills, languages, professionalSkills, status, preferences, createdAt, updatedAt} = content;
       cardBody.push(
@@ -1572,11 +1565,6 @@ function getViewDetailsAction(type, action) {
     case 'courtsSettlementAccepted':return `/courts?filter=actions`;
     case 'courtsNomination':        return `/courts?filter=judges`;
     case 'courtsNominationVote':    return `/courts?filter=judges`;
-    case 'tribeJoin':
-    case 'tribeLeave':
-    case 'tribeFeedPost':
-    case 'tribeFeedRefeed':
-    return `/tribe/${encodeURIComponent(action.content?.tribeId || '')}`;
     case 'spread': {
       const link = normalizeSpreadLink(action.content?.spreadTargetId || action.content?.vote?.link || '');
       return link ? `/thread/${encodeURIComponent(link)}#${encodeURIComponent(link)}` : `/activity`;
@@ -1615,6 +1603,7 @@ function getViewDetailsAction(type, action) {
     case 'report':     return `/reports/${id}`;
     case 'bankWallet': return `/wallet`;
     case 'bankClaim':  return `/banking${action.content?.epochId ? `/epoch/${encodeURIComponent(action.content.epochId)}` : ''}`;
+    case 'ubiClaim':   return action.content?.transferId ? `/transfers/${encodeURIComponent(action.content.transferId)}` : `/transfers?filter=ubi`;
     case 'gameScore':  return `/games?filter=scoring`;
     default:           return `/activity`;
   }
@@ -1670,13 +1659,9 @@ exports.activityView = (actions, filter, userId, q = '') => {
     const now = Date.now();
     filteredActions = actions.filter(action => action.type !== 'tombstone' && action.ts && now - action.ts < 24 * 60 * 60 * 1000);
   } else if (filter === 'banking') {
-    filteredActions = actions.filter(action => action.type !== 'tombstone' && (action.type === 'bankWallet' || action.type === 'bankClaim' || action.type === 'ubiclaimresult'));
+    filteredActions = actions.filter(action => action.type !== 'tombstone' && (action.type === 'bankWallet' || action.type === 'bankClaim' || action.type === 'ubiClaim' || action.type === 'ubiclaimresult'));
   } else if (filter === 'tribe') {
-    filteredActions = actions.filter(action =>
-      action.type !== 'tombstone' &&
-      String(action.type || '').startsWith('tribe') &&
-      action.type !== 'tribe'
-    );
+    filteredActions = actions.filter(action => action.type === 'tribe');
   } else if (filter === 'parliament') {
     filteredActions = actions.filter(action => ['parliamentCandidature','parliamentTerm','parliamentProposal','parliamentRevocation','parliamentLaw'].includes(action.type));
   } else if (filter === 'courts') {

+ 24 - 12
src/views/banking_views.js

@@ -43,11 +43,23 @@ const fmtDate = (timestamp) => {
     return moment(timestamp).format('YYYY-MM-DD HH:mm:ss');
 };
 
+const fmtEcoTime = (ms) => {
+  if (!ms || ms <= 0) return `0 ${i18n.bankUnitMs || 'ms'}`;
+  if (ms < 1000) return `${Number(ms).toFixed(3)} ${i18n.bankUnitMs || 'ms'}`;
+  const s = ms / 1000;
+  if (s < 60) return `${s.toFixed(2)} ${i18n.bankUnitSeconds || 'seconds'}`;
+  const m = s / 60;
+  if (m < 60) return `${m.toFixed(2)} ${i18n.bankUnitMinutes || 'minutes'}`;
+  const h = m / 60;
+  if (h < 24) return `${h.toFixed(2)} ${i18n.bankHoursOfWork || 'hours'}`;
+  return `${(h / 24).toFixed(2)} ${i18n.bankUnitDays || 'days'}`;
+};
+
 const renderExchange = (ex) => {
   if (!ex) return div(p(i18n.bankExchangeNoData));
   const syncStatus = ex.isSynced ? i18n.bankingSyncStatusSynced : i18n.bankingSyncStatusOutdated;
   const syncStatusClass = ex.isSynced ? 'synced' : 'outdated';
-  const ecoInHours = ex.isSynced ? ex.ecoInHours : 0;
+  const ecoTimeLabel = ex.isSynced ? fmtEcoTime(ex.ecoTimeMs) : fmtEcoTime(0);
   return div(
     div({ class: "bank-summary" },
       table({ class: "bank-info-table" },
@@ -58,8 +70,9 @@ const renderExchange = (ex) => {
           kvRow(i18n.bankExchangeCurrentValue, `${fmtIndex(ex.ecoValue)} ECO`),
           kvRow(i18n.bankCurrentSupply, `${Number(ex.currentSupply || 0).toFixed(6)} ECO`),
           kvRow(i18n.bankTotalSupply, `${Number(ex.totalSupply || 0).toFixed(6)} ECO`),
-          kvRow(i18n.bankEcoinHours, `${ecoInHours} ${i18n.bankHoursOfWork}`),
-          kvRow(i18n.bankInflation, `${ex.inflationFactor.toFixed(2)}%`)
+          kvRow(i18n.bankEcoinHours, ecoTimeLabel),
+          kvRow(i18n.bankInflation, `${ex.inflationFactor.toFixed(2)}%`),
+          kvRow(i18n.bankInflationMonthly, `${Number(ex.inflationMonthly || 0).toFixed(2)}%`)
         )
       )
     )
@@ -92,13 +105,12 @@ const renderOverviewSummaryTable = (s, rules) => {
   );
 };
 
-const renderClaimUBIBlock = (pendingAllocation, isPub, alreadyClaimed, pubId) => {
-  if (alreadyClaimed) {
-    return div({ class: "bank-claim-ubi" }, p(i18n.bankAlreadyClaimedThisMonth));
-  }
-  if (!pubId && !isPub) {
-    return div({ class: "bank-claim-ubi" }, p(i18n.bankNoPubConfigured));
-  }
+const renderClaimUBIBlock = (pendingAllocation, isPub, alreadyClaimed, pubId, hasValidWallet, pubBalance, ubiAvailability) => {
+  if (alreadyClaimed) return "";
+  if (!pubId && !isPub) return "";
+  if (!isPub && !hasValidWallet) return "";
+  if (Number(pubBalance || 0) <= 0) return "";
+  if (ubiAvailability !== "OK") return "";
   if (!pendingAllocation && !isPub) {
     return div({ class: "bank-claim-ubi" },
       div({ class: "bank-claim-card" },
@@ -108,7 +120,7 @@ const renderClaimUBIBlock = (pendingAllocation, isPub, alreadyClaimed, pubId) =>
       )
     );
   }
-  if (!pendingAllocation) return div({ class: "bank-claim-ubi" }, p(i18n.bankNoPendingUBI));
+  if (!pendingAllocation) return "";
   return div({ class: "bank-claim-ubi" },
     div({ class: "bank-claim-card" },
       p(`${i18n.bankUbiThisMonth}: `, span({ class: "accent" }, `${Number(pendingAllocation.amount || 0).toFixed(6)} ECO`)),
@@ -312,7 +324,7 @@ const renderBankingView = (data, filter, userId, isPub) =>
       filter === "overview"
         ? div(
             renderOverviewSummaryTable(data.summary || {}, data.rules),
-            renderClaimUBIBlock(data.pendingUBI || null, isPub, data.alreadyClaimed, (data.summary || {}).pubId),
+            renderClaimUBIBlock(data.pendingUBI || null, isPub, data.alreadyClaimed, (data.summary || {}).pubId, (data.summary || {}).hasValidWallet, (data.summary || {}).pubBalance, (data.summary || {}).ubiAvailability),
             allocationsTable((data.allocations || []).sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)), userId)
           )
         : filter === "exchange"

+ 7 - 1
src/views/blockchain_view.js

@@ -271,7 +271,13 @@ const renderSingleBlockView = (block, filter = 'recent', userId, search = {}, vi
           ),
           div({ class:'block-row block-row--content' },
             div({ class:'block-content-preview' },
-              pre({ class:'json-content' }, JSON.stringify(block.content,null,2))
+              block.content && typeof block.content.encryptedPayload === 'string'
+                ? div({ class: 'encrypted-payload-box' },
+                    p({ class: 'encrypted-label' }, `[${i18n.bxEncrypted || 'ENCRYPTED'}]`),
+                    p({ class: 'encrypted-hex-label' }, i18n.bxEncryptedHexLabel || 'Ciphertext (preview)'),
+                    pre({ class: 'json-content' }, String(block.content.encryptedPayload).slice(0, 128) + (String(block.content.encryptedPayload).length > 128 ? '…' : ''))
+                  )
+                : pre({ class:'json-content' }, JSON.stringify(block.content,null,2))
             )
           )
         );

+ 17 - 0
src/views/main_views.js

@@ -1037,6 +1037,23 @@ exports.tribeAccessDeniedView = (tribe) => {
   );
 };
 
+exports.inviteRequiredView = (kind, tribe) => {
+  const msg = kind === 'pad' ? (i18n.tribePadInviteRequired || 'You do not have access to the pad. Ask for an invitation to access the content.')
+            : kind === 'chat' ? (i18n.tribeChatInviteRequired || 'You do not have access to the chat. Ask for an invitation to access the content.')
+            : (i18n.tribeContentAccessDeniedMsg);
+  const backHref = tribe ? `/tribe/${encodeURIComponent(tribe.id)}?section=${kind === 'chat' ? 'chats' : 'pads'}` : (kind === 'chat' ? '/chats' : '/pads');
+  return template(
+    i18n.tribeContentAccessDenied,
+    div({ class: "div-center" },
+      h2(i18n.tribeContentAccessDenied),
+      p(msg),
+      div({ class: "visit-btn-centered" },
+        a({ href: backHref, class: "filter-btn" }, i18n.walletBack || "Back")
+      )
+    )
+  );
+};
+
 const thread = (messages) => {
   let lookingForTarget = true;
   let shallowest = Infinity;

+ 5 - 1
src/views/pm_view.js

@@ -1,10 +1,13 @@
-const { div, h2, p, section, button, form, input, textarea, br, label, pre } = require("../server/node_modules/hyperaxe");
+const { div, h2, p, section, button, form, input, textarea, br, label, pre, span } = require("../server/node_modules/hyperaxe");
 const { template, i18n } = require('./main_views');
+const { getConfig } = require('../configs/config-manager.js');
 
 exports.pmView = async (initialRecipients = '', initialSubject = '', initialText = '', showPreview = false) => {
   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,
@@ -16,6 +19,7 @@ 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),
             br(),
             input({

+ 32 - 5
src/views/settings_view.js

@@ -25,10 +25,9 @@ const settingsView = ({ version, aiPrompt }) => {
   const walletUrl = currentConfig.wallet.url;
   const walletUser = currentConfig.wallet.user;
   const walletFee = currentConfig.wallet.fee;
-  const pubWalletUrl = currentConfig.walletPub.url || '';
-  const pubWalletUser = currentConfig.walletPub.user || '';
-  const pubWalletPass = currentConfig.walletPub.pass || '';
-  const pubId = currentConfig.pubId || '';
+  const pubId = currentConfig.walletPub?.pubId || '';
+  const currentWish = currentConfig.wish === 'mutuals' ? 'mutuals' : 'whole';
+  const currentPmVisibility = currentConfig.pmVisibility === 'mutuals' ? 'mutuals' : 'whole';
 
   const themeElements = [
     option({ value: "Dark-SNH", selected: theme === "Dark-SNH" ? true : undefined }, "Dark-SNH"),
@@ -148,6 +147,34 @@ const settingsView = ({ version, aiPrompt }) => {
       )
      )
     ),
+    section(
+      div({ class: "tags-header" },
+        h2(i18n.settingsWishTitle),
+        p(i18n.settingsWishDesc),
+        form(
+          { 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)
+          ), br(), br(),
+          button({ type: "submit" }, i18n.saveSettings)
+        )
+      )
+    ),
+    section(
+      div({ class: "tags-header" },
+        h2(i18n.settingsPmVisibilityTitle),
+        p(i18n.settingsPmVisibilityDesc),
+        form(
+          { action: "/settings/pm-visibility", method: "POST" },
+          select({ name: "pmVisibility" },
+            option({ value: "whole", selected: currentPmVisibility === "whole" ? true : undefined }, i18n.settingsPmVisibilityWhole),
+            option({ value: "mutuals", selected: currentPmVisibility === "mutuals" ? true : undefined }, i18n.settingsPmVisibilityMutuals)
+          ), br(), br(),
+          button({ type: "submit" }, i18n.saveSettings)
+        )
+      )
+    ),
     section(
       div({ class: "tags-header" },
         h2(i18n.wallet),
@@ -172,7 +199,7 @@ const settingsView = ({ version, aiPrompt }) => {
     section(
       div({ class: "tags-header" },
         h2(i18n.pubIdTitle || "PUB Wallet"),
-        p(i18n.pubIdDescription || "Set the PUB wallet URL. This will be used for PUB transactions (including the UBI)."),
+        p(i18n.pubIdDescription || "Set the PUB OASIS ID. This will be used for PUB transactions (including the UBI)."),
         form(
           { action: "/settings/pub-id", method: "POST" },
           input({

+ 6 - 2
src/views/transfer_view.js

@@ -159,6 +159,8 @@ const generateTransferCard = (transfer, filter, params = {}) => {
   const isUnconfirmed = String(transfer.status || "").toUpperCase() === "UNCONFIRMED"
   const dl = transfer.deadline ? moment(transfer.deadline) : null
   const isExpired = dl && dl.isValid() ? dl.isBefore(moment()) : false
+  const tags = Array.isArray(transfer.tags) ? transfer.tags.map(t => String(t).toUpperCase()) : []
+  const isUbi = tags.includes("UBI")
   const showConfirm = isUnconfirmed && transfer.to === userId && !confirmedBy.includes(userId) && !isExpired
 
   const topbar = renderTransferTopbar(transfer, filter, params)
@@ -169,7 +171,7 @@ const generateTransferCard = (transfer, filter, params = {}) => {
       { class: "card-section transfer" },
       topbar ? topbar : null,
       renderCardField(`${i18n.transfersConcept}:`, transfer.concept || ""),
-      renderCardField(`${i18n.transfersDeadline}:`, dl && dl.isValid() ? dl.format("YYYY-MM-DD HH:mm") : ""),
+      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`)),
@@ -360,6 +362,8 @@ exports.singleTransferView = async (transfer, filter, params = {}) => {
   const isUnconfirmed = String(transfer.status || "").toUpperCase() === "UNCONFIRMED"
   const dl = transfer.deadline ? moment(transfer.deadline) : null
   const isExpired = dl && dl.isValid() ? dl.isBefore(moment()) : false
+  const tags = Array.isArray(transfer.tags) ? transfer.tags.map(t => String(t).toUpperCase()) : []
+  const isUbi = tags.includes("UBI")
   const showConfirm = isUnconfirmed && transfer.to === userId && !confirmedBy.includes(userId) && !isExpired
 
   const topbar = renderTransferTopbar(transfer, normalizedFilter, { ...params, q, sort, single: true })
@@ -398,7 +402,7 @@ exports.singleTransferView = async (transfer, filter, params = {}) => {
           br,
           div({ class: "transfer-amount-highlight" }, renderCardField(`${i18n.transfersAmount}:`, `${fmtAmount(transfer.amount)} ECO`)),
           renderCardField(`${i18n.transfersConcept}:`, transfer.concept || ""),
-          renderCardField(`${i18n.transfersDeadline}:`, dl && dl.isValid() ? dl.format("YYYY-MM-DD HH:mm") : ""),
+          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,
           renderConfirmationsBar(confirmedCount, required),

+ 176 - 9
src/views/tribes_view.js

@@ -1,4 +1,4 @@
-const { div, h2, p, section, button, form, a, input, img, label, select, option, br, textarea, h1, span, nav, ul, li, video, audio, table, tr, td } = require("../server/node_modules/hyperaxe");
+const { div, h2, h3, p, section, button, form, a, input, img, label, select, option, br, textarea, h1, span, nav, ul, li, video, audio, table, tr, td } = require("../server/node_modules/hyperaxe");
 const { template, i18n } = require('./main_views');
 const { config } = require('../server/SSB_server.js');
 const { renderUrl } = require('../backend/renderUrl');
@@ -112,7 +112,21 @@ exports.tribesView = async (tribes, filter, tribeId, query = {}, allTribes = nul
   const now = Date.now();
   const search = (query.search || '').toLowerCase();
 
-  const visible = (t) => !t.isAnonymous || t.members.includes(userId);
+  const allT0 = allTribes || tribes;
+  const isAncestorPrivate = (t) => {
+    let cur = t;
+    const seen = new Set();
+    while (cur && cur.parentTribeId && !seen.has(cur.parentTribeId)) {
+      seen.add(cur.parentTribeId);
+      const p = allT0.find(x => x.id === cur.parentTribeId);
+      if (!p) break;
+      if (p.isAnonymous) return true;
+      cur = p;
+    }
+    return false;
+  };
+  const effectivePrivate = (t) => !!t.isAnonymous || isAncestorPrivate(t);
+  const visible = (t) => !effectivePrivate(t) || t.author === userId || (Array.isArray(t.members) && t.members.includes(userId));
   const isMainTribe = (t) => !t.parentTribeId;
   const filtered = tribes.filter(t => {
     return (
@@ -252,7 +266,10 @@ exports.tribesView = async (tribes, filter, tribeId, query = {}, allTribes = nul
       parentTribe
         ? div({ class: 'tribe-card-parent' },
             span({ class: 'tribe-info-label' }, i18n.tribeMainTribeLabel || 'MAIN TRIBE'),
-            a({ href: `/tribe/${encodeURIComponent(parentTribe.id)}`, class: 'tribe-parent-card-link' }, parentTribe.title)
+            a({ href: `/tribe/${encodeURIComponent(parentTribe.id)}`, class: 'tribe-parent-card-link' },
+              renderMediaBlob(parentTribe.image, '/assets/images/default-tribe.png', { class: 'tribe-parent-image', alt: parentTribe.title }),
+              span({ class: 'tribe-parent-card-title' }, parentTribe.title)
+            )
           )
         : null,
       div({ class: 'tribe-card-image-wrapper' },
@@ -383,12 +400,12 @@ const sectionLink = (tribe, sectionKey, label, currentSection) =>
 const renderSectionNav = (tribe, section) => {
   const firstGroup = [{ key: 'activity', label: i18n.tribeSectionActivity }, { key: 'inhabitants', label: i18n.tribeSectionInhabitants }];
   if (!tribe.parentTribeId) firstGroup.push({ key: 'subtribes', label: i18n.tribeSubTribes });
+  firstGroup.push({ key: 'governance', label: i18n.tribeSectionGovernance || 'GOVERNANCE' });
   const sections = [
     { items: firstGroup },
     { items: [{ key: 'votations', label: i18n.tribeSectionVotations }, { key: 'events', label: i18n.tribeSectionEvents }, { key: 'tasks', label: i18n.tribeSectionTasks }] },
-    { items: [{ key: 'feed', label: i18n.tribeSectionFeed }, { key: 'forum', label: i18n.tribeSectionForum }] },
-    { items: [{ key: 'images', label: i18n.tribeSectionImages || 'IMAGES' }, { key: 'audios', label: i18n.tribeSectionAudios || 'AUDIOS' }, { key: 'videos', label: i18n.tribeSectionVideos || 'VIDEOS' }, { key: 'documents', label: i18n.tribeSectionDocuments || 'DOCUMENTS' }, { key: 'bookmarks', label: i18n.tribeSectionBookmarks || 'BOOKMARKS' }, { key: 'maps', label: i18n.tribeSectionMaps || 'MAPS' }, { key: 'torrents', label: i18n.tribeSectionTorrents || 'TORRENTS' }] },
-    { items: [{ key: 'pads', label: i18n.tribeSectionPads || 'PADS' }, { key: 'chats', label: i18n.tribeSectionChats || 'CHATS' }, { key: 'calendars', label: i18n.tribeSectionCalendars || 'CALENDARS' }] },
+    { items: [{ key: 'feed', label: i18n.tribeSectionFeed }, { key: 'forum', label: i18n.tribeSectionForum }, { key: 'maps', label: i18n.tribeSectionMaps || 'MAPS' }, { key: 'torrents', label: i18n.tribeSectionTorrents || 'TORRENTS' }, { key: 'pads', label: i18n.tribeSectionPads || 'PADS' }, { key: 'chats', label: i18n.tribeSectionChats || 'CHATS' }, { key: 'calendars', label: i18n.tribeSectionCalendars || 'CALENDARS' }] },
+    { items: [{ key: 'images', label: i18n.tribeSectionImages || 'IMAGES' }, { key: 'audios', label: i18n.tribeSectionAudios || 'AUDIOS' }, { key: 'videos', label: i18n.tribeSectionVideos || 'VIDEOS' }, { key: 'documents', label: i18n.tribeSectionDocuments || 'DOCUMENTS' }, { key: 'bookmarks', label: i18n.tribeSectionBookmarks || 'BOOKMARKS' }] },
     { items: [{ key: 'search', label: i18n.tribeSectionSearch }] },
   ];
   return div({ class: 'tribe-section-nav', style: 'border: none;' },
@@ -1218,6 +1235,10 @@ const renderSubTribesSection = (tribe, items, query) => {
     : tribe.author === userId;
 
   if (action === 'create' && canCreate) {
+    const parentIsPrivate = !!tribe.isAnonymous;
+    const statusOptions = parentIsPrivate
+      ? [{ value: 'true', label: (i18n.tribeSubInheritedPrivate || i18n.tribePrivate) }]
+      : [{ value: 'true', label: i18n.tribePrivate }, { value: 'false', label: i18n.tribePublic }];
     return renderCreateForm(tribe, 'subtribes', [
       { name: 'title', label: i18n.tribeTitleLabel, required: true, placeholder: 'Name of the sub-tribe' },
       { name: 'description', type: 'textarea', label: i18n.tribeDescriptionLabel, required: true, placeholder: 'Description of the sub-tribe' },
@@ -1228,9 +1249,7 @@ const renderSubTribesSection = (tribe, items, query) => {
       { name: 'inviteMode', type: 'select', label: i18n.tribeModeLabel, options: [
         { value: 'open', label: i18n.tribeOpen }, { value: 'strict', label: i18n.tribeStrict }
       ], spaceBefore: true },
-      { name: 'isAnonymous', type: 'select', label: i18n.tribeIsAnonymousLabel, options: [
-        { value: 'true', label: i18n.tribePrivate }, { value: 'false', label: i18n.tribePublic }
-      ], spaceBefore: true },
+      { name: 'isAnonymous', type: 'select', label: i18n.tribeIsAnonymousLabel, options: statusOptions, spaceBefore: true },
       { name: 'isLARP', type: 'select', label: 'L.A.R.P.?', options: [
         { value: 'false', label: i18n.tribeNo }, { value: 'true', label: i18n.tribeYes }
       ], spaceBefore: true },
@@ -1433,6 +1452,7 @@ exports.tribeView = async (tribe, userIdParam, query, section, sectionData) => {
     case 'pads': sectionContent = renderTribePadsSection(tribe, sectionData); break;
     case 'chats': sectionContent = renderTribeChatsSection(tribe, sectionData); break;
     case 'calendars': sectionContent = renderTribeCalendarsSection(tribe, sectionData); break;
+    case 'governance': sectionContent = renderGovernance(tribe, sectionData); break;
     case 'activity':
     default: sectionContent = renderTribeActivitySection(tribe, sectionData); break;
   }
@@ -1537,3 +1557,150 @@ exports.tribeView = async (tribe, userIdParam, query, section, sectionData) => {
   );
 };
 
+const GOVERNANCE_METHODS = ['DEMOCRACY', 'MAJORITY', 'MINORITY', 'DICTATORSHIP', 'KARMATOCRACY'];
+
+const governanceFilterBar = (tribeId, currentFilter, showPublish) => {
+  const row1 = [
+    { key: 'government', label: i18n.parliamentFilterGovernment || 'GOVERNMENT' },
+    { key: 'candidatures', label: i18n.parliamentFilterCandidatures || 'CANDIDATURES' },
+    { key: 'proposals', label: i18n.parliamentFilterProposals || 'PROPOSALS' },
+    { key: 'laws', label: i18n.parliamentFilterLaws || 'LAWS' }
+  ];
+  const row2 = [
+    { key: 'revocations', label: i18n.parliamentFilterRevocations || 'REVOCATIONS' },
+    { key: 'historical', label: i18n.parliamentFilterHistorical || 'HISTORICAL' },
+    { key: 'leaders', label: i18n.parliamentFilterLeaders || 'LEADERS' },
+    { key: 'rules', label: i18n.parliamentFilterRules || 'RULES' }
+  ];
+  const mkRow = (filters) => div({ class: 'mode-buttons-row' },
+    filters.map(f =>
+      form({ method: 'GET', action: `/tribe/${encodeURIComponent(tribeId)}` },
+        input({ type: 'hidden', name: 'section', value: 'governance' }),
+        input({ type: 'hidden', name: 'filter', value: f.key }),
+        button({ type: 'submit', class: currentFilter === f.key ? 'filter-btn active' : 'filter-btn' }, f.label)
+      )
+    )
+  );
+  return div({ class: 'filters' },
+    mkRow(row1),
+    mkRow(row2),
+    showPublish
+      ? div({ class: 'mode-buttons-row' },
+          form({ method: 'POST', action: `/tribe/${encodeURIComponent(tribeId)}/governance/publish-candidature` },
+            button({ type: 'submit', class: 'create-button' }, i18n.parliamentCandidatureProposeBtn || 'Publish Candidature')
+          )
+        )
+      : null
+  );
+};
+
+const governmentCard = (tribe, term, leaders) => {
+  if (!term) {
+    return div({ class: 'card' },
+      h3(i18n.tribeGovernanceNoGov || 'No active government'),
+      p(i18n.tribeGovernanceNoGovDesc || 'This tribe has not yet elected a government. Propose candidatures to start the process.')
+    );
+  }
+  return div({ class: 'card' },
+    h3(`${i18n.parliamentMethod || 'Method'}: ${term.method || 'DEMOCRACY'}`),
+    p(`${i18n.parliamentTerm || 'Term'}: ${term.startAt?.slice(0, 10) || '-'} → ${term.endAt?.slice(0, 10) || '-'}`),
+    Array.isArray(leaders) && leaders.length > 0
+      ? div({},
+          h3(i18n.parliamentFilterLeaders || 'LEADERS'),
+          ul({}, leaders.map(l => li({}, `@${l.slice(0, 12)}…`)))
+        )
+      : null
+  );
+};
+
+const candidaturesBlock = (tribe, candidatures, alreadyPublishedThisGlobalCycle) => {
+  const list = (Array.isArray(candidatures) ? candidatures : [])
+    .filter(c => (c.status || 'OPEN') === 'OPEN')
+    .sort((a, b) => Number(b.votes || 0) - Number(a.votes || 0));
+  return div({},
+    alreadyPublishedThisGlobalCycle
+      ? p({ class: 'notice' }, i18n.tribeGovernanceAlreadyPublished || 'This tribe already has an open candidature in the current global parliament cycle.')
+      : null,
+    h3(i18n.tribeGovernanceProposeInternal || 'Propose internal candidature'),
+    form({ method: 'POST', action: `/tribe/${encodeURIComponent(tribe.id)}/governance/candidature/propose` },
+      label({}, i18n.parliamentCandidatureId || 'Candidate'), br(),
+      input({ type: 'text', name: 'candidateId', placeholder: '@...ed25519', required: true }), br(), br(),
+      label({}, i18n.parliamentCandidatureMethod || 'Method'), br(),
+      select({ name: 'method' },
+        GOVERNANCE_METHODS.map(m => option({ value: m }, i18n[`parliamentMethod${m}`] || m))
+      ), br(), br(),
+      button({ type: 'submit', class: 'create-button' }, i18n.parliamentCandidatureProposeBtn || 'Publish Candidature')
+    ),
+    h3(i18n.tribeGovernanceInternalCandidatures || 'Internal candidatures'),
+    list.length === 0
+      ? p(i18n.tribeGovernanceNoCandidatures || 'No open candidatures.')
+      : ul({}, list.map(c =>
+          li({},
+            `${c.candidateId?.slice(0, 16) || '?'}… — ${c.method || 'DEMOCRACY'} — ${c.votes || 0} ${i18n.votes || 'votes'}`,
+            ' ',
+            form({ method: 'POST', action: `/tribe/${encodeURIComponent(tribe.id)}/governance/candidature/vote`, style: 'display:inline' },
+              input({ type: 'hidden', name: 'candidatureId', value: c.id }),
+              button({ type: 'submit', class: 'filter-btn' }, i18n.vote || 'Vote')
+            )
+          )
+        ))
+  );
+};
+
+const rulesBlock = (tribe, rules, isCreator) => div({},
+  isCreator
+    ? div({},
+        h3(i18n.tribeGovernanceAddRule || 'Add rule'),
+        form({ method: 'POST', action: `/tribe/${encodeURIComponent(tribe.id)}/governance/rule/add` },
+          label({}, i18n.parliamentRuleTitle || 'Title'), br(),
+          input({ type: 'text', name: 'title', required: true }), br(), br(),
+          label({}, i18n.parliamentRuleBody || 'Body'), br(),
+          textarea({ name: 'body', rows: 4 }), br(), br(),
+          button({ type: 'submit', class: 'create-button' }, i18n.save || 'Save')
+        )
+      )
+    : null,
+  h3(i18n.parliamentFilterRules || 'Rules'),
+  (!Array.isArray(rules) || rules.length === 0)
+    ? p(i18n.tribeGovernanceNoRules || 'No rules yet.')
+    : ul({}, rules.map(r =>
+        li({},
+          span({ style: 'font-weight:bold' }, r.title || '-'),
+          r.body ? p(r.body) : null,
+          isCreator
+            ? form({ method: 'POST', action: `/tribe/${encodeURIComponent(tribe.id)}/governance/rule/delete`, style: 'display:inline' },
+                input({ type: 'hidden', name: 'ruleId', value: r.id }),
+                button({ type: 'submit', class: 'filter-btn' }, i18n.delete || 'Delete')
+              )
+            : null
+        )
+      ))
+);
+
+const renderGovernance = (tribe, data) => {
+  const { filter, term, candidatures, rules, leaders, isCreator, canPublishToGlobal, alreadyPublishedThisGlobalCycle, hasElectedCandidate } = data || {};
+  const f = filter || 'government';
+  const header = div({ class: 'tags-header' },
+    h2(i18n.tribeSectionGovernance || 'GOVERNANCE'),
+    p(i18n.tribeGovernanceDesc || 'Internal governance for this tribe. Propose candidatures, debate rules, elect leaders — mirrors the global Parliament.')
+  );
+  let body;
+  if (f === 'government') body = governmentCard(tribe, term, leaders);
+  else if (f === 'candidatures') body = candidaturesBlock(tribe, candidatures, alreadyPublishedThisGlobalCycle);
+  else if (f === 'rules') body = rulesBlock(tribe, rules, !!isCreator);
+  else if (f === 'leaders') body = div({ class: 'card' },
+    h3(i18n.parliamentFilterLeaders || 'LEADERS'),
+    (!Array.isArray(leaders) || leaders.length === 0)
+      ? p(i18n.tribeGovernanceNoLeaders || 'No leaders elected yet.')
+      : ul({}, leaders.map(l => li({}, `@${l.slice(0, 12)}…`)))
+  );
+  else body = div({ class: 'card' },
+    h3(i18n[`parliamentFilter${f.charAt(0).toUpperCase()}${f.slice(1)}`] || f.toUpperCase()),
+    p(i18n.tribeGovernanceComingSoon || 'Coming soon in this tribe\'s governance module.')
+  );
+
+  const showPublish = !!(canPublishToGlobal && hasElectedCandidate && !alreadyPublishedThisGlobalCycle);
+  return div({ class: 'tribe-governance' }, header, governanceFilterBar(tribe.id, f, showPublish), body);
+};
+
+exports.renderGovernance = renderGovernance;