浏览代码

Oasis release 0.5.4

psy 3 天之前
父节点
当前提交
fdae20322a

+ 12 - 1
docs/CHANGELOG.md

@@ -13,7 +13,18 @@ All notable changes to this project will be documented in this file.
 ### Security
 -->
 
-## v0.5.2 - 2025-10-27
+## v0.5.4 - 2025-10-30
+
+### Fixed
+
+ + Content stats (Stats plugin).
+ + Non-avatar inhabitants listing (Inhabitants plugin).
+ + Inhabitants suggestions (Inhabitants plugin).
+ + Activity level (Inhabitants plugin).
+ + Parliament duplication (Parliament plugin).
+ + Added Parliament to blockexplorer (Blockexplorer plugin).
+
+## v0.5.3 - 2025-10-27
 
 ### Fixed
 

+ 38 - 36
src/backend/backend.js

@@ -988,55 +988,57 @@ router
    })
   .get('/inhabitants', async (ctx) => {
     const filter = ctx.query.filter || 'all';
-    const query = {
-        search: ctx.query.search || ''
-    };
+    const query = { search: ctx.query.search || '' };
     if (['CVs', 'MATCHSKILLS'].includes(filter)) {
-        query.location = ctx.query.location || '';
-        query.language = ctx.query.language || '';
-        query.skills = ctx.query.skills || '';
+      query.location = ctx.query.location || '';
+      query.language = ctx.query.language || '';
+      query.skills = ctx.query.skills || '';
     }
     const userId = SSBconfig.config.keys.id;
-    const inhabitants = await inhabitantsModel.listInhabitants({
-        filter,
-        ...query
-    });
+    const inhabitants = await inhabitantsModel.listInhabitants({ filter, ...query });
     const [addresses, karmaList] = await Promise.all([
-        bankingModel.listAddressesMerged(),
-        Promise.all(
-            inhabitants.map(async (u) => {
-                try {
-                    const { karmaScore } = await bankingModel.getBankingData(u.id);
-                    return { id: u.id, karmaScore: typeof karmaScore === 'number' ? karmaScore : 0 };
-                } catch {
-                    return { id: u.id, karmaScore: 0 };
-                }
-            })
-        )
+    bankingModel.listAddressesMerged(),
+    Promise.all(
+      inhabitants.map(async (u) => {
+          try {
+            const { karmaScore } = await bankingModel.getBankingData(u.id);
+            return { id: u.id, karmaScore: typeof karmaScore === 'number' ? karmaScore : 0 };
+          } catch {
+            return { id: u.id, karmaScore: 0 };
+          }
+        })
+      )
     ]);
     const addrMap = new Map(addresses.map(x => [x.id, x.address]));
     const karmaMap = new Map(karmaList.map(x => [x.id, x.karmaScore]));
     let enriched = inhabitants.map(u => ({
-        ...u,
-        ecoAddress: addrMap.get(u.id) || null,
-        karmaScore: karmaMap.has(u.id)
-            ? karmaMap.get(u.id)
-            : (typeof u.karmaScore === 'number' ? u.karmaScore : 0)
+      ...u,
+      ecoAddress: addrMap.get(u.id) || null,
+      karmaScore: karmaMap.has(u.id)
+        ? karmaMap.get(u.id)
+        : (typeof u.karmaScore === 'number' ? u.karmaScore : 0)
     }));
     if (filter === 'TOP KARMA') {
-        enriched = enriched.sort((a, b) => (b.karmaScore || 0) - (a.karmaScore || 0));
+      enriched = enriched.sort((a, b) => (b.karmaScore || 0) - (a.karmaScore || 0));
     }
     ctx.body = await inhabitantsView(enriched, filter, query, userId);
   })
   .get('/inhabitant/:id', async (ctx) => {
     const id = ctx.params.id;
-    const about = await inhabitantsModel.getLatestAboutById(id);
-    const cv = await inhabitantsModel.getCVByUserId(id);
-    const feed = await inhabitantsModel.getFeedByUserId(id);
+    const [about, cv, feed, photo, bank, lastTs] = await Promise.all([
+      inhabitantsModel.getLatestAboutById(id),
+      inhabitantsModel.getCVByUserId(id),
+      inhabitantsModel.getFeedByUserId(id),
+      inhabitantsModel.getPhotoUrlByUserId(id, 256),
+      bankingModel.getBankingData(id).catch(() => ({ karmaScore: 0 })),
+      inhabitantsModel.getLastActivityTimestampByUserId(id).catch(() => null)
+    ]);
+    const bucketInfo = inhabitantsModel.bucketLastActivity(lastTs || null);
     const currentUserId = SSBconfig.config.keys.id;
-    ctx.body = await inhabitantsProfileView({ about, cv, feed }, currentUserId);
+    const karmaScore = bank && typeof bank.karmaScore === 'number' ? bank.karmaScore : 0;
+    ctx.body = await inhabitantsProfileView({ about, cv, feed, photo, karmaScore, lastActivityBucket: bucketInfo.bucket, viewedId: id }, currentUserId);
   })
- .get('/parliament', async (ctx) => {
+  .get('/parliament', async (ctx) => {
     const mod = ctx.cookies.get('parliamentMod') || 'on';
     if (mod !== 'on') { ctx.redirect('/modules'); return }
     const filter = (ctx.query.filter || 'government').toLowerCase();
@@ -1049,7 +1051,7 @@ router
       candidatures, proposals, futureLaws, canPropose, laws,
       historical, leaders, revocations, futureRevocations, revocationsEnactedCount,
       inhabitantsAll
-      ] = await Promise.all([
+     ] = await Promise.all([
       parliamentModel.listCandidatures('OPEN'),
       parliamentModel.listProposalsCurrent(),
       parliamentModel.listFutureLawsCurrent(),
@@ -1061,7 +1063,7 @@ router
       parliamentModel.listFutureRevocationsCurrent(),
       parliamentModel.countRevocationsEnacted(),
       inhabitantsModel.listInhabitants({ filter: 'all' })
-    ]); 
+    ]);
     const inhabitantsTotal = Array.isArray(inhabitantsAll) ? inhabitantsAll.length : 0;
     const leader = pickLeader(candidatures || []);
     const leaderMeta = leader ? await parliamentModel.getActorMeta({ targetType: leader.targetType || leader.powerType || 'inhabitant', targetId: leader.targetId || leader.powerId }) : null;
@@ -1100,10 +1102,10 @@ router
       leaderMeta,
       powerMeta,
       historicalMetas,
-      leadersMetas,
+      leadersMetas,  
       revocations,
       futureRevocations,
-      revocationsEnactedCount
+      revocationsEnactedCount 
     });
   })
   .get('/tribes', async ctx => {

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

@@ -1616,7 +1616,8 @@ module.exports = {
     statsYourTombstone: "Tombstones",
     statsNetwork: "Network",
     statsTotalInhabitants: "Inhabitants",
-    statsDiscoveredTribes: "Tribes",
+    statsDiscoveredTribes: "Tribes (Public)",
+    statsPrivateDiscoveredTribes: "Tribes (Private)",
     statsNetworkContent: "Content",
     statsYourMarket: "Market",
     statsYourJob: "Jobs",
@@ -1661,6 +1662,8 @@ module.exports = {
     statsActivity7dTotal: "7-day total",
     statsActivity30dTotal: "30-day total",
     statsKarmaScore: "KARMA Score",
+    statsPublic: "Public",
+    statsPrivate: "Private",
     day: "Day",
     messages: "Messages",
     statsProject: "Projects",

+ 4 - 1
src/client/assets/translations/oasis_es.js

@@ -1622,7 +1622,8 @@ module.exports = {
     statsProject: "Proyectos",
     statsNetwork: "Red",
     statsTotalInhabitants: "Habitantes",
-    statsDiscoveredTribes: "Tribus",
+    statsDiscoveredTribes: "Tribus (Públicas)",
+    statsPrivateDiscoveredTribes: "Tribus (Privadas)",
     statsNetworkContent: "Contenido",
     statsYourMarket: "Mercado",
     statsYourJob: "Trabajos",
@@ -1673,6 +1674,8 @@ module.exports = {
     statsActivity7dTotal: "Total de 7 días",
     statsActivity30dTotal: "Total de 30 días",
     statsKarmaScore: "Puntuación de KARMA",
+    statsPublic: "Públicas",
+    statsPrivate: "Privadas",
     day: "Día",
     messages: "Mensajes",
     statsProject: "Proyectos",

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

@@ -1616,7 +1616,8 @@ module.exports = {
     statsYourTombstone: "Hilarriak",
     statsNetwork: "Sarea",
     statsTotalInhabitants: "Bizilagunak",
-    statsDiscoveredTribes: "Tribuak",
+    statsDiscoveredTribes: "Tribuak (Publikoak)",
+    statsPrivateDiscoveredTribes: "Tribuak (Pribatuak)",
     statsNetworkContent: "Edukia",
     statsYourMarket: "Merkatua",
     statsYourJob: "Lanak",
@@ -1674,6 +1675,8 @@ module.exports = {
     statsActivity7dTotal: "7 egunerako guztira",
     statsActivity30dTotal: "30 egunerako guztira",
     statsKarmaScore: "KARMA Puntuazioa",
+    statsPublic: "Publikoa",
+    statsPrivate: "Pribatua",
     day: "Eguna",
     messages: "Mezuak",
     statsProject: "Proiektuak",

+ 4 - 1
src/client/assets/translations/oasis_fr.js

@@ -1622,7 +1622,8 @@ module.exports = {
     statsProject: "Projets",
     statsNetwork: "Réseau",
     statsTotalInhabitants: "Habitants",
-    statsDiscoveredTribes: "Tribus",
+    statsDiscoveredTribes: "Tribus (Publiques)",
+    statsPrivateDiscoveredTribes: "Tribus (Privées)",
     statsNetworkContent: "Contenu",
     statsYourMarket: "Marché",
     statsYourJob: "Emplois",
@@ -1673,6 +1674,8 @@ module.exports = {
     statsActivity7dTotal: "Total 7 jours",
     statsActivity30dTotal: "Total 30 jours",
     statsKarmaScore: "Score de KARMA",
+    statsPublic: "Public",
+    statsPrivate: "Privé",
     day: "Jour",
     messages: "Messages",
     statsProject: "Projets",

+ 6 - 2
src/models/blockchain_model.js

@@ -104,13 +104,17 @@ module.exports = ({ cooler }) => {
       });
 
       let filtered = blockData;
-      if (filter === 'RECENT') {
+      if (filter === 'RECENT' || filter === 'recent') {
         const now = Date.now();
         filtered = blockData.filter(b => b && now - b.ts <= 24 * 60 * 60 * 1000);
       }
-      if (filter === 'MINE') {
+      if (filter === 'MINE' || filter === 'mine') {
         filtered = blockData.filter(b => b && b.author === config.keys.id);
       }
+      if (filter === 'PARLIAMENT' || filter === 'parliament') {
+        const pset = new Set(['parliamentTerm','parliamentProposal','parliamentLaw','parliamentCandidature','parliamentRevocation']);
+        filtered = blockData.filter(b => b && pset.has(b.type));
+      }
 
       return filtered.filter(Boolean);
     },

+ 141 - 129
src/models/inhabitants_model.js

@@ -9,6 +9,12 @@ const { about, friend } = models({
 const { getConfig } = require('../configs/config-manager.js');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
+function toImageUrl(imgId, size=256){
+  if (!imgId) return '/assets/images/default-avatar.png';
+  if (typeof imgId === 'string' && imgId.startsWith('/image/')) return imgId.replace('/image/256/','/image/'+size+'/').replace('/image/512/','/image/'+size+'/');
+  return `/image/${size}/${encodeURIComponent(imgId)}`;
+}
+
 module.exports = ({ cooler }) => {
   let ssb;
   const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
@@ -65,136 +71,121 @@ module.exports = ({ cooler }) => {
     return { bucket: 'red', range: '≥6m' };
   }
 
+  const timeoutPromise = (timeout) => new Promise((_, reject) => setTimeout(() => reject('Timeout'), timeout));
+  const fetchUserImageUrl = async (feedId, size=256) => {
+    try{
+      const img = await Promise.race([about.image(feedId), timeoutPromise(5000)]);
+      const id = typeof img === 'string' ? img : (img && (img.link || img.url));
+      return toImageUrl(id, size);
+    }catch{
+      return '/assets/images/default-avatar.png';
+    }
+  };
+
+  async function listAllBase(ssbClient) {
+    const authorsMsgs = await new Promise((res, rej) => {
+      pull(
+        ssbClient.createLogStream({ limit: logLimit, reverse: true }),
+        pull.filter(msg => !!msg.value?.author && msg.value?.content?.type !== 'tombstone'),
+        pull.collect((err, msgs) => err ? rej(err) : res(msgs))
+      );
+    });
+    const uniqueFeedIds = Array.from(new Set(authorsMsgs.map(r => r.value.author).filter(Boolean)));
+    const users = await Promise.all(
+      uniqueFeedIds.map(async (feedId) => {
+        const rawName = await about.name(feedId);
+        const name = rawName || feedId.slice(0, 10);
+        const description = await about.description(feedId);
+        const photo = await fetchUserImageUrl(feedId, 256);
+        const lastActivityTs = await getLastActivityTimestamp(feedId);
+        const { bucket, range } = bucketLastActivity(lastActivityTs);
+        return { id: feedId, name, description, photo, lastActivityTs, lastActivityBucket: bucket, lastActivityRange: range };
+      })
+    );
+    return Array.from(new Map(users.filter(u => u && u.id).map(u => [u.id, u])).values());
+  }
+
+  function normalizeRel(rel) {
+    const r = rel || {};
+    const iFollow = !!(r.following || r.iFollow || r.youFollow || r.i_follow || r.isFollowing);
+    const followsMe = !!(r.followsMe || r.followingMe || r.follows_me || r.theyFollow || r.isFollowedBy);
+    const blocking = !!(r.blocking || r.iBlock || r.isBlocking);
+    const blockedBy = !!(r.blocked || r.blocksMe || r.isBlockedBy);
+    return { iFollow, followsMe, blocking, blockedBy };
+  }
+
   return {
     async listInhabitants(options = {}) {
       const { filter = 'all', search = '', location = '', language = '', skills = '' } = options;
       const ssbClient = await openSsb();
       const userId = ssbClient.id;
-      const timeoutPromise = (timeout) => new Promise((_, reject) => setTimeout(() => reject('Timeout'), timeout));
-      const fetchUserImage = (feedId) => {
-        return Promise.race([
-          about.image(feedId),
-          timeoutPromise(5000)
-        ]).catch(() => '/assets/images/default-avatar.png');
-      };
-      if (filter === 'GALLERY') {
-        const feedIds = await new Promise((res, rej) => {
-          pull(
-            ssbClient.createLogStream({ limit: logLimit }),
-            pull.filter(msg => {
-              const c = msg.value?.content;
-              const a = msg.value?.author;
-              return c &&
-                c.type === 'about' &&
-                c.type !== 'tombstone' &&
-                typeof c.name === 'string' &&
-                typeof c.about === 'string' &&
-                c.about === a;
-            }),
-            pull.collect((err, msgs) => err ? rej(err) : res(msgs))
-          );
-        });
 
-        const uniqueFeedIds = Array.from(new Set(feedIds.map(r => r.value.author).filter(Boolean)));
-        const users = await Promise.all(
-          uniqueFeedIds.map(async (feedId) => {
-            const name = await about.name(feedId);
-            const description = await about.description(feedId);
-            const image = await fetchUserImage(feedId);
-            const photo =
-              typeof image === 'string'
-                ? `/image/256/${encodeURIComponent(image)}`
-                : '/assets/images/default-avatar.png';
-            return { id: feedId, name, description, photo };
-          })
-        );
+      if (filter === 'GALLERY') {
+        const users = await listAllBase(ssbClient);
         return users;
       }
+
       if (filter === 'all' || filter === 'TOP KARMA' || filter === 'TOP ACTIVITY') {
-        const feedIds = await new Promise((res, rej) => {
-          pull(
-            ssbClient.createLogStream({ limit: logLimit, reverse: true }),
-            pull.filter(msg => {
-              const c = msg.value?.content;
-              const a = msg.value?.author;
-              return c &&
-                c.type === 'about' &&
-                c.type !== 'tombstone' &&
-                typeof c.name === 'string' &&
-                typeof c.about === 'string' &&
-                c.about === a;
-            }),
-            pull.collect((err, msgs) => err ? rej(err) : res(msgs))
-          );
-        });
-        const uniqueFeedIds = Array.from(new Set(feedIds.map(r => r.value.author).filter(Boolean)));
-        let users = await Promise.all(
-          uniqueFeedIds.map(async (feedId) => {
-            const name = await about.name(feedId);
-            const description = await about.description(feedId);
-            const image = await fetchUserImage(feedId);
-            const photo =
-              typeof image === 'string'
-                ? `/image/256/${encodeURIComponent(image)}`
-                : '/assets/images/default-avatar.png';
-            const lastActivityTs = await getLastActivityTimestamp(feedId);
-            const { bucket, range } = bucketLastActivity(lastActivityTs);
-            return { id: feedId, name, description, photo, lastActivityTs, lastActivityBucket: bucket, lastActivityRange: range };
-          })
-        );
-        users = Array.from(new Map(users.filter(u => u && u.id).map(u => [u.id, u])).values());
+        let users = await listAllBase(ssbClient);
         if (search) {
           const q = search.toLowerCase();
           users = users.filter(u =>
-            u.name?.toLowerCase().includes(q) ||
-            u.description?.toLowerCase().includes(q) ||
-            u.id?.toLowerCase().includes(q)
+            (u.name || '').toLowerCase().includes(q) ||
+            (u.description || '').toLowerCase().includes(q) ||
+            (u.id || '').toLowerCase().includes(q)
           );
         }
         const withMetrics = await Promise.all(users.map(async u => {
           const karmaScore = await getLastKarmaScore(u.id);
           return { ...u, karmaScore };
         }));
-        if (filter === 'TOP KARMA') {
-          return withMetrics.sort((a, b) => (b.karmaScore || 0) - (a.karmaScore || 0));
-        }
-        if (filter === 'TOP ACTIVITY') {
-          return withMetrics.sort((a, b) => (b.lastActivityTs || 0) - (a.lastActivityTs || 0));
-        }
+        if (filter === 'TOP KARMA') return withMetrics.sort((a, b) => (b.karmaScore || 0) - (a.karmaScore || 0));
+        if (filter === 'TOP ACTIVITY') return withMetrics.sort((a, b) => (b.lastActivityTs || 0) - (a.lastActivityTs || 0));
         return withMetrics;
       }
+
       if (filter === 'contacts') {
         const all = await this.listInhabitants({ filter: 'all' });
         const result = [];
         for (const user of all) {
-          const rel = await friend.getRelationship(user.id);
-          if (rel.following) result.push(user);
+          const rel = await friend.getRelationship(user.id).catch(() => ({}));
+          if (rel && (rel.following || rel.iFollow)) result.push(user);
         }
         return Array.from(new Map(result.map(u => [u.id, u])).values());
       }
+
       if (filter === 'blocked') {
         const all = await this.listInhabitants({ filter: 'all' });
         const result = [];
         for (const user of all) {
-          const rel = await friend.getRelationship(user.id);
-          if (rel.blocking) result.push({ ...user, isBlocked: true });
+          const rel = await friend.getRelationship(user.id).catch(() => ({}));
+          const n = normalizeRel(rel);
+          if (n.blocking) result.push({ ...user, isBlocked: true });
         }
         return Array.from(new Map(result.map(u => [u.id, u])).values());
       }
+
       if (filter === 'SUGGESTED') {
-        const all = await this.listInhabitants({ filter: 'all' });
-        const result = [];
-        for (const user of all) {
-          if (user.id === userId) continue;
-          const rel = await friend.getRelationship(user.id);
-          if (!rel.following && !rel.blocking && rel.followsMe) {
-            const cv = await this.getCVByUserId(user.id);
-            if (cv) result.push({ ...this._normalizeCurriculum(cv), mutualCount: 1 });
-          }
-        }
-        return Array.from(new Map(result.map(u => [u.id, u])).values())
-          .sort((a, b) => (b.mutualCount || 0) - (a.mutualCount || 0));
+        const base = await listAllBase(ssbClient);
+        const rels = await Promise.all(
+          base.map(async u => {
+            if (u.id === userId) return null;
+            const rel = await friend.getRelationship(u.id).catch(() => ({}));
+            const n = normalizeRel(rel);
+            const karmaScore = await getLastKarmaScore(u.id);
+            return { user: u, rel: n, karmaScore };
+          })
+        );
+        const candidates = rels.filter(Boolean).filter(x => !x.rel.iFollow && !x.rel.blocking && !x.rel.blockedBy);
+        const enriched = candidates.map(x => ({
+          ...x.user,
+          karmaScore: x.karmaScore,
+          mutualCount: x.rel.followsMe ? 1 : 0
+        }));
+        const unique = Array.from(new Map(enriched.map(u => [u.id, u])).values());
+        return unique.sort((a, b) => (b.karmaScore || 0) - (a.karmaScore || 0) || (b.lastActivityTs || 0) - (a.lastActivityTs || 0));
       }
+
       if (filter === 'CVs' || filter === 'MATCHSKILLS') {
         const records = await new Promise((res, rej) => {
           pull(
@@ -207,44 +198,55 @@ module.exports = ({ cooler }) => {
           );
         });
 
-        let cvs = records.map(r => this._normalizeCurriculum(r.value.content));
-        cvs = Array.from(new Map(cvs.map(u => [u.id, u])).values());
+        let cvs = records.map(r => r.value.content);
+        cvs = Array.from(new Map(cvs.map(u => [u.author, u])).values());
 
         if (filter === 'CVs') {
+          let out = await Promise.all(cvs.map(async c => {
+            const photo = await fetchUserImageUrl(c.author, 256);
+            const lastActivityTs = await getLastActivityTimestamp(c.author);
+            const { bucket, range } = bucketLastActivity(lastActivityTs);
+            const base = this._normalizeCurriculum(c, photo);
+            return { ...base, lastActivityTs, lastActivityBucket: bucket, lastActivityRange: range };
+          }));
           if (search) {
             const q = search.toLowerCase();
-            cvs = cvs.filter(u =>
-              u.name.toLowerCase().includes(q) ||
-              u.description.toLowerCase().includes(q) ||
-              u.skills.some(s => s.toLowerCase().includes(q))
+            out = out.filter(u =>
+              (u.name || '').toLowerCase().includes(q) ||
+              (u.description || '').toLowerCase().includes(q) ||
+              u.skills.some(s => (s || '').toLowerCase().includes(q))
             );
           }
-          if (location) {
-            cvs = cvs.filter(u => u.location?.toLowerCase() === location.toLowerCase());
-          }
-          if (language) {
-            cvs = cvs.filter(u => u.languages.map(l => l.toLowerCase()).includes(language.toLowerCase()));
-          }
+          if (location) out = out.filter(u => (u.location || '').toLowerCase() === location.toLowerCase());
+          if (language) out = out.filter(u => u.languages.map(l => l.toLowerCase()).includes(language.toLowerCase()));
           if (skills) {
             const skillList = skills.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
-            cvs = cvs.filter(u => skillList.every(s => u.skills.map(k => k.toLowerCase()).includes(s)));
+            out = out.filter(u => skillList.every(s => u.skills.map(k => (k || '').toLowerCase()).includes(s)));
           }
-          return cvs;
+          return out;
         }
+
         if (filter === 'MATCHSKILLS') {
-          const cv = await this.getCVByUserId();
-          const userSkills = cv
+          const base = await Promise.all(cvs.map(async c => {
+            const photo = await fetchUserImageUrl(c.author, 256);
+            const lastActivityTs = await getLastActivityTimestamp(c.author);
+            const { bucket, range } = bucketLastActivity(lastActivityTs);
+            const norm = this._normalizeCurriculum(c, photo);
+            return { ...norm, lastActivityTs, lastActivityBucket: bucket, lastActivityRange: range };
+          }));
+          const mecv = await this.getCVByUserId();
+          const userSkills = mecv
             ? [
-                ...cv.personalSkills,
-                ...cv.oasisSkills,
-                ...cv.educationalSkills,
-                ...cv.professionalSkills
-              ].map(s => s.toLowerCase())
+                ...(mecv.personalSkills || []),
+                ...(mecv.oasisSkills || []),
+                ...(mecv.educationalSkills || []),
+                ...(mecv.professionalSkills || [])
+              ].map(s => (s || '').toLowerCase())
             : [];
           if (!userSkills.length) return [];
-          const matches = cvs.map(c => {
+          const matches = base.map(c => {
             if (c.id === userId) return null;
-            const common = c.skills.map(s => s.toLowerCase()).filter(s => userSkills.includes(s));
+            const common = c.skills.map(s => (s || '').toLowerCase()).filter(s => userSkills.includes(s));
             if (!common.length) return null;
             const matchScore = common.length / userSkills.length;
             return { ...c, commonSkills: common, matchScore };
@@ -252,25 +254,22 @@ module.exports = ({ cooler }) => {
           return matches.sort((a, b) => b.matchScore - a.matchScore);
         }
       }
+
       return [];
     },
 
-    _normalizeCurriculum(c) {
-      const photo =
-        typeof c.photo === 'string'
-          ? `/image/256/${encodeURIComponent(c.photo)}`
-          : '/assets/images/default-avatar.png';
-
+    _normalizeCurriculum(c, photoUrl) {
+      const photo = photoUrl || toImageUrl(c.photo, 256);
       return {
         id: c.author,
         name: c.name,
         description: c.description,
         photo,
         skills: [
-          ...c.personalSkills,
-          ...c.oasisSkills,
-          ...c.educationalSkills,
-          ...c.professionalSkills
+          ...(c.personalSkills || []),
+          ...(c.oasisSkills || []),
+          ...(c.educationalSkills || []),
+          ...(c.professionalSkills || [])
         ],
         location: c.location,
         languages: typeof c.languages === 'string'
@@ -279,7 +278,7 @@ module.exports = ({ cooler }) => {
         createdAt: c.createdAt
       };
     },
-    
+
     async getLatestAboutById(id) {
       const ssbClient = await openSsb();
       const records = await new Promise((res, rej) => {
@@ -296,7 +295,7 @@ module.exports = ({ cooler }) => {
       const latest = records.sort((a, b) => b.value.timestamp - a.value.timestamp)[0];
       return latest.value.content;
     },
-    
+
     async getFeedByUserId(id) {
       const ssbClient = await openSsb();
       const targetId = id || ssbClient.id;
@@ -332,6 +331,19 @@ module.exports = ({ cooler }) => {
         );
       });
       return records.length ? records[records.length - 1].value.content : null;
+    },
+
+    async getPhotoUrlByUserId(id, size = 256) {
+      return await fetchUserImageUrl(id, size);
+    },
+
+    async getLastActivityTimestampByUserId(id) {
+      return await getLastActivityTimestamp(id);
+    },
+
+    bucketLastActivity(ts) {
+      return bucketLastActivity(ts);
     }
   };
 };
+

+ 94 - 45
src/models/parliament_model.js

@@ -236,7 +236,8 @@ module.exports = ({ cooler, services = {} }) => {
 
   async function listTermsBase(filter = 'all') {
     const all = await listByType('parliamentTerm');
-    let arr = all.map(t => ({ ...t, status: moment().isAfter(parseISO(t.endAt)) ? 'EXPIRED' : 'ACTIVE' }));
+    const collapsed = collapseOverlappingTerms(all);
+    let arr = collapsed.map(t => ({ ...t, status: moment().isAfter(parseISO(t.endAt)) ? 'EXPIRED' : 'ACTIVE' }));
     if (filter === 'active') arr = arr.filter(t => t.status === 'ACTIVE');
     if (filter === 'expired') arr = arr.filter(t => t.status === 'EXPIRED');
     return arr.sort((a, b) => new Date(b.startAt) - new Date(a.startAt));
@@ -444,34 +445,51 @@ module.exports = ({ cooler, services = {} }) => {
   }
 
   async function createRevocation({ lawId, title, reasons }) {
-  const term = await getCurrentTermBase();
-  if (!term) throw new Error('No active government');
-
-  const allowed = await this.canPropose();
-  if (!allowed) throw new Error('You are not in the goverment, yet.');
-
-  const lawIdStr = String(lawId || '').trim();
-  if (!lawIdStr) throw new Error('Law required');
-
-  const laws = await listByType('parliamentLaw');
-  const law = laws.find(l => l.id === lawIdStr);
-  if (!law) throw new Error('Law not found');
-
-  const method = String(term.method || 'DEMOCRACY').toUpperCase();
-  const ssbClient = await openSsb();
-  const deadline = moment().add(REVOCATION_DAYS, 'days').toISOString();
-
-  if (method === 'DICTATORSHIP' || method === 'KARMATOCRACY') {
+    const term = await getCurrentTermBase();
+    if (!term) throw new Error('No active government');
+    const allowed = await this.canPropose();
+    if (!allowed) throw new Error('You are not in the goverment, yet.');
+    const lawIdStr = String(lawId || '').trim();
+    if (!lawIdStr) throw new Error('Law required');
+    const laws = await listByType('parliamentLaw');
+    const law = laws.find(l => l.id === lawIdStr);
+    if (!law) throw new Error('Law not found');
+    const method = String(term.method || 'DEMOCRACY').toUpperCase();
+    const ssbClient = await openSsb();
+    const deadline = moment().add(REVOCATION_DAYS, 'days').toISOString();
+    if (method === 'DICTATORSHIP' || method === 'KARMATOCRACY') {
+      const rev = {
+        type: 'parliamentRevocation',
+        lawId: lawIdStr,
+        title: title || law.question || '',
+        reasons: reasons || '',
+        method,
+        termId: term.id || term.startAt,
+        proposer: userId,
+        status: 'OPEN',
+        deadline,
+        createdAt: nowISO()
+      };
+      return await new Promise((resolve, reject) =>
+        ssbClient.publish(rev, (e, r) => (e ? reject(e) : resolve(r)))
+      );
+    }
+    const voteMsg = await services.votes.createVote(
+      `Revoke: ${title || law.question || ''}`,
+      deadline,
+      ['YES', 'NO', 'ABSTENTION'],
+      [`gov:${term.id || term.startAt}`, `govMethod:${method}`, 'revocation']
+    );
     const rev = {
       type: 'parliamentRevocation',
       lawId: lawIdStr,
       title: title || law.question || '',
       reasons: reasons || '',
       method,
+      voteId: voteMsg.key || voteMsg.id,
       termId: term.id || term.startAt,
       proposer: userId,
       status: 'OPEN',
-      deadline,
       createdAt: nowISO()
     };
     return await new Promise((resolve, reject) =>
@@ -479,31 +497,6 @@ module.exports = ({ cooler, services = {} }) => {
     );
   }
 
-  const voteMsg = await services.votes.createVote(
-    `Revoke: ${title || law.question || ''}`,
-    deadline,
-    ['YES', 'NO', 'ABSTENTION'],
-    [`gov:${term.id || term.startAt}`, `govMethod:${method}`, 'revocation']
-  );
-
-  const rev = {
-    type: 'parliamentRevocation',
-    lawId: lawIdStr,
-    title: title || law.question || '',
-    reasons: reasons || '',
-    method,
-    voteId: voteMsg.key || voteMsg.id,
-    termId: term.id || term.startAt,
-    proposer: userId,
-    status: 'OPEN',
-    createdAt: nowISO()
-  };
-
-  return await new Promise((resolve, reject) =>
-    ssbClient.publish(rev, (e, r) => (e ? reject(e) : resolve(r)))
-  );
-}
-
   async function closeRevocation(revId) {
     const ssbClient = await openSsb();
     const msg = await new Promise((resolve, reject) => ssbClient.get(revId, (e, m) => (e || !m) ? reject(new Error('Revocation not found')) : resolve(m)));
@@ -955,6 +948,15 @@ module.exports = ({ cooler, services = {} }) => {
       const resAnarchy = await new Promise((resolve, reject) =>
         ssbClient.publish(termAnarchy, (e, r) => (e ? reject(e) : resolve(r)))
       );
+      try {
+        await sleep(250);
+        const canonical = await getCurrentTermBase();
+        if (canonical && canonical.id !== (resAnarchy.key || resAnarchy.id)) {
+          const tomb = { type: 'tombstone', target: resAnarchy.key || resAnarchy.id, deletedAt: nowISO(), author: userId };
+          await new Promise((resolve) => ssbClient.publish(tomb, () => resolve()));
+          return canonical;
+        }
+      } catch {}
       await archiveAllCandidatures();
       return resAnarchy;
     }
@@ -977,6 +979,15 @@ module.exports = ({ cooler, services = {} }) => {
     const res = await new Promise((resolve, reject) =>
       ssbClient.publish(term, (e, r) => (e ? reject(e) : resolve(r)))
     );
+    try {
+      await sleep(250);
+      const canonical = await getCurrentTermBase();
+      if (canonical && canonical.id !== (res.key || res.id)) {
+        const tomb = { type: 'tombstone', target: res.key || res.id, deletedAt: nowISO(), author: userId };
+        await new Promise((resolve) => ssbClient.publish(tomb, () => resolve()));
+        return canonical;
+      }
+    } catch {}
     await archiveAllCandidatures();
     return res;
   }
@@ -1046,3 +1057,41 @@ module.exports = ({ cooler, services = {} }) => {
   };
 };
 
+function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
+function collapseOverlappingTerms(terms = []) {
+  if (!terms.length) return [];
+  const sorted = [...terms].sort((a, b) => new Date(a.startAt) - new Date(b.startAt));
+  const groups = [];
+  for (const t of sorted) {
+    const tStart = new Date(t.startAt).getTime();
+    const tEnd = new Date(t.endAt).getTime();
+    let placed = false;
+    for (const g of groups) {
+      if (tStart < g.maxEnd && tEnd > g.minStart) {
+        g.items.push(t);
+        if (tStart < g.minStart) g.minStart = tStart;
+        if (tEnd > g.maxEnd) g.maxEnd = tEnd;
+        placed = true;
+        break;
+      }
+    }
+    if (!placed) groups.push({ items: [t], minStart: tStart, maxEnd: tEnd });
+  }
+  const winners = groups.map(g => {
+    g.items.sort((a, b) => {
+      const aAn = String(a.method || '').toUpperCase() === 'ANARCHY' ? 1 : 0;
+      const bAn = String(b.method || '').toUpperCase() === 'ANARCHY' ? 1 : 0;
+      if (aAn !== bAn) return aAn - bAn;
+      const aC = new Date(a.createdAt || a.startAt).getTime();
+      const bC = new Date(b.createdAt || b.startAt).getTime();
+      if (aC !== bC) return aC - bC;
+      const aS = new Date(a.startAt).getTime();
+      const bS = new Date(b.startAt).getTime();
+      if (aS !== bS) return aS - bS;
+      return String(a.id || '').localeCompare(String(b.id || ''));
+    });
+    return g.items[0];
+  });
+  return winners.sort((a, b) => new Date(b.startAt) - new Date(a.startAt));
+}
+

+ 29 - 6
src/models/stats_model.js

@@ -175,6 +175,26 @@ module.exports = ({ cooler }) => {
     const tribeDedupNodes = dedupeTribesNodes(tribeTipNodes);
     const tribeDedupContents = tribeDedupNodes.map(n => n.content);
 
+    const tribePublic = tribeDedupContents.filter(c => c.isAnonymous === false);
+    const tribePrivate = tribeDedupContents.filter(c => c.isAnonymous !== false);
+    const tribePublicNames = tribePublic.map(c => c.name || c.title || c.id).filter(Boolean);
+    const tribePublicCount = tribePublicNames.length;
+    const tribePrivateCount = tribePrivate.length;
+
+    const allTribesPublic = tribeDedupNodes
+      .filter(n => n.content?.isAnonymous === false)
+      .map(n => ({ id: n.key, name: n.content.name || n.content.title || n.key }));
+
+    const allTribes = allTribesPublic.map(t => t.name);
+
+    const memberTribesDetailed = tribeDedupNodes
+      .filter(n => Array.isArray(n.content?.members) && n.content.members.includes(userId))
+      .map(n => ({ id: n.key, name: n.content.name || n.content.title || n.key }));
+
+    const myPrivateTribesDetailed = tribeDedupNodes
+      .filter(n => n.content?.isAnonymous !== false && Array.isArray(n.content?.members) && n.content.members.includes(userId))
+      .map(n => ({ id: n.key, name: n.content.name || n.content.title || n.key }));
+
     const content = {};
     const opinions = {};
     for (const t of types) {
@@ -208,10 +228,6 @@ module.exports = ({ cooler }) => {
       content['karmaScore'] = sumKarma;
     }
 
-    const memberTribes = tribeDedupContents
-      .filter(c => Array.isArray(c.members) && c.members.includes(userId))
-      .map(c => c.name || c.title || c.id);
-
     const inhabitants = new Set(allMsgs.map(m => m.value.author)).size;
 
     const secretStat = fs.statSync(`${os.homedir()}/.ssb/secret`);
@@ -305,14 +321,21 @@ module.exports = ({ cooler }) => {
       totalAddresses: Object.keys(addrMap).length
     };
     const pubsCount = listPubsFromEbt().length;
-    
+
     const stats = {
       id: userId,
       createdAt,
       inhabitants,
       content,
       opinions,
-      memberTribes,
+      memberTribes: memberTribesDetailed.map(t => t.name),
+      memberTribesDetailed,
+      myPrivateTribesDetailed,
+      allTribes,
+      allTribesPublic,
+      tribePublicNames,
+      tribePublicCount,
+      tribePrivateCount,
       userTombstoneCount: scopedMsgs.filter(m => m.value.content.type === 'tombstone').length,
       networkTombstoneCount: allMsgs.filter(m => m.value.content.type === 'tombstone').length,
       folderSize: formatSize(folderSize),

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

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

+ 1 - 1
src/server/package.json

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

+ 39 - 3
src/views/activity_view.js

@@ -10,6 +10,35 @@ function sumAmounts(list = []) {
   return list.reduce((s, x) => s + (parseFloat(x.amount || 0) || 0), 0);
 }
 
+function pickActiveParliamentTerm(terms) {
+  if (!terms.length) return null;
+  const now = Date.now();
+  const getC = t => t.value?.content || t.content || {};
+  const isActive = t => {
+    const c = getC(t);
+    const s = new Date(c.startAt || 0).getTime();
+    const e = new Date(c.endAt || 0).getTime();
+    return s && e && s <= now && now < e;
+  };
+  const cmp = (a, b) => {
+    const ca = getC(a);
+    const cb = getC(b);
+    const aAn = String(ca.method || '').toUpperCase() === 'ANARCHY' ? 1 : 0;
+    const bAn = String(cb.method || '').toUpperCase() === 'ANARCHY' ? 1 : 0;
+    if (aAn !== bAn) return aAn - bAn;
+    const aC = new Date(ca.createdAt || ca.startAt || 0).getTime();
+    const bC = new Date(cb.createdAt || cb.startAt || 0).getTime();
+    if (aC !== bC) return aC - bC;
+    const aS = new Date(ca.startAt || 0).getTime();
+    const bS = new Date(cb.startAt || 0).getTime();
+    if (aS !== bS) return aS - bS;
+    return String(a.id || '').localeCompare(String(b.id || ''));
+  };
+  const active = terms.filter(isActive);
+  if (active.length) return active.sort(cmp)[0];
+  return terms.sort(cmp)[0];
+}
+
 function renderActionCards(actions, userId) {
   const validActions = actions
     .filter(action => {
@@ -29,13 +58,20 @@ function renderActionCards(actions, userId) {
     })
     .sort((a, b) => b.ts - a.ts);
 
-  if (!validActions.length) {
+  const terms = validActions.filter(a => a.type === 'parliamentTerm');
+  let chosenTerm = null;
+  if (terms.length) chosenTerm = pickActiveParliamentTerm(terms);
+  const deduped = chosenTerm
+    ? validActions.filter(a => a.type !== 'parliamentTerm' || a === chosenTerm)
+    : validActions;
+
+  if (!deduped.length) {
     return div({ class: "no-actions" }, p(i18n.noActions));
   }
 
   const seenDocumentTitles = new Set();
 
-  return validActions.map(action => {
+  return deduped.map(action => {
     const date = action.ts ? new Date(action.ts).toLocaleString() : "";
     const userLink = action.author
       ? a({ href: `/author/${encodeURIComponent(action.author)}` }, action.author)
@@ -51,7 +87,7 @@ function renderActionCards(actions, userId) {
       headerText = `[${String(typeLabel).toUpperCase()}]`;
     }
 
-    const content = action.content || {};
+    const content = action.value?.content || action.content || {};
     const cardBody = [];
 
     if (type === 'votes') {

+ 172 - 163
src/views/blockchain_view.js

@@ -3,191 +3,200 @@ const { template, i18n } = require("../views/main_views");
 const moment = require("../server/node_modules/moment");
 
 const FILTER_LABELS = {
-    votes: i18n.typeVotes, vote: i18n.typeVote, recent: i18n.recent, all: i18n.all,
-    mine: i18n.mine, tombstone: i18n.typeTombstone, pixelia: i18n.typePixelia,
-    curriculum: i18n.typeCurriculum, document: i18n.typeDocument, bookmark: i18n.typeBookmark,
-    feed: i18n.typeFeed, event: i18n.typeEvent, task: i18n.typeTask, report: i18n.typeReport,
-    image: i18n.typeImage, audio: i18n.typeAudio, video: i18n.typeVideo, post: i18n.typePost,
-    forum: i18n.typeForum, about: i18n.typeAbout, contact: i18n.typeContact, pub: i18n.typePub,
-    transfer: i18n.typeTransfer, market: i18n.typeMarket, job: i18n.typeJob, tribe: i18n.typeTribe,
-    project: i18n.typeProject, banking: i18n.typeBanking, bankWallet: i18n.typeBankWallet, bankClaim: i18n.typeBankClaim,
-    aiExchange: i18n.typeAiExchange,
+  votes: i18n.typeVotes, vote: i18n.typeVote, recent: i18n.recent, all: i18n.all,
+  mine: i18n.mine, tombstone: i18n.typeTombstone, pixelia: i18n.typePixelia,
+  curriculum: i18n.typeCurriculum, document: i18n.typeDocument, bookmark: i18n.typeBookmark,
+  feed: i18n.typeFeed, event: i18n.typeEvent, task: i18n.typeTask, report: i18n.typeReport,
+  image: i18n.typeImage, audio: i18n.typeAudio, video: i18n.typeVideo, post: i18n.typePost,
+  forum: i18n.typeForum, about: i18n.typeAbout, contact: i18n.typeContact, pub: i18n.typePub,
+  transfer: i18n.typeTransfer, market: i18n.typeMarket, job: i18n.typeJob, tribe: i18n.typeTribe,
+  project: i18n.typeProject, banking: i18n.typeBanking, bankWallet: i18n.typeBankWallet, bankClaim: i18n.typeBankClaim,
+  aiExchange: i18n.typeAiExchange, parliament: i18n.typeParliament
 };
 
 const BASE_FILTERS = ['recent', 'all', 'mine', 'tombstone'];
-const CAT_BLOCK1  = ['votes', 'event', 'task', 'report'];
+const CAT_BLOCK1  = ['votes', 'event', 'task', 'report', 'parliament'];
 const CAT_BLOCK2  = ['pub', 'tribe', 'about', 'contact', 'curriculum', 'vote', 'aiExchange'];
 const CAT_BLOCK3  = ['banking', 'job', 'market', 'project', 'transfer', 'feed', 'post', 'pixelia'];
 const CAT_BLOCK4  = ['forum', 'bookmark', 'image', 'video', 'audio', 'document'];
 
 const filterBlocks = (blocks, filter, userId) => {
-    if (filter === 'recent') return blocks.filter(b => Date.now() - b.ts < 24*60*60*1000);
-    if (filter === 'mine') return blocks.filter(b => b.author === userId);
-    if (filter === 'all') return blocks;
-    if (filter === 'banking') return blocks.filter(b => b.type === 'bankWallet' || b.type === 'bankClaim');
-    return blocks.filter(b => b.type === filter);
+  if (filter === 'recent') return blocks.filter(b => Date.now() - b.ts < 24*60*60*1000);
+  if (filter === 'mine') return blocks.filter(b => b.author === userId);
+  if (filter === 'all') return blocks;
+  if (filter === 'banking') return blocks.filter(b => b.type === 'bankWallet' || b.type === 'bankClaim');
+  if (filter === 'parliament') {
+    const pset = new Set(['parliamentTerm','parliamentProposal','parliamentLaw','parliamentCandidature','parliamentRevocation']);
+    return blocks.filter(b => pset.has(b.type));
+  }
+  return blocks.filter(b => b.type === filter);
 };
 
 const generateFilterButtons = (filters, currentFilter, action) =>
-    div({ class: 'mode-buttons-cols' },
-        filters.map(mode =>
-            form({ method: 'GET', action },
-                input({ type: 'hidden', name: 'filter', value: mode }),
-                button({
-                    type: 'submit',
-                    class: currentFilter === mode ? 'filter-btn active' : 'filter-btn'
-                }, (FILTER_LABELS[mode]||mode).toUpperCase())
-            )
-        )
-    );
+  div({ class: 'mode-buttons-cols' },
+    filters.map(mode =>
+      form({ method: 'GET', action },
+        input({ type: 'hidden', name: 'filter', value: mode }),
+        button({
+          type: 'submit',
+          class: currentFilter === mode ? 'filter-btn active' : 'filter-btn'
+        }, (FILTER_LABELS[mode]||mode).toUpperCase())
+      )
+    )
+  );
 
 const getViewDetailsAction = (type, block) => {
-    switch (type) {
-        case 'votes': return `/votes/${encodeURIComponent(block.id)}`;
-        case 'transfer': return `/transfers/${encodeURIComponent(block.id)}`;
-        case 'pixelia': return `/pixelia`;
-        case 'tribe': return `/tribe/${encodeURIComponent(block.id)}`;
-        case 'curriculum': return `/inhabitant/${encodeURIComponent(block.author)}`;
-        case 'image': return `/images/${encodeURIComponent(block.id)}`;
-        case 'audio': return `/audios/${encodeURIComponent(block.id)}`;
-        case 'video': return `/videos/${encodeURIComponent(block.id)}`;
-        case 'forum': return `/forum/${encodeURIComponent(block.content?.key||block.id)}`;
-        case 'document': return `/documents/${encodeURIComponent(block.id)}`;
-        case 'bookmark': return `/bookmarks/${encodeURIComponent(block.id)}`;
-        case 'event': return `/events/${encodeURIComponent(block.id)}`;
-        case 'task': return `/tasks/${encodeURIComponent(block.id)}`;
-        case 'about': return `/author/${encodeURIComponent(block.author)}`;
-        case 'post': return `/thread/${encodeURIComponent(block.id)}#${encodeURIComponent(block.id)}`;
-        case 'vote': return `/thread/${encodeURIComponent(block.content.vote.link)}#${encodeURIComponent(block.content.vote.link)}`;
-        case 'contact': return `/inhabitants`;
-        case 'pub': return `/invites`;
-        case 'market': return `/market/${encodeURIComponent(block.id)}`;
-        case 'job': return `/jobs/${encodeURIComponent(block.id)}`;
-        case 'project': return `/projects/${encodeURIComponent(block.id)}`;
-        case 'report': return `/reports/${encodeURIComponent(block.id)}`;
-        case 'bankWallet': return `/wallet`;
-        case 'bankClaim': return `/banking${block.content?.epochId ? `/epoch/${encodeURIComponent(block.content.epochId)}` : ''}`;
-        default: return null;
-    }
+  switch (type) {
+    case 'votes': return `/votes/${encodeURIComponent(block.id)}`;
+    case 'transfer': return `/transfers/${encodeURIComponent(block.id)}`;
+    case 'pixelia': return `/pixelia`;
+    case 'tribe': return `/tribe/${encodeURIComponent(block.id)}`;
+    case 'curriculum': return `/inhabitant/${encodeURIComponent(block.author)}`;
+    case 'image': return `/images/${encodeURIComponent(block.id)}`;
+    case 'audio': return `/audios/${encodeURIComponent(block.id)}`;
+    case 'video': return `/videos/${encodeURIComponent(block.id)}`;
+    case 'forum': return `/forum/${encodeURIComponent(block.content?.key||block.id)}`;
+    case 'document': return `/documents/${encodeURIComponent(block.id)}`;
+    case 'bookmark': return `/bookmarks/${encodeURIComponent(block.id)}`;
+    case 'event': return `/events/${encodeURIComponent(block.id)}`;
+    case 'task': return `/tasks/${encodeURIComponent(block.id)}`;
+    case 'about': return `/author/${encodeURIComponent(block.author)}`;
+    case 'post': return `/thread/${encodeURIComponent(block.id)}#${encodeURIComponent(block.id)}`;
+    case 'vote': return `/thread/${encodeURIComponent(block.content.vote.link)}#${encodeURIComponent(block.content.vote.link)}`;
+    case 'contact': return `/inhabitants`;
+    case 'pub': return `/invites`;
+    case 'market': return `/market/${encodeURIComponent(block.id)}`;
+    case 'job': return `/jobs/${encodeURIComponent(block.id)}`;
+    case 'project': return `/projects/${encodeURIComponent(block.id)}`;
+    case 'report': return `/reports/${encodeURIComponent(block.id)}`;
+    case 'bankWallet': return `/wallet`;
+    case 'bankClaim': return `/banking${block.content?.epochId ? `/epoch/${encodeURIComponent(block.content.epochId)}` : ''}`;
+    case 'parliamentTerm': return `/parliament`;
+    case 'parliamentProposal': return `/parliament`;
+    case 'parliamentLaw': return `/parliament`;
+    case 'parliamentCandidature': return `/parliament`;
+    case 'parliamentRevocation': return `/parliament`;
+    default: return null;
+  }
 };
 
 const renderSingleBlockView = (block, filter) =>
-    template(
-        i18n.blockchain,
-        section(
-            div({ class: 'tags-header' },
-                h2(i18n.blockchain),
-                p(i18n.blockchainDescription)
-            ),
-            div({ class: 'mode-buttons-row' },
-                div({ style: 'display:flex;flex-direction:column;gap:8px;' },
-                    generateFilterButtons(BASE_FILTERS, filter, '/blockexplorer')
-                ),
-                div({ style: 'display:flex;flex-direction:column;gap:8px;' },
-                    generateFilterButtons(CAT_BLOCK1, filter, '/blockexplorer'),
-                    generateFilterButtons(CAT_BLOCK2, filter, '/blockexplorer')
-                ),
-                div({ style: 'display:flex;flex-direction:column;gap:8px;' },
-                    generateFilterButtons(CAT_BLOCK3, filter, '/blockexplorer'),
-                    generateFilterButtons(CAT_BLOCK4, filter, '/blockexplorer')
-                )
-            ),
-            div({ class: 'block-single' },
-                div({ class: 'block-row block-row--meta' },
-                    span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockID}:`),
-                    span({ class: 'blockchain-card-value' }, block.id)
-                ),
-                div({ class: 'block-row block-row--meta' },
-                    span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockTimestamp}:`),
-                    span({ class: 'blockchain-card-value' }, moment(block.ts).format('YYYY-MM-DDTHH:mm:ss.SSSZ')),
-                    span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockType}:`),
-                    span({ class: 'blockchain-card-value' }, (FILTER_LABELS[block.type]||block.type).toUpperCase())
-                ),
-                div({ class: 'block-row block-row--meta', style:'margin-top:8px;' },
-                    a({ href:`/author/${encodeURIComponent(block.author)}`, class:'block-author user-link' }, block.author)
-                )
-            ),
-            div({ class:'block-row block-row--content' },
-                div({ class:'block-content-preview' },
-                    pre({ class:'json-content' }, JSON.stringify(block.content,null,2))
-                )
-            ),
-            div({ class:'block-row block-row--back' },
-                form({ method:'GET', action:'/blockexplorer' },
-                    button({ type:'submit', class:'filter-btn' }, `← ${i18n.blockchainBack}`)
-                ),
-                !block.isTombstoned && !block.isReplaced && getViewDetailsAction(block.type, block) ?
+  template(
+    i18n.blockchain,
+    section(
+      div({ class: 'tags-header' },
+        h2(i18n.blockchain),
+        p(i18n.blockchainDescription)
+      ),
+      div({ class: 'mode-buttons-row' },
+        div({ style: 'display:flex;flex-direction:column;gap:8px;' },
+          generateFilterButtons(BASE_FILTERS, filter, '/blockexplorer')
+        ),
+        div({ style: 'display:flex;flex-direction:column;gap:8px;' },
+          generateFilterButtons(CAT_BLOCK1, filter, '/blockexplorer'),
+          generateFilterButtons(CAT_BLOCK2, filter, '/blockexplorer')
+        ),
+        div({ style: 'display:flex;flex-direction:column;gap:8px;' },
+          generateFilterButtons(CAT_BLOCK3, filter, '/blockexplorer'),
+          generateFilterButtons(CAT_BLOCK4, filter, '/blockexplorer')
+        )
+      ),
+      div({ class: 'block-single' },
+        div({ class: 'block-row block-row--meta' },
+          span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockID}:`),
+          span({ class: 'blockchain-card-value' }, block.id)
+        ),
+        div({ class: 'block-row block-row--meta' },
+          span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockTimestamp}:`),
+          span({ class: 'blockchain-card-value' }, moment(block.ts).format('YYYY-MM-DDTHH:mm:ss.SSSZ')),
+          span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockType}:`),
+          span({ class: 'blockchain-card-value' }, (FILTER_LABELS[block.type]||block.type).toUpperCase())
+        ),
+        div({ class: 'block-row block-row--meta', style:'margin-top:8px;' },
+          a({ href:`/author/${encodeURIComponent(block.author)}`, class:'block-author user-link' }, block.author)
+        )
+      ),
+      div({ class:'block-row block-row--content' },
+        div({ class:'block-content-preview' },
+          pre({ class:'json-content' }, JSON.stringify(block.content,null,2))
+        )
+      ),
+      div({ class:'block-row block-row--back' },
+        form({ method:'GET', action:'/blockexplorer' },
+          button({ type:'submit', class:'filter-btn' }, `← ${i18n.blockchainBack}`)
+        ),
+        !block.isTombstoned && !block.isReplaced && getViewDetailsAction(block.type, block) ?
+          form({ method:'GET', action:getViewDetailsAction(block.type, block) },
+            button({ type:'submit', class:'filter-btn' }, i18n.visitContent)
+          )
+        : (block.isTombstoned || block.isReplaced) ?
+          div({ class: 'deleted-label', style: 'color:#b00;font-weight:bold;margin-top:8px;' },
+            i18n.blockchainContentDeleted || "This content has been deleted."
+          )
+        : null
+      )
+    )
+  );
+
+const renderBlockchainView = (blocks, filter, userId) =>
+  template(
+    i18n.blockchain,
+    section(
+      div({ class:'tags-header' },
+        h2(i18n.blockchain),
+        p(i18n.blockchainDescription)
+      ),
+      div({ class:'mode-buttons-row' },
+        div({ style:'display:flex;flex-direction:column;gap:8px;' },
+          generateFilterButtons(BASE_FILTERS,filter,'/blockexplorer')
+        ),
+        div({ style:'display:flex;flex-direction:column;gap:8px;' },
+          generateFilterButtons(CAT_BLOCK1,filter,'/blockexplorer'),
+          generateFilterButtons(CAT_BLOCK2,filter,'/blockexplorer')
+        ),
+        div({ style:'display:flex;flex-direction:column;gap:8px;' },
+          generateFilterButtons(CAT_BLOCK3,filter,'/blockexplorer'),
+          generateFilterButtons(CAT_BLOCK4,filter,'/blockexplorer')
+        )
+      ),
+      filterBlocks(blocks,filter,userId).length===0
+        ? div(p(i18n.blockchainNoBlocks))
+        : filterBlocks(blocks,filter,userId)
+            .sort((a,b)=>{
+              const ta = a.type==='market'&&a.content.updatedAt
+                ? new Date(a.content.updatedAt).getTime()
+                : a.ts;
+              const tb = b.type==='market'&&b.content.updatedAt
+                ? new Date(b.content.updatedAt).getTime()
+                : b.ts;
+              return tb - ta;
+            })
+            .map(block=>
+              div({ class:'block' },
+                div({ class:'block-buttons' },
+                  a({ href:`/blockexplorer/block/${encodeURIComponent(block.id)}`, class:'btn-singleview', title:i18n.blockchainDetails },'⦿'),
+                  !block.isTombstoned && !block.isReplaced && getViewDetailsAction(block.type, block) ?
                     form({ method:'GET', action:getViewDetailsAction(block.type, block) },
-                        button({ type:'submit', class:'filter-btn' }, i18n.visitContent)
+                      button({ type:'submit', class:'filter-btn' }, i18n.visitContent)
                     )
-                : (block.isTombstoned || block.isReplaced) ?
+                  : (block.isTombstoned || block.isReplaced) ?
                     div({ class: 'deleted-label', style: 'color:#b00;font-weight:bold;margin-top:8px;' },
-                        i18n.blockchainContentDeleted || "This content has been deleted."
+                      i18n.blockchainContentDeleted || "This content has been deleted."
                     )
-                : null
-            )
-        )
-    );
-
-const renderBlockchainView = (blocks, filter, userId) =>
-    template(
-        i18n.blockchain,
-        section(
-            div({ class:'tags-header' },
-                h2(i18n.blockchain),
-                p(i18n.blockchainDescription)
-            ),
-            div({ class:'mode-buttons-row' },
-                div({ style:'display:flex;flex-direction:column;gap:8px;' },
-                    generateFilterButtons(BASE_FILTERS,filter,'/blockexplorer')
-                ),
-                div({ style:'display:flex;flex-direction:column;gap:8px;' },
-                    generateFilterButtons(CAT_BLOCK1,filter,'/blockexplorer'),
-                    generateFilterButtons(CAT_BLOCK2,filter,'/blockexplorer')
+                  : null
                 ),
-                div({ style:'display:flex;flex-direction:column;gap:8px;' },
-                    generateFilterButtons(CAT_BLOCK3,filter,'/blockexplorer'),
-                    generateFilterButtons(CAT_BLOCK4,filter,'/blockexplorer')
+                div({ class:'block-row block-row--meta' },
+                  table({ class:'block-info-table' },
+                    tr(td({ class:'card-label' }, i18n.blockchainBlockTimestamp), td({ class:'card-value' }, moment(block.ts).format('YYYY-MM-DDTHH:mm:ss.SSSZ'))),
+                    tr(td({ class:'card-label' }, i18n.blockchainBlockID),        td({ class:'card-value' }, block.id)),
+                    tr(td({ class:'card-label' }, i18n.blockchainBlockType),      td({ class:'card-value' }, (FILTER_LABELS[block.type]||block.type).toUpperCase())),
+                    tr(td({ class:'card-label' }, i18n.blockchainBlockAuthor),    td({ class:'card-value' }, a({ href:`/author/${encodeURIComponent(block.author)}`, class:'block-author user-link' }, block.author)))
+                  )
                 )
-            ),
-            filterBlocks(blocks,filter,userId).length===0
-                ? div(p(i18n.blockchainNoBlocks))
-                : filterBlocks(blocks,filter,userId)
-                    .sort((a,b)=>{
-                        const ta = a.type==='market'&&a.content.updatedAt
-                            ? new Date(a.content.updatedAt).getTime()
-                            : a.ts;
-                        const tb = b.type==='market'&&b.content.updatedAt
-                            ? new Date(b.content.updatedAt).getTime()
-                            : b.ts;
-                        return tb - ta;
-                    })
-                    .map(block=>
-                        div({ class:'block' },
-                            div({ class:'block-buttons' },
-                                a({ href:`/blockexplorer/block/${encodeURIComponent(block.id)}`, class:'btn-singleview', title:i18n.blockchainDetails },'⦿'),
-                                !block.isTombstoned && !block.isReplaced && getViewDetailsAction(block.type, block) ?
-                                    form({ method:'GET', action:getViewDetailsAction(block.type, block) },
-                                        button({ type:'submit', class:'filter-btn' }, i18n.visitContent)
-                                    )
-                                : (block.isTombstoned || block.isReplaced) ?
-                                    div({ class: 'deleted-label', style: 'color:#b00;font-weight:bold;margin-top:8px;' },
-                                        i18n.blockchainContentDeleted || "This content has been deleted."
-                                    )
-                                : null
-                            ),
-                            div({ class:'block-row block-row--meta' },
-                                table({ class:'block-info-table' },
-                                    tr(td({ class:'card-label' }, i18n.blockchainBlockTimestamp), td({ class:'card-value' }, moment(block.ts).format('YYYY-MM-DDTHH:mm:ss.SSSZ'))),
-                                    tr(td({ class:'card-label' }, i18n.blockchainBlockID),        td({ class:'card-value' }, block.id)),
-                                    tr(td({ class:'card-label' }, i18n.blockchainBlockType),      td({ class:'card-value' }, (FILTER_LABELS[block.type]||block.type).toUpperCase())),
-                                    tr(td({ class:'card-label' }, i18n.blockchainBlockAuthor),    td({ class:'card-value' }, a({ href:`/author/${encodeURIComponent(block.author)}`, class:'block-author user-link' }, block.author)))
-                                )
-                            )
-                        )
-                    )
-        )
-    );
+              )
+            )
+    )
+  );
 
 module.exports = { renderBlockchainView, renderSingleBlockView };
 

+ 154 - 87
src/views/inhabitants_view.js

@@ -2,13 +2,46 @@ const { div, h2, p, section, button, form, img, a, textarea, input, br, span, st
 const { template, i18n } = require('./main_views');
 const { renderUrl } = require('../backend/renderUrl');
 
+const DEFAULT_HASH_ENC = "%260000000000000000000000000000000000000000000%3D.sha256";
+const DEFAULT_HASH_PATH_RE = /\/image\/\d+\/%260000000000000000000000000000000000000000000%3D\.sha256$/;
+
+function isDefaultImageId(v){
+  if (!v) return true;
+  if (typeof v === 'string') {
+    if (v === DEFAULT_HASH_ENC) return true;
+    if (DEFAULT_HASH_PATH_RE.test(v)) return true;
+  }
+  return false;
+}
+
+function toImageUrl(imgId, size=256){
+  if (!imgId || isDefaultImageId(imgId)) return '/assets/images/default-avatar.png';
+  if (typeof imgId === 'string' && imgId.startsWith('/image/')) {
+    return imgId.replace('/image/256/','/image/'+size+'/').replace('/image/512/','/image/'+size+'/');
+  }
+  return `/image/${size}/${encodeURIComponent(imgId)}`;
+}
+
+function extractAboutImageId(about){
+  if (!about || typeof about !== 'object') return null;
+  const aimg = about.image;
+  if (!aimg) return null;
+  if (typeof aimg === 'string') return aimg;
+  return aimg.link || aimg.url || null;
+}
+
 function resolvePhoto(photoField, size = 256) {
-  if (photoField == "/image/256/%260000000000000000000000000000000000000000000%3D.sha256"){
-    return '/assets/images/default-avatar.png';
-  } else {
-    return photoField;
+  if (!photoField) return '/assets/images/default-avatar.png';
+  if (typeof photoField === 'string') {
+    if (photoField.startsWith('/assets/')) return photoField;
+    if (photoField.startsWith('/blob/')) return photoField;
+    if (photoField.startsWith('/image/')) {
+      if (isDefaultImageId(photoField)) return '/assets/images/default-avatar.png';
+      return photoField.replace('/image/256/','/image/'+size+'/').replace('/image/512/','/image/'+size+'/');
+    }
   }
-};
+  return toImageUrl(photoField, size);
+}
 
 const generateFilterButtons = (filters, currentFilter) =>
   filters.map(mode =>
@@ -21,14 +54,6 @@ const generateFilterButtons = (filters, currentFilter) =>
     )
   );
 
-function formatRange(bucket, i18n) {
-  const ws = i18n.weeksShort || 'w';
-  const ms = i18n.monthsShort || 'm';
-  if (bucket === 'green') return `<2 ${ws}`;
-  if (bucket === 'orange') return `2 ${ws}–6 ${ms}`;
-  return `≥6 ${ms}`;
-}
-
 function lastActivityBadge(user) {
   const label = i18n.inhabitantActivityLevel;
   const bucket = user.lastActivityBucket || 'red';
@@ -40,21 +65,23 @@ function lastActivityBadge(user) {
   );
 }
 
+const lightboxId = (id) => 'inhabitant_' + String(id || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '_');
+
 const renderInhabitantCard = (user, filter, currentUserId) => {
   const isMe = user.id === currentUserId;
   return div({ class: 'inhabitant-card' },
     div({ class: 'inhabitant-left' },
       a(
          { href: `/author/${encodeURIComponent(user.id)}` },
-         img({ class: 'inhabitant-photo-details', src: resolvePhoto(user.photo), alt: user.name })
+         img({ class: 'inhabitant-photo-details', src: resolvePhoto(user.photo, 256), alt: user.name || 'Anonymous' })
       ),
       br(),
       span(`${i18n.bankingUserEngagementScore}: `),
-     h2(strong(typeof user.karmaScore === 'number' ? user.karmaScore : 0)),
-     lastActivityBadge(user)
+      h2(strong(typeof user.karmaScore === 'number' ? user.karmaScore : 0)),
+      lastActivityBadge(user)
     ),
     div({ class: 'inhabitant-details' },
-      h2(user.name),
+      h2(user.name || 'Anonymous'),
       user.description ? p(...renderUrl(user.description)) : null,
       filter === 'MATCHSKILLS' && user.commonSkills?.length
         ? div({ class: 'matchskills' },
@@ -75,15 +102,13 @@ const renderInhabitantCard = (user, filter, currentUserId) => {
             p(i18n.ecoWalletNotConfigured || "ECOin Wallet not configured")
           ),
       div(
-        { class: 'cv-actions', style: 'display:flex; flex-direction:column; gap:8px; margin-top:12px;' },
-        isMe
-          ? p(i18n.relationshipYou)
-          : (filter === 'CVs' || filter === 'MATCHSKILLS' || filter === 'SUGGESTED' || filter === 'TOP KARMA')
-            ? form(
-                { method: 'GET', action: `/inhabitant/${encodeURIComponent(user.id)}` },
-                button({ type: 'submit', class: 'btn' }, i18n.inhabitantviewDetails)
-              )
-            : null,
+        { class: 'cv-actions' },
+        !isMe
+          ? form(
+              { method: 'GET', action: `/inhabitant/${encodeURIComponent(user.id)}` },
+              button({ type: 'submit', class: 'btn' }, i18n.inhabitantviewDetails)
+            )
+          : p(i18n.relationshipYou),
         !isMe
           ? form(
               { method: 'GET', action: '/pm' },
@@ -98,11 +123,11 @@ const renderInhabitantCard = (user, filter, currentUserId) => {
 
 const renderGalleryInhabitants = inhabitants =>
   div(
-    { class: "gallery", style: 'display:grid; grid-template-columns: repeat(3, 1fr); gap:16px;' },
+    { class: "gallery" },
     inhabitants.length
       ? inhabitants.map(u =>
-          a({ href: `#inhabitant-${encodeURIComponent(u.id)}`, class: "gallery-item" },
-            img({ src: resolvePhoto(u.photo), alt: u.name || "Anonymous", class: "gallery-image" })
+          a({ href: `#${lightboxId(u.id)}`, class: "gallery-item" },
+            img({ src: resolvePhoto(u.photo, 256), alt: u.name || "Anonymous", class: "gallery-image" })
           )
         )
       : p(i18n.noInhabitantsFound)
@@ -111,12 +136,27 @@ const renderGalleryInhabitants = inhabitants =>
 const renderLightbox = inhabitants =>
   inhabitants.map(u =>
     div(
-      { id: `inhabitant-${encodeURIComponent(u.id)}`, class: "lightbox" },
+      { id: lightboxId(u.id), class: "lightbox" },
       a({ href: "#", class: "lightbox-close" }, "×"),
-      img({ src: resolvePhoto(u.photo), class: "lightbox-image", alt: u.name || "Anonymous" })
+      img({ src: resolvePhoto(u.photo, 256), class: "lightbox-image", alt: u.name || "Anonymous" })
     )
   );
 
+function stripAndCollectImgs(text) {
+  if (!text || typeof text !== 'string') return { clean: '', imgs: [] };
+  const imgs = [];
+  let clean = text;
+  const rawImgRe = /<img[^>]*src="([^"]+)"[^>]*>/gi;
+  clean = clean.replace(rawImgRe, (_, src) => { imgs.push(src); return ''; });
+  const encImgRe = /&lt;img[^&]*src=&quot;([^&]*)&quot;[^&]*&gt;/gi;
+  clean = clean.replace(encImgRe, (_, src) => { imgs.push(src.replace(/&amp;/g, '&')); return ''; });
+  return { clean, imgs };
+}
+
+function msgIdOf(m) {
+  return m && (m.key || m.value?.key || m.value?.content?.root || m.value?.content?.branch || null);
+}
+
 exports.inhabitantsView = (inhabitants, filter, query, currentUserId) => {
   const title = filter === 'contacts'    ? i18n.yourContacts
                : filter === 'CVs'         ? i18n.allCVs
@@ -125,8 +165,8 @@ exports.inhabitantsView = (inhabitants, filter, query, currentUserId) => {
                : filter === 'blocked'     ? i18n.blockedSectionTitle
                : filter === 'GALLERY'     ? i18n.gallerySectionTitle
                : filter === 'TOP KARMA'    ? i18n.topkarmaSectionTitle
-               : filter === 'TOP ACTIVITY' ? (i18n.topactivitySectionTitle)
-                                          : i18n.allInhabitants;
+               : filter === 'TOP ACTIVITY' ? i18n.topactivitySectionTitle
+               : i18n.allInhabitants;
 
   const showCVFilters = filter === 'CVs' || filter === 'MATCHSKILLS';
   const filters = ['all', 'TOP ACTIVITY', 'TOP KARMA', 'contacts', 'SUGGESTED', 'blocked', 'CVs', 'MATCHSKILLS', 'GALLERY'];
@@ -145,20 +185,20 @@ exports.inhabitantsView = (inhabitants, filter, query, currentUserId) => {
             type: 'text',
             name: 'search',
             placeholder: i18n.searchInhabitantsPlaceholder,
-            value: query.search || ''
+            value: (query && query.search) || ''
           }),
           showCVFilters
             ? [
-                input({ type: 'text', name: 'location', placeholder: i18n.filterLocation, value: query.location || '' }),
-                input({ type: 'text', name: 'language', placeholder: i18n.filterLanguage, value: query.language || '' }),
-                input({ type: 'text', name: 'skills', placeholder: i18n.filterSkills, value: query.skills || '' })
+                input({ type: 'text', name: 'location', placeholder: i18n.filterLocation, value: (query && query.location) || '' }),
+                input({ type: 'text', name: 'language', placeholder: i18n.filterLanguage, value: (query && query.language) || '' }),
+                input({ type: 'text', name: 'skills', placeholder: i18n.filterSkills, value: (query && query.skills) || '' })
               ]
             : null,
           br(),
           button({ type: 'submit' }, i18n.applyFilters)
         )
       ),
-      div({ class: 'inhabitant-action', style: 'margin-top:1em;' },
+      div({ class: 'inhabitant-action' },
         ...generateFilterButtons(filters, filter)
       ),
       filter === 'GALLERY'
@@ -173,37 +213,58 @@ exports.inhabitantsView = (inhabitants, filter, query, currentUserId) => {
   );
 };
 
-exports.inhabitantsProfileView = ({ about = {}, cv = {}, feed = [] }, currentUserId) => {
-  const profile = Object.keys(cv).length ? cv : about;
-  const id = cv.author || about.about || 'unknown';
-  const name = cv.name || about.name || 'Unnamed';
-  const description = cv.description || about.description || '';
-  const image = resolvePhoto(cv.photo) || '/assets/images/default-oasis.jpg';
-  const location = cv.location || '';
-  const languages = typeof cv.languages === 'string'
-    ? cv.languages.split(',').map(x => x.trim()).filter(Boolean)
-    : Array.isArray(cv.languages) ? cv.languages : [];
+exports.inhabitantsProfileView = (payload, currentUserId) => {
+  const safe = payload && typeof payload === 'object' ? payload : {};
+  const about = (safe.about && typeof safe.about === 'object') ? safe.about : {};
+  const cv = (safe.cv && typeof safe.cv === 'object') ? safe.cv : {};
+  const feed = Array.isArray(safe.feed) ? safe.feed : [];
+
+  const viewedId = typeof safe.viewedId === 'string' ? safe.viewedId : '';
+  const id = (cv && cv.author) || (about && about.about) || viewedId || '';
+  const baseName = ((cv && cv.name) || (about && about.name) || '').trim();
+  const name = baseName || (i18n.unnamed || 'Anonymous');
+  const description = (cv && cv.description) || (about && about.description) || '';
+
+  const listPhoto = (typeof safe.photo === 'string' && safe.photo.trim()) ? safe.photo : null;
+  const rawCandidate = listPhoto || extractAboutImageId(about) || (cv && cv.photo) || null;
+  const image = (
+    typeof rawCandidate === 'string' &&
+    rawCandidate.startsWith('/image/') &&
+    !DEFAULT_HASH_PATH_RE.test(rawCandidate) &&
+    rawCandidate.indexOf(DEFAULT_HASH_ENC) === -1
+  )
+    ? rawCandidate.replace('/image/512/','/image/256/').replace('/image/1024/','/image/256/')
+    : resolvePhoto(rawCandidate, 256);
+
+  const location = (cv && cv.location) || '';
+  const languages = typeof (cv && cv.languages) === 'string'
+    ? (cv.languages || '').split(',').map(x => x.trim()).filter(Boolean)
+    : Array.isArray(cv && cv.languages) ? cv.languages : [];
   const skills = [
-    ...(cv.personalSkills || []),
-    ...(cv.oasisSkills || []),
-    ...(cv.educationalSkills || []),
-    ...(cv.professionalSkills || [])
+    ...((cv && cv.personalSkills) || []),
+    ...((cv && cv.oasisSkills) || []),
+    ...((cv && cv.educationalSkills) || []),
+    ...((cv && cv.professionalSkills) || [])
   ];
-  const status = cv.status || '';
-  const preferences = cv.preferences || '';
-  const createdAt = cv.createdAt ? new Date(cv.createdAt).toLocaleString() : '';
-  const isMe = id === currentUserId;
+  const status = (cv && cv.status) || '';
+  const preferences = (cv && cv.preferences) || '';
+  const createdAt = (cv && cv.createdAt) ? new Date(cv.createdAt).toLocaleString() : '';
+  const isMe = id && id === currentUserId;
   const title = i18n.inhabitantProfileTitle || i18n.inhabitantviewDetails;
+  const karmaScore = typeof safe.karmaScore === 'number' ? safe.karmaScore : 0;
 
-  const lastFromFeed = Array.isArray(feed) && feed.length ? feed.reduce((mx, m) => Math.max(mx, m.value?.timestamp || 0), 0) : null;
-  const now = Date.now();
-  const delta = lastFromFeed ? Math.max(0, now - lastFromFeed) : Number.POSITIVE_INFINITY;
-  const days = delta / 86400000;
-  const bucket = days < 14 ? 'green' : days < 182.5 ? 'orange' : 'red';
-  const ws = i18n.weeksShort || 'w';
-  const ms = i18n.monthsShort || 'm';
-  const range = bucket === 'green' ? `<2 ${ws}` : bucket === 'orange' ? `2 ${ws}–6 ${ms}` : `≥6 ${ms}`;
-  const dotClass = bucket === 'green' ? 'green' : bucket === 'orange' ? 'orange' : 'red';
+  const providedBucket = typeof safe.lastActivityBucket === 'string' ? safe.lastActivityBucket : null;
+  const dotClass = providedBucket === 'green' ? 'green' : providedBucket === 'orange' ? 'orange' : 'red';
+
+  const detailNodes = [
+    description ? p(...renderUrl(description)) : null,
+    location ? p(`${i18n.locationLabel}: ${location}`) : null,
+    languages.length ? p(`${i18n.languagesLabel}: ${languages.join(', ').toUpperCase()}`) : null,
+    skills.length ? p(`${i18n.skillsLabel}: ${skills.join(', ')}`) : null,
+    status ? p(`${i18n.statusLabel || 'Status'}: ${status}`) : null,
+    preferences ? p(`${i18n.preferencesLabel || 'Preferences'}: ${preferences}`) : null,
+    createdAt ? p(`${i18n.createdAtLabel || 'Created at'}: ${createdAt}`) : null
+  ].filter(Boolean);
 
   return template(
     name,
@@ -212,45 +273,51 @@ exports.inhabitantsProfileView = ({ about = {}, cv = {}, feed = [] }, currentUse
         h2(title),
         p(i18n.discoverPeople)
       ),
-      div({ class: 'mode-buttons', style: 'display:flex; gap:8px; margin-top:16px;' },
-        ...generateFilterButtons(['all', 'TOP KARMA', 'contacts', 'SUGGESTED', 'blocked', 'CVs', 'MATCHSKILLS', 'GALLERY'], 'all')
+      div({ class: 'mode-buttons' },
+        ...generateFilterButtons(['all', 'TOP ACTIVITY', 'TOP KARMA', 'contacts', 'SUGGESTED', 'blocked', 'CVs', 'MATCHSKILLS', 'GALLERY'], 'all')
       ),
-      div({ class: 'inhabitant-card', style: 'margin-top:32px;' },
-        div({ class: 'inhabitant-details' },
-          img({ class: 'inhabitant-photo-details', src: image, alt: name }),
-          h2(name),
-          p(a({ class: 'user-link', href: `/author/${encodeURIComponent(id)}` }, id)),
-          description ? p(...renderUrl(description)) : null,
-          location ? p(`${i18n.locationLabel}: ${location}`) : null,
-          languages.length ? p(`${i18n.languagesLabel}: ${languages.join(', ').toUpperCase()}`) : null,
-          skills.length ? p(`${i18n.skillsLabel}: ${skills.join(', ')}`) : null,
+      div({ class: 'inhabitant-card' },
+        div({ class: 'inhabitant-left' },
+          img({ class: 'inhabitant-photo-details', src: image, alt: name || 'Anonymous' }),
+          h2(name || 'Anonymous'),
+          span(`${i18n.bankingUserEngagementScore}: `),
+          h2(strong(karmaScore)),
           div(
             { class: 'inhabitant-last-activity' },
             span({ class: 'label' }, `${i18n.inhabitantActivityLevel}:`),
-            span({ class: `activity-dot ${dotClass}` }, ''),
-            span({ class: 'range' }, range)
+            span({ class: `activity-dot ${dotClass}` }, '')
           ),
-          status ? p(`${i18n.statusLabel || 'Status'}: ${status}`) : null,
-          preferences ? p(`${i18n.preferencesLabel || 'Preferences'}: ${preferences}`) : null,
-          createdAt ? p(`${i18n.createdAtLabel || 'Created at'}: ${createdAt}`) : null,
-          !isMe
+          (!isMe && (id || viewedId))
             ? form(
                 { method: 'GET', action: '/pm' },
-                input({ type: 'hidden', name: 'recipients', value: id }),
-                button({ type: 'submit', class: 'btn', style: 'margin-top:1em;' }, i18n.pmCreateButton)
+                input({ type: 'hidden', name: 'recipients', value: id || viewedId }),
+                button({ type: 'submit', class: 'btn' }, i18n.pmCreateButton)
               )
             : null
-        )
+        ),
+        detailNodes.length ? div({ class: 'inhabitant-details' }, ...detailNodes) : null
       ),
       feed.length
         ? section({ class: 'profile-feed' },
             h2(i18n.latestInteractions),
             ...feed.map(m => {
-              const text = (m.value.content.text || '').replace(/<br\s*\/?>/g, '');
-              return div({ class: 'post' }, p(...renderUrl(text)));
+              const raw = (m.value?.content?.text || '').replace(/<br\s*\/?>/g, '');
+              const parts = stripAndCollectImgs(raw);
+              const tid = msgIdOf(m);
+              const visitBtn = tid
+                ? form({ method: 'GET', action: `/thread/${encodeURIComponent(tid)}#${encodeURIComponent(tid)}` },
+                    button({ type:'submit', class:'filter-btn' }, i18n.visitContent)
+                  )
+                : null;
+              return div({ class: 'post' },
+                visitBtn,
+                parts.clean && parts.clean.trim() ? p(...renderUrl(parts.clean)) : null,
+                ...(parts.imgs || []).map(src => img({ src, class: 'post-image', alt: 'image' }))
+              );
             })
           )
         : null
     )
   );
 };
+

+ 2 - 49
src/views/parliament_view.js

@@ -24,11 +24,8 @@ const showVoteMetrics = (method) => {
   const m = String(method || '').toUpperCase();
   return !(m === 'DICTATORSHIP' || m === 'KARMATOCRACY');
 };
-
 const applyEl = (fn, attrs, kids) => fn.apply(null, [attrs || {}].concat(kids || []));
-
 const methodImageSrc = (method) => `assets/images/${String(method || '').toUpperCase().toLowerCase()}.png`;
-
 const MethodBadge = (method) => {
   const m = String(method || '').toUpperCase();
   const label = String(i18n[`parliamentMethod${m}`] || m).toUpperCase();
@@ -39,7 +36,6 @@ const MethodBadge = (method) => {
     img({ src: methodImageSrc(m), alt: label, class: 'method-badge__icon' })
   );
 };
-
 const MethodHero = (method) => {
   const m = String(method || '').toUpperCase();
   const label = String(i18n[`parliamentMethod${m}`] || m).toUpperCase();
@@ -50,13 +46,11 @@ const MethodHero = (method) => {
     img({ src: methodImageSrc(m), alt: label, class: 'method-hero__icon' })
   );
 };
-
 const KPI = (label, value) =>
   div({ class: 'kpi' },
     span({ class: 'kpi__label' }, label),
     span({ class: 'kpi__value' }, value)
   );
-
 const CycleInfo = (start, end, labels = {
   since: i18n.parliamentLegSince,
   end: i18n.parliamentLegEnd,
@@ -67,7 +61,6 @@ const CycleInfo = (start, end, labels = {
     KPI((labels.end + ': ').toUpperCase(), fmt(end)),
     KPI((labels.remaining + ': ').toUpperCase(), timeLeft(end))
   );
-
 const Tabs = (active) =>
   div(
     { class: 'filters' },
@@ -78,7 +71,6 @@ const Tabs = (active) =>
       )
     )
   );
-
 const GovHeader = (g) => {
   const termStart = g && g.since ? g.since : moment().toISOString();
   const termEnd = g && g.end ? g.end : moment(termStart).add(1, 'minutes').toISOString();
@@ -91,7 +83,6 @@ const GovHeader = (g) => {
   const votesReceivedNum = Number.isFinite(Number(g.votesReceived)) ? Number(g.votesReceived) : 0;
   const totalVotesNum = Number.isFinite(Number(g.totalVotes)) ? Number(g.totalVotes) : 0;
   const votesDisplay = `${votesReceivedNum} (${totalVotesNum})`;
-
   return div(
     { class: 'cycle-info' },
     div({ class: 'kpi' },
@@ -122,29 +113,23 @@ const GovHeader = (g) => {
       : null
   );
 };
-
 const GovernmentCard = (g, meta) => {
   const termStart = g && g.since ? g.since : moment().toISOString();
   const termEnd   = g && g.end   ? g.end   : moment(termStart).add(1, 'minutes').toISOString();
-
   const actorLabel =
     g.powerType === 'tribe'
       ? (i18n.parliamentActorInPowerTribe || i18n.parliamentActorInPower || 'TRIBE RULING')
       : (i18n.parliamentActorInPowerInhabitant || i18n.parliamentActorInPower || 'INHABITANT RULING');
-
   const methodKeyRaw = g && g.method ? String(g.method) : 'ANARCHY';
   const methodKey    = methodKeyRaw.toUpperCase();
   const i18nMeth     = i18n[`parliamentMethod${methodKey}`];
   const methodLabel  = (i18nMeth && String(i18nMeth).trim() ? String(i18nMeth) : methodKey).toUpperCase();
-
   const actorLink =
     g.powerType === 'tribe'
       ? a({ class: 'user-link', href: `/tribe/${encodeURIComponent(g.powerId)}` }, g.powerTitle || g.powerId)
       : a({ class: 'user-link', href: `/author/${encodeURIComponent(g.powerId)}` }, g.powerTitle || g.powerId);
-
   const actorBio = meta && meta.bio ? meta.bio : '';
   const memberIds = Array.isArray(g.membersList) ? g.membersList : (Array.isArray(g.members) ? g.members : []);
-
   const membersRow =
     g.powerType === 'tribe'
       ? tr(
@@ -160,7 +145,6 @@ const GovernmentCard = (g, meta) => {
           )
         )
       : null;
-
   return div(
     { class: 'card' },
     h2(i18n.parliamentGovernmentCard),
@@ -208,13 +192,11 @@ const GovernmentCard = (g, meta) => {
       : null
   );
 };
-
 const NoGovernment = () => div({ class: 'empty' }, p(i18n.parliamentNoStableGov));
 const NoProposals = () => div({ class: 'empty' }, p(i18n.parliamentNoProposals));
 const NoLaws = () => div({ class: 'empty' }, p(i18n.parliamentNoLaws));
 const NoGovernments = () => div({ class: 'empty' }, p(i18n.parliamentNoGovernments));
 const NoRevocations = () => null;
-
 const CandidatureForm = () =>
   div(
     { class: 'div-center' },
@@ -230,7 +212,6 @@ const CandidatureForm = () =>
       button({ type: 'submit', class: 'create-button' }, i18n.parliamentCandidatureProposeBtn)
     )
   );
-
 const pickLeader = (arr) => {
   if (!arr || !arr.length) return null;
   const sorted = [...arr].sort((a, b) => {
@@ -246,37 +227,30 @@ const pickLeader = (arr) => {
   });
   return sorted[0];
 };
-
 const CandidatureStats = (cands, govCard, leaderMeta) => {
   if (!cands || !cands.length) return null;
-
   const leader      = pickLeader(cands || []);
   const methodKey   = String(leader.method || '').toUpperCase();
   const methodLabel = String(i18n[`parliamentMethod${methodKey}`] || methodKey).toUpperCase();
   const votes       = String(leader.votes || 0);
   const avatarSrc   = (leaderMeta && leaderMeta.avatarUrl) ? leaderMeta.avatarUrl : '/assets/images/default-avatar.png';
-
   const winLbl = (i18n.parliamentWinningCandidature || i18n.parliamentCurrentLeader || 'WINNING CANDIDATURE').toUpperCase();
   const idLink = leader
     ? (leader.targetType === 'inhabitant'
         ? a({ class: 'user-link', href: `/author/${encodeURIComponent(leader.targetId)}` }, leader.targetId)
         : a({ class: 'tag-link', href: `/tribe/${encodeURIComponent(leader.targetId)}?` }, leader.targetTitle || leader.targetId))
     : null;
-
   return div(
     { class: 'card' },
     h2(i18n.parliamentElectionsStatusTitle),
-
     div({ class: 'card-field card-field--spaced' },
       span({ class: 'card-label' }, winLbl + ': '),
       span({ class: 'card-value' }, idLink)
     ),
-
     div({ class: 'card-field card-field--spaced' },
       span({ class: 'card-label' }, (i18n.parliamentGovMethod + ': ').toUpperCase()),
       span({ class: 'card-value' }, methodLabel)
     ),
-
     div(
       { class: 'table-wrap mt-2' },
       applyEl(table, [
@@ -298,7 +272,6 @@ const CandidatureStats = (cands, govCard, leaderMeta) => {
     )
   );
 };
-
 const CandidaturesTable = (candidatures) => {
   const rows = (candidatures || []).map(c => {
     const idLink =
@@ -330,7 +303,6 @@ const CandidaturesTable = (candidatures) => {
     ])
   );
 };
-
 const ProposalForm = () =>
   div(
     { class: 'div-center' },
@@ -344,7 +316,6 @@ const ProposalForm = () =>
       button({ type: 'submit', class: 'create-button' }, i18n.parliamentProposalPublish)
     )
   );
-
 const ProposalsList = (proposals) => {
   if (!proposals || !proposals.length) return null;
   const cards = proposals.map(pItem =>
@@ -374,7 +345,6 @@ const ProposalsList = (proposals) => {
     applyEl(div, null, cards)
   );
 };
-
 const FutureLawsList = (rows) => {
   if (!rows || !rows.length) return null;
   const cards = rows.map(pItem =>
@@ -394,7 +364,6 @@ const FutureLawsList = (rows) => {
     applyEl(div, null, cards)
   );
 };
-
 const RevocationForm = (laws = []) =>
   div(
     { class: 'div-center' },
@@ -421,7 +390,6 @@ const RevocationForm = (laws = []) =>
       button({ type: 'submit', class: 'create-button' }, i18n.parliamentRevocationPublish || 'Publish Revocation')
     )
   );
-
 const RevocationsList = (revocations) => {
   if (!revocations || !revocations.length) return null;
   const cards = revocations.map(pItem =>
@@ -493,7 +461,6 @@ const RevocationsList = (revocations) => {
     applyEl(div, null, cards)
   );
 };
-
 const FutureRevocationsList = (rows) => {
   if (!rows || !rows.length) return null;
   const cards = rows.map(pItem =>
@@ -513,7 +480,6 @@ const FutureRevocationsList = (rows) => {
     applyEl(div, null, cards)
   );
 };
-
 const LawsStats = (laws = [], revocatedCount = 0) => {
   const proposed = laws.length;
   const approved = laws.length;
@@ -543,7 +509,6 @@ const LawsStats = (laws = [], revocatedCount = 0) => {
     ])
   );
 };
-
 const LawsList = (laws) => {
   if (!laws || !laws.length) return NoLaws();
   const cards = laws.map(l => {
@@ -569,7 +534,6 @@ const LawsList = (laws) => {
     applyEl(div, null, cards)
   );
 };
-
 const HistoricalGovsSummary = (rows = []) => {
   const byMethod = new Map();
   for (const g of rows) {
@@ -589,7 +553,6 @@ const HistoricalGovsSummary = (rows = []) => {
     ])
   );
 };
-
 const HistoricalList = (rows, metasByKey = {}) => {
   if (!rows || !rows.length) return NoGovernments();
   const cards = rows.map(g => {
@@ -672,7 +635,6 @@ const HistoricalList = (rows, metasByKey = {}) => {
     applyEl(div, null, cards)
   );
 };
-
 const countCandidaturesByActor = (cands = []) => {
   const m = new Map();
   for (const c of cands) {
@@ -681,7 +643,6 @@ const countCandidaturesByActor = (cands = []) => {
   }
   return m;
 };
-
 const LeadersSummary = (leaders = [], candidatures = []) => {
   const candCounts = countCandidaturesByActor(candidatures);
   const totals = leaders.reduce((acc, l) => {
@@ -727,7 +688,6 @@ const LeadersSummary = (leaders = [], candidatures = []) => {
     ])
   );
 };
-
 const LeadersList = (leaders, metas = {}, candidatures = []) => {
   if (!leaders || !leaders.length) return div({ class: 'empty' }, p(i18n.parliamentNoLeaders));
   const rows = leaders.map(l => {
@@ -765,7 +725,6 @@ const LeadersList = (leaders, metas = {}, candidatures = []) => {
     ])
   );
 };
-
 const RulesContent = () =>
   div(
     { class: 'card' },
@@ -786,7 +745,6 @@ const RulesContent = () =>
       li(i18n.parliamentRulesLeaders)
     )
   );
-
 const CandidaturesSection = (governmentCard, candidatures, leaderMeta) => {
   const termStart = governmentCard && governmentCard.since ? governmentCard.since : moment().toISOString();
   const termEnd   = governmentCard && governmentCard.end   ? governmentCard.end   : moment(termStart).add(1, 'minutes').toISOString();
@@ -798,7 +756,6 @@ const CandidaturesSection = (governmentCard, candidatures, leaderMeta) => {
     candidatures && candidatures.length ? CandidaturesTable(candidatures) : null
   );
 };
-
 const ProposalsSection = (governmentCard, proposals, futureLaws, canPropose) => {
   const has = proposals && proposals.length > 0;
   const fl = FutureLawsList(futureLaws || []);
@@ -806,7 +763,6 @@ const ProposalsSection = (governmentCard, proposals, futureLaws, canPropose) =>
   if (!has && !canPropose) return div(h2(i18n.parliamentGovernmentCard), GovHeader(governmentCard || {}), NoProposals(), fl);
   return div(h2(i18n.parliamentGovernmentCard), GovHeader(governmentCard || {}), ProposalForm(), ProposalsList(proposals), fl);
 };
-
 const RevocationsSection = (governmentCard, laws, revocations, futureRevocations) =>
   div(
     h2(i18n.parliamentGovernmentCard),
@@ -815,8 +771,7 @@ const RevocationsSection = (governmentCard, laws, revocations, futureRevocations
     RevocationsList(revocations || []) || '',
     FutureRevocationsList(futureRevocations || []) || ''
   );
-
-exports.parliamentView = async (state) => {
+const parliamentView = async (state) => {
   const {
     filter,
     governmentCard,
@@ -835,13 +790,11 @@ exports.parliamentView = async (state) => {
     historicalMetas = {},
     leadersMetas = {}
   } = state;
-
   const LawsSectionWrap = () =>
     div(
       LawsStats(laws || [], revocationsEnactedCount || 0),
       LawsList(laws || [])
     );
-
   const fallbackAnarchy = {
     method: 'ANARCHY',
     votesReceived: 0,
@@ -853,7 +806,6 @@ exports.parliamentView = async (state) => {
     revocated: 0,
     efficiency: 0
   };
-
   return template(
     i18n.parliamentTitle,
     section(div({ class: 'tags-header' }, h2(i18n.parliamentTitle), p(i18n.parliamentDescription)), Tabs(filter)),
@@ -869,4 +821,5 @@ exports.parliamentView = async (state) => {
     )
   );
 };
+module.exports = { parliamentView, pickLeader };
 

+ 66 - 70
src/views/stats_view.js

@@ -13,9 +13,7 @@ exports.statsView = (stats, filter) => {
     'image', 'audio', 'video', 'document', 'transfer', 'post', 'tribe',
     'market', 'forum', 'job', 'aiExchange'
   ];
-  const totalContent = types
-  .filter(t => t !== 'karmaScore') 
-  .reduce((sum, t) => sum + C(stats, t), 0);
+  const totalContent = types.filter(t => t !== 'karmaScore').reduce((sum, t) => sum + C(stats, t), 0);
   const totalOpinions = types.reduce((sum, t) => sum + O(stats, t), 0);
   const blockStyle = 'padding:16px;border:1px solid #ddd;border-radius:8px;margin-bottom:24px;';
   const headerStyle = 'background-color:#f8f9fa; padding:24px; border-radius:8px; border:1px solid #e0e0e0; box-shadow:0 2px 8px rgba(0,0,0,0.1);';
@@ -26,7 +24,6 @@ exports.statsView = (stats, filter) => {
         h2(title),
         p(description)
       ),
-
       div({ class: 'mode-buttons', style: 'display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:16px;margin-bottom:24px;' },
         modes.map(m =>
           form({ method: 'GET', action: '/stats' },
@@ -43,77 +40,47 @@ exports.statsView = (stats, filter) => {
           ),
           div({ style: 'margin-bottom:16px;' },
             ul({ style: 'list-style-type:none; padding:0; margin:0;' },
-              li({ style: 'font-size:18px; color:#555; margin:8px 0;' },
-                `${i18n.statsBlobsSize}: `,
-                span({ style: 'color:#888;' }, stats.statsBlobsSize)
-              ),
-              li({ style: 'font-size:18px; color:#555; margin:8px 0;' },
-                `${i18n.statsBlockchainSize}: `,
-                span({ style: 'color:#888;' }, stats.statsBlockchainSize)
-              ),
-              li({ style: 'font-size:18px; color:#555; margin:8px 0;' },
-                strong(`${i18n.statsSize}: `, span({ style: 'color:#888;' }, span({ style: 'color:#555;' }, stats.folderSize)))
-              )
+              li({ style: 'font-size:18px; color:#555; margin:8px 0;' }, `${i18n.statsBlobsSize}: `, span({ style: 'color:#888;' }, stats.statsBlobsSize)),
+              li({ style: 'font-size:18px; color:#555; margin:8px 0;' }, `${i18n.statsBlockchainSize}: `, span({ style: 'color:#888;' }, stats.statsBlockchainSize)),
+              li({ style: 'font-size:18px; color:#555; margin:8px 0;' }, strong(`${i18n.statsSize}: `, span({ style: 'color:#888;' }, span({ style: 'color:#555;' }, stats.folderSize))))
             )
           )
         ),
-        
         div({ style: headerStyle }, h3(`${i18n.bankingUserEngagementScore}: ${C(stats, 'karmaScore')}`)),
-
         div({ style: headerStyle },
           h3({ style: 'font-size:18px; color:#555; margin:8px 0; font-weight:600;' }, i18n.statsBankingTitle),
           ul({ style: 'list-style-type:none; padding:0; margin:0;' },
-		li({ style: 'font-size:18px; color:#555; margin:8px 0;' },
-		  `${i18n.statsEcoWalletLabel}: `,
-		  a(
-		    { 
-		      href: '/wallet',
-		      style: 'color:#007bff; text-decoration:none; word-break:break-all;' 
-		    },
-		    stats?.banking?.myAddress || i18n.statsEcoWalletNotConfigured
-		  )
-		),
-            li({ style: 'font-size:18px; color:#555; margin:8px 0;' },
-              `${i18n.statsTotalEcoAddresses}: `,
-              span({ style: 'color:#888;' }, String(stats?.banking?.totalAddresses || 0))
-            )
+            li({ style: 'font-size:18px; color:#555; margin:8px 0;' }, `${i18n.statsEcoWalletLabel}: `, a({ href: '/wallet', style: 'color:#007bff; text-decoration:none; word-break:break-all;' }, stats?.banking?.myAddress || i18n.statsEcoWalletNotConfigured)),
+            li({ style: 'font-size:18px; color:#555; margin:8px 0;' }, `${i18n.statsTotalEcoAddresses}: `, span({ style: 'color:#888;' }, String(stats?.banking?.totalAddresses || 0)))
           )
         ),
-
         div({ style: headerStyle },
           h3({ style: 'font-size:18px; color:#555; margin:8px 0; font-weight:600;' }, i18n.statsAITraining),
           ul({ style: 'list-style-type:none; padding:0; margin:0;' },
-            li({ style: 'font-size:18px; color:#555; margin:8px 0;' },
-              `${i18n.statsAIExchanges}: `,
-              span({ style: 'color:#888;' }, String(C(stats, 'aiExchange') || 0))
-            )
+            li({ style: 'font-size:18px; color:#555; margin:8px 0;' }, `${i18n.statsAIExchanges}: `, span({ style: 'color:#888;' }, String(C(stats, 'aiExchange') || 0)))
           )
-        ),  
-        
-        div({ style: headerStyle },
-	  h3(`${i18n.statsPUBs}: ${String(stats.pubsCount || 0)}`)
-	),   
-
+        ),
+        div({ style: headerStyle }, h3(`${i18n.statsPUBs}: ${String(stats.pubsCount || 0)}`)),
         filter === 'ALL'
           ? div({ class: 'stats-container' }, [
               div({ style: blockStyle },
                 h2(i18n.statsActivity7d),
                 table({ style: 'width:100%; border-collapse: collapse;' },
                   tr(th(i18n.day), th(i18n.messages)),
-                  ...(Array.isArray(stats.activity?.daily7) ? stats.activity.daily7 : []).map(row =>
-                    tr(td(row.day), td(String(row.count)))
-                  )
+                  ...(Array.isArray(stats.activity?.daily7) ? stats.activity.daily7 : []).map(row => tr(td(row.day), td(String(row.count))))
                 ),
                 p(`${i18n.statsActivity7dTotal}: ${stats.activity?.daily7Total || 0}`),
                 p(`${i18n.statsActivity30dTotal}: ${stats.activity?.daily30Total || 0}`)
               ),
               div({ style: blockStyle },
-                h2(`${i18n.statsDiscoveredTribes}: ${stats.memberTribes.length}`),
+                h2(`${i18n.statsDiscoveredTribes}: ${stats.allTribesPublic.length}`),
                 table({ style: 'width:100%; border-collapse: collapse; margin-top: 8px;' },
-                  tr(th(i18n.typeTribe || 'Tribe')),
-                  ...stats.memberTribes.map(name => tr(td(name)))
+                  ...stats.allTribesPublic.map(t => tr(td(a({ href: `/tribe/${encodeURIComponent(t.id)}`, class: 'tribe-link' }, t.name))))
                 )
               ),
+              div({ style: blockStyle },
+                h2(`${i18n.statsPrivateDiscoveredTribes}: ${stats.tribePrivateCount || 0}`)
+              ),
               div({ style: blockStyle }, h2(`${i18n.statsUsersTitle}: ${stats.usersKPIs?.totalInhabitants || stats.inhabitants || 0}`)),
               div({ style: blockStyle }, h2(`${i18n.statsDiscoveredForum}: ${C(stats, 'forum')}`)),
               div({ style: blockStyle }, h2(`${i18n.statsDiscoveredTransfer}: ${C(stats, 'transfer')}`)),
@@ -161,14 +128,22 @@ exports.statsView = (stats, filter) => {
                 h2(`${i18n.statsNetworkOpinions}: ${totalOpinions}`),
                 ul(types.map(t => O(stats, t) > 0 ? li(`${i18n[`stats${t.charAt(0).toUpperCase() + t.slice(1)}`]}: ${O(stats, t)}`) : null).filter(Boolean))
               ),
-	      div({ style: blockStyle },
-		  h2(`${i18n.statsNetworkContent}: ${totalContent}`),
-		  ul(types
-		    .filter(t => t !== 'karmaScore')
-		    .map(t => C(stats, t) > 0 ? li(`${i18n[`stats${t.charAt(0).toUpperCase() + t.slice(1)}`]}: ${C(stats, t)}`) : null)
-		    .filter(Boolean)
-		  )
-	      )
+              div({ style: blockStyle },
+                h2(`${i18n.statsNetworkContent}: ${totalContent}`),
+                ul(
+                  types.filter(t => t !== 'karmaScore').map(t => {
+                    if (C(stats, t) <= 0) return null;
+                    if (t !== 'tribe') return li(`${i18n[`stats${t.charAt(0).toUpperCase() + t.slice(1)}`]}: ${C(stats, t)}`);
+                    return li(
+                      span(`${i18n[`stats${t.charAt(0).toUpperCase() + t.slice(1)}`]}: ${C(stats, t)}`),
+                      ul([
+                        li(`${i18n.statsPublic}: ${stats.tribePublicCount || 0}`),
+                        li(`${i18n.statsPrivate}: ${stats.tribePrivateCount || 0}`)
+                      ])
+                    );
+                  }).filter(Boolean)
+                )
+              )
             ])
           : filter === 'MINE'
             ? div({ class: 'stats-container' }, [
@@ -176,20 +151,25 @@ exports.statsView = (stats, filter) => {
                   h2(i18n.statsActivity7d),
                   table({ style: 'width:100%; border-collapse: collapse;' },
                     tr(th(i18n.day), th(i18n.messages)),
-                    ...(Array.isArray(stats.activity?.daily7) ? stats.activity.daily7 : []).map(row =>
-                      tr(td(row.day), td(String(row.count)))
-                    )
+                    ...(Array.isArray(stats.activity?.daily7) ? stats.activity.daily7 : []).map(row => tr(td(row.day), td(String(row.count))))
                   ),
                   p(`${i18n.statsActivity7dTotal}: ${stats.activity?.daily7Total || 0}`),
                   p(`${i18n.statsActivity30dTotal}: ${stats.activity?.daily30Total || 0}`)
                 ),
                 div({ style: blockStyle },
-                  h2(`${i18n.statsDiscoveredTribes}: ${stats.memberTribes.length}`),
+                  h2(`${i18n.statsDiscoveredTribes}: ${stats.memberTribesDetailed.length}`),
                   table({ style: 'width:100%; border-collapse: collapse; margin-top: 8px;' },
-                    tr(th(i18n.typeTribe || 'Tribe')),
-                    ...stats.memberTribes.map(name => tr(td(name)))
+                    ...stats.memberTribesDetailed.map(t => tr(td(a({ href: `/tribe/${encodeURIComponent(t.id)}`, class: 'tribe-link' }, t.name))))
                   )
                 ),
+                Array.isArray(stats.myPrivateTribesDetailed) && stats.myPrivateTribesDetailed.length
+                  ? div({ style: blockStyle },
+                      h2(`${i18n.statsPrivateDiscoveredTribes}: ${stats.myPrivateTribesDetailed.length}`),
+                      table({ style: 'width:100%; border-collapse: collapse; margin-top: 8px;' },
+                        ...stats.myPrivateTribesDetailed.map(tp => tr(td(a({ href: `/tribe/${encodeURIComponent(tp.id)}`, class: 'tribe-link' }, tp.name))))
+                      )
+                    )
+                  : null,
                 div({ style: blockStyle }, h2(`${i18n.statsYourForum}: ${C(stats, 'forum')}`)),
                 div({ style: blockStyle }, h2(`${i18n.statsYourTransfer}: ${C(stats, 'transfer')}`)),
                 div({ style: blockStyle },
@@ -224,14 +204,30 @@ exports.statsView = (stats, filter) => {
                   h2(`${i18n.statsYourOpinions}: ${totalOpinions}`),
                   ul(types.map(t => O(stats, t) > 0 ? li(`${i18n[`stats${t.charAt(0).toUpperCase() + t.slice(1)}`]}: ${O(stats, t)}`) : null).filter(Boolean))
                 ),
-		div({ style: blockStyle },
-		  h2(`${i18n.statsYourContent}: ${totalContent}`),
-		  ul(types
-		    .filter(t => t !== 'karmaScore') 
-		    .map(t => C(stats, t) > 0 ? li(`${i18n[`stats${t.charAt(0).toUpperCase() + t.slice(1)}`]}: ${C(stats, t)}`) : null)
-		    .filter(Boolean)
-		  )
-		)
+                div({ style: blockStyle },
+                  h2(`${i18n.statsYourContent}: ${totalContent}`),
+                  ul(
+                    types.filter(t => t !== 'karmaScore').map(t => {
+                      if (C(stats, t) <= 0) return null;
+                      if (t !== 'tribe') return li(`${i18n[`stats${t.charAt(0).toUpperCase() + t.slice(1)}`]}: ${C(stats, t)}`);
+                      return li(
+                        span(`${i18n[`stats${t.charAt(0).toUpperCase() + t.slice(1)}`]}: ${C(stats, t)}`),
+                        ul([
+                          li(`${i18n.statsPublic}: ${stats.tribePublicCount || 0}`),
+                          li(`${i18n.statsPrivate}: ${stats.tribePrivateCount || 0}`),
+                          ...(Array.isArray(stats.myPrivateTribesDetailed) && stats.myPrivateTribesDetailed.length
+                            ? [
+                                li(i18n.statsPrivateDiscoveredTribes),
+                                ...stats.myPrivateTribesDetailed.map(tp =>
+                                  li(a({ href: `/tribe/${encodeURIComponent(tp.id)}`, class: 'tribe-link' }, tp.name))
+                                )
+                              ]
+                            : [])
+                        ])
+                      );
+                    }).filter(Boolean)
+                  )
+                )
               ])
             : div({ class: 'stats-container' }, [
                 div({ style: blockStyle },