Kaynağa Gözat

Oasis release 0.6.4

psy 5 gün önce
ebeveyn
işleme
e2181f32a4

+ 16 - 0
docs/CHANGELOG.md

@@ -13,6 +13,22 @@ All notable changes to this project will be documented in this file.
 ### Security
 -->
 
+## v0.6.4 - 2026-01-05
+
+### Added
+
+ + More information shared when spreading content at inhabitants activity (Activity plugin).
+ + New PUB: pub.andromeda.oasis
+
+### Changed
+
+ + Strong backend refactoring (4962L -> 2666L) (Core plugin).
+
+### Fixed
+
+ + Fixed parliament cycles (Parliament plugin).
+ + Refeeding with hashtags (Feed plugin).
+
 ## v0.6.3 - 2025-12-10
 
 ### Fixed

Dosya farkı çok büyük olduğundan ihmal edildi
+ 900 - 3160
src/backend/backend.js


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

@@ -167,7 +167,6 @@ nav ul li {
 nav ul {
   display: flex;
   list-style: none;
-  padding: 2px;
   margin: 0;
 }
 

+ 2 - 0
src/client/assets/translations/oasis_en.js

@@ -158,6 +158,7 @@ module.exports = {
     // spreads view
     viewLikes: "View spreads",
     spreadedDescription: "List of posts spread by the inhabitant.",
+    totalspreads: "Total spreads",
     // composer
     attachFiles: "Attach files",
     preview: "Preview",
@@ -1433,6 +1434,7 @@ module.exports = {
     typeBankClaim:        "BANKING/UBI",
     typeKarmaScore:       "KARMA",
     typeParliament:       "PARLIAMENT",
+    typeSpread:           "SPREADS",
     typeParliamentCandidature: "Parliament · Candidature",
     typeParliamentTerm:   "Parliament · Term",
     typeParliamentProposal:"Parliament · Proposal",

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

@@ -153,8 +153,9 @@ module.exports = {
     relationshipConflict: "Conflicto",
     relationshipBlockingPost: "Post bloqueado",
     // spreads view
-    viewLikes: "Ver soportes",
-    spreadedDescription: "Lista de posts soportados por habitante.",
+    viewLikes: "Ver replicas",
+    spreadedDescription: "Lista de posts replicados por habitante.",
+    totalspreads: "Replicas totales",
     // composer
     attachFiles: "Adjuntar ficheros",
     preview: "Previsualizar",
@@ -1428,6 +1429,7 @@ module.exports = {
     typeBankClaim:        "BANCA/UBI",
     typeKarmaScore:       "KARMA",
     typeParliament:       "PARLAMENTO",
+    typeSpread:           "RÉPLICAS",
     typeParliamentCandidature: "Parlamento · Candidatura",
     typeParliamentTerm:   "Parlamento · Mandato",
     typeParliamentProposal:"Parlamento · Propuesta",

+ 2 - 0
src/client/assets/translations/oasis_eu.js

@@ -155,6 +155,7 @@ module.exports = {
     // spreads view
     viewLikes: "Ikusi zabalpenak",
     spreadedDescription: "Bizilagunak zabaldutako bidalketen zerrenda.",
+    totalspreads: "Hedapenak guztira",
     // composer
     attachFiles: "Erantsi fitxategiak",
     preview: "Aurrebista",
@@ -1429,6 +1430,7 @@ module.exports = {
     typeBankClaim:        "BANKUA/UBI",
     typeKarmaScore:       "KARMA",
     typeParliament:       "PARLAMENTUA",
+    typeSpread:	          "ERREPLIKAK",
     typeParliamentCandidature: "Parlamentua · Hautagaitza",
     typeParliamentTerm:   "Parlamentua · Agintaldia",
     typeParliamentProposal:"Parlamentua · Proposamena",

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

@@ -155,6 +155,7 @@ module.exports = {
     // spreads view
     viewLikes: "Voir les soutiens",
     spreadedDescription: "Liste des posts soutenus par habitant.",
+    totalspreads: "Soutiens totales",
     // composer
     attachFiles: "Joindre des fichiers",
     preview: "Prévisualiser",
@@ -1428,6 +1429,7 @@ module.exports = {
     typeBankClaim:        "BANQUE/UBI",
     typeKarmaScore:       "KARMA",
     typeParliament:       "PARLEMENT",
+    typeSpread:           "SOUTINES",
     typeParliamentCandidature: "Parlement · Candidature",
     typeParliamentTerm:   "Parlement · Mandat",
     typeParliamentProposal:"Parlement · Proposition",

+ 252 - 141
src/models/activity_model.js

@@ -21,6 +21,10 @@ function inferType(c = {}) {
   if (c.type === 'courts_nom_vote') return 'courtsNominationVote';
   if (c.type === 'courts_public_pref') return 'courtsPublicPref';
   if (c.type === 'courts_mediators') return 'courtsMediators';
+  if (c.type === 'vote' && c.vote && typeof c.vote.link === 'string') {
+    const br = Array.isArray(c.branch) ? c.branch : [];
+    if (br.includes(c.vote.link) && Number(c.vote.value) === 1) return 'spread';
+  }
   return c.type || '';
 }
 
@@ -28,6 +32,20 @@ module.exports = ({ cooler }) => {
   let ssb;
   const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb };
   const hasBlob = async (ssbClient, url) => new Promise(resolve => ssbClient.blobs.has(url, (err, has) => resolve(!err && has)));
+  const getMsg = async (ssbClient, key) => new Promise(resolve => ssbClient.get(key, (err, msg) => resolve(err ? null : msg)));
+  const normNL = (s) => String(s || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
+  const stripHtml = (s) => normNL(s)
+    .replace(/<br\s*\/?>/gi, '\n')
+    .replace(/<\/p\s*>/gi, '\n\n')
+    .replace(/<[^>]*>/g, '')
+    .replace(/[ \t]+\n/g, '\n')
+    .replace(/\n{3,}/g, '\n\n')
+    .trim();
+  const excerpt = (s, max = 900) => {
+    const t = stripHtml(s);
+    if (!t) return '';
+    return t.length > max ? t.slice(0, max - 1) + '…' : t;
+  };
 
   return {
     async listFeed(filter = 'all') {
@@ -58,6 +76,93 @@ module.exports = ({ cooler }) => {
         if (c.replaces) parentOf.set(k, c.replaces);
       }
 
+      const replacedIds = new Set(parentOf.values());
+      const spreadVoteState = new Map();
+
+      for (const a of idToAction.values()) {
+        const c = a.content || {};
+        if (c.type !== 'vote' || !c.vote || typeof c.vote.link !== 'string') continue;
+
+        const link = c.vote.link;
+        const br = Array.isArray(c.branch) ? c.branch : [];
+        if (!br.includes(link)) continue;
+
+        if (tombstoned.has(a.id)) continue;
+        if (replacedIds.has(a.id)) continue;
+        if (tombstoned.has(link)) continue;
+
+        const author = a.author;
+        if (!author) continue;
+
+        const value = Number(c.vote.value);
+        const key = `${link}:${author}`;
+        const prev = spreadVoteState.get(key);
+        const curTs = a.ts || 0;
+
+        if (!prev || curTs > prev.ts || (curTs === prev.ts && String(a.id || '').localeCompare(String(prev.id || '')) > 0)) {
+          spreadVoteState.set(key, { ts: curTs, id: a.id, value, link });
+        }
+      }
+
+      const spreadCountByTarget = new Map();
+      for (const v of spreadVoteState.values()) {
+        if (Number(v.value) !== 1) continue;
+        spreadCountByTarget.set(v.link, (spreadCountByTarget.get(v.link) || 0) + 1);
+      }
+
+      const fetchedTargetCache = new Map();
+
+      for (const a of idToAction.values()) {
+        if (a.type !== 'spread') continue;
+        const c = a.content || {};
+        const link = c.vote?.link || '';
+        const totalSpreads = link ? (spreadCountByTarget.get(link) || 0) : 0;
+
+        let targetMsg = link ? rawById.get(link) : null;
+        if (!targetMsg && link) {
+          if (fetchedTargetCache.has(link)) targetMsg = fetchedTargetCache.get(link);
+          else {
+            const got = await getMsg(ssbClient, link);
+            if (got) {
+              const wrapped = { key: link, value: got };
+              fetchedTargetCache.set(link, wrapped);
+              targetMsg = wrapped;
+            } else {
+              fetchedTargetCache.set(link, null);
+              targetMsg = null;
+            }
+          }
+        }
+
+        const targetContent = targetMsg?.value?.content || null;
+        const title =
+          (typeof targetContent?.title === 'string' && targetContent.title.trim())
+            ? targetContent.title.trim()
+            : (typeof targetContent?.name === 'string' && targetContent.name.trim())
+              ? targetContent.name.trim()
+              : '';
+        const rawText =
+          (typeof targetContent?.text === 'string' && targetContent.text.trim())
+            ? targetContent.text
+            : (typeof targetContent?.description === 'string' && targetContent.description.trim())
+              ? targetContent.description
+              : '';
+        const text = rawText ? excerpt(rawText, 700) : '';
+        const cw =
+          (typeof targetContent?.contentWarning === 'string' && targetContent.contentWarning.trim())
+            ? targetContent.contentWarning
+            : '';
+        a.content = {
+          ...c,
+          spreadTargetId: link,
+          spreadTotalSpreads: totalSpreads,
+          spreadOriginalAuthor: targetMsg?.value?.author || '',
+          spreadTitle: title,
+          spreadContentWarning: cw,
+          spreadText: text
+        };
+      }
+
       const rootOf = (id) => { let cur = id; while (parentOf.has(cur)) cur = parentOf.get(cur); return cur };
 
       const groups = new Map();
@@ -114,148 +219,148 @@ module.exports = ({ cooler }) => {
               prev = cur;
             }
           }
-          
+
           if (type === 'tribe') {
-		  const baseId = tip.id;
-		  const baseTitle = (tip.content && tip.content.title) || '';
-		  const isAnonymous = tip.content && typeof tip.content.isAnonymous === 'boolean' ? tip.content.isAnonymous : false;
-
-		  const uniq = (xs) => Array.from(new Set((Array.isArray(xs) ? xs : []).filter(x => typeof x === 'string' && x.trim().length)));
-		  const toSet = (xs) => new Set(uniq(xs));
-		  const excerpt = (s, max = 220) => {
-		    const t = String(s || '').replace(/\s+/g, ' ').trim();
-		    return t.length > max ? t.slice(0, max - 1) + '…' : t;
-		  };
-		  const feedMap = (feed) => {
-		    const m = new Map();
-		    for (const it of (Array.isArray(feed) ? feed : [])) {
-		      if (!it || typeof it !== 'object') continue;
-		      const id = typeof it.id === 'string' || typeof it.id === 'number' ? String(it.id) : '';
-		      if (!id) continue;
-		      m.set(id, it);
-		    }
-		    return m;
-		  };
-
-		  const sorted = arr
-		    .filter(a => a.type === 'tribe' && a.content && typeof a.content === 'object')
-		    .sort((a, b) => (a.ts || 0) - (b.ts || 0));
-
-		  let prev = null;
-
-		  for (const ev of sorted) {
-		    if (!prev) { prev = ev; continue; }
-
-		    const prevMembers = toSet(prev.content.members);
-		    const curMembers = toSet(ev.content.members);
-		    const added = Array.from(curMembers).filter(x => !prevMembers.has(x));
-		    const removed = Array.from(prevMembers).filter(x => !curMembers.has(x));
-
-		    for (const member of added) {
-		      const overlayId = `${ev.id}:tribeJoin:${member}`;
-		      idToAction.set(overlayId, {
-			id: overlayId,
-			author: member,
-			ts: ev.ts,
-			type: 'tribeJoin',
-			content: { type: 'tribeJoin', tribeId: baseId, tribeTitle: baseTitle, isAnonymous, member }
-		      });
-		      idToTipId.set(overlayId, overlayId);
-		    }
-
-		    for (const member of removed) {
-		      const overlayId = `${ev.id}:tribeLeave:${member}`;
-		      idToAction.set(overlayId, {
-			id: overlayId,
-			author: member,
-			ts: ev.ts,
-			type: 'tribeLeave',
-			content: { type: 'tribeLeave', tribeId: baseId, tribeTitle: baseTitle, isAnonymous, member }
-		      });
-		      idToTipId.set(overlayId, overlayId);
-		    }
-
-		    const prevFeed = feedMap(prev.content.feed);
-		    const curFeed = feedMap(ev.content.feed);
-
-		    for (const [fid, item] of curFeed.entries()) {
-		      if (prevFeed.has(fid)) continue;
-		      const feedAuthor = (item && typeof item.author === 'string' && item.author.trim().length) ? item.author : ev.author;
-		      const overlayId = `${ev.id}:tribeFeedPost:${fid}:${feedAuthor}`;
-		      idToAction.set(overlayId, {
-			id: overlayId,
-			author: feedAuthor,
-			ts: ev.ts,
-			type: 'tribeFeedPost',
-			content: {
-			  type: 'tribeFeedPost',
-			  tribeId: baseId,
-			  tribeTitle: baseTitle,
-			  isAnonymous,
-			  feedId: fid,
-			  date: item.date || ev.ts,
-			  text: excerpt(item.message || '')
-			}
-		      });
-		      idToTipId.set(overlayId, overlayId);
-		    }
-
-		    for (const [fid, curItem] of curFeed.entries()) {
-		      const prevItem = prevFeed.get(fid);
-		      if (!prevItem) continue;
-
-		      const pInh = toSet(prevItem.refeeds_inhabitants);
-		      const cInh = toSet(curItem.refeeds_inhabitants);
-		      const newInh = Array.from(cInh).filter(x => !pInh.has(x));
-
-		      const curRefeeds = Number(curItem.refeeds || 0);
-		      const prevRefeeds = Number(prevItem.refeeds || 0);
-
-		      const postText = excerpt(curItem.message || '');
-
-		      if (newInh.length) {
-			for (const who of newInh) {
-			  const overlayId = `${ev.id}:tribeFeedRefeed:${fid}:${who}`;
-			  idToAction.set(overlayId, {
-			    id: overlayId,
-			    author: who,
-			    ts: ev.ts,
-			    type: 'tribeFeedRefeed',
-			    content: {
-			      type: 'tribeFeedRefeed',
-			      tribeId: baseId,
-			      tribeTitle: baseTitle,
-			      isAnonymous,
-			      feedId: fid,
-			      text: postText
-			    }
-			  });
-			  idToTipId.set(overlayId, overlayId);
-			}
-		      } else if (curRefeeds > prevRefeeds && ev.author) {
-			const who = ev.author;
-			const overlayId = `${ev.id}:tribeFeedRefeed:${fid}:${who}`;
-			idToAction.set(overlayId, {
-			  id: overlayId,
-			  author: who,
-			  ts: ev.ts,
-			  type: 'tribeFeedRefeed',
-			  content: {
-			    type: 'tribeFeedRefeed',
-			    tribeId: baseId,
-			    tribeTitle: baseTitle,
-			    isAnonymous,
-			    feedId: fid,
-			    text: postText
-			  }
-			});
-			idToTipId.set(overlayId, overlayId);
-		      }
-		    }
-
-		    prev = ev;
-		  }
-		}
+            const baseId = tip.id;
+            const baseTitle = (tip.content && tip.content.title) || '';
+            const isAnonymous = tip.content && typeof tip.content.isAnonymous === 'boolean' ? tip.content.isAnonymous : false;
+
+            const uniq = (xs) => Array.from(new Set((Array.isArray(xs) ? xs : []).filter(x => typeof x === 'string' && x.trim().length)));
+            const toSet = (xs) => new Set(uniq(xs));
+            const excerpt2 = (s, max = 220) => {
+              const t = String(s || '').replace(/\s+/g, ' ').trim();
+              return t.length > max ? t.slice(0, max - 1) + '…' : t;
+            };
+            const feedMap = (feed) => {
+              const m = new Map();
+              for (const it of (Array.isArray(feed) ? feed : [])) {
+                if (!it || typeof it !== 'object') continue;
+                const id = typeof it.id === 'string' || typeof it.id === 'number' ? String(it.id) : '';
+                if (!id) continue;
+                m.set(id, it);
+              }
+              return m;
+            };
+
+            const sorted = arr
+              .filter(a => a.type === 'tribe' && a.content && typeof a.content === 'object')
+              .sort((a, b) => (a.ts || 0) - (b.ts || 0));
+
+            let prev = null;
+
+            for (const ev of sorted) {
+              if (!prev) { prev = ev; continue; }
+
+              const prevMembers = toSet(prev.content.members);
+              const curMembers = toSet(ev.content.members);
+              const added = Array.from(curMembers).filter(x => !prevMembers.has(x));
+              const removed = Array.from(prevMembers).filter(x => !curMembers.has(x));
+
+              for (const member of added) {
+                const overlayId = `${ev.id}:tribeJoin:${member}`;
+                idToAction.set(overlayId, {
+                  id: overlayId,
+                  author: member,
+                  ts: ev.ts,
+                  type: 'tribeJoin',
+                  content: { type: 'tribeJoin', tribeId: baseId, tribeTitle: baseTitle, isAnonymous, member }
+                });
+                idToTipId.set(overlayId, overlayId);
+              }
+
+              for (const member of removed) {
+                const overlayId = `${ev.id}:tribeLeave:${member}`;
+                idToAction.set(overlayId, {
+                  id: overlayId,
+                  author: member,
+                  ts: ev.ts,
+                  type: 'tribeLeave',
+                  content: { type: 'tribeLeave', tribeId: baseId, tribeTitle: baseTitle, isAnonymous, member }
+                });
+                idToTipId.set(overlayId, overlayId);
+              }
+
+              const prevFeed = feedMap(prev.content.feed);
+              const curFeed = feedMap(ev.content.feed);
+
+              for (const [fid, item] of curFeed.entries()) {
+                if (prevFeed.has(fid)) continue;
+                const feedAuthor = (item && typeof item.author === 'string' && item.author.trim().length) ? item.author : ev.author;
+                const overlayId = `${ev.id}:tribeFeedPost:${fid}:${feedAuthor}`;
+                idToAction.set(overlayId, {
+                  id: overlayId,
+                  author: feedAuthor,
+                  ts: ev.ts,
+                  type: 'tribeFeedPost',
+                  content: {
+                    type: 'tribeFeedPost',
+                    tribeId: baseId,
+                    tribeTitle: baseTitle,
+                    isAnonymous,
+                    feedId: fid,
+                    date: item.date || ev.ts,
+                    text: excerpt2(item.message || '')
+                  }
+                });
+                idToTipId.set(overlayId, overlayId);
+              }
+
+              for (const [fid, curItem] of curFeed.entries()) {
+                const prevItem = prevFeed.get(fid);
+                if (!prevItem) continue;
+
+                const pInh = toSet(prevItem.refeeds_inhabitants);
+                const cInh = toSet(curItem.refeeds_inhabitants);
+                const newInh = Array.from(cInh).filter(x => !pInh.has(x));
+
+                const curRefeeds = Number(curItem.refeeds || 0);
+                const prevRefeeds = Number(prevItem.refeeds || 0);
+
+                const postText = excerpt2(curItem.message || '');
+
+                if (newInh.length) {
+                  for (const who of newInh) {
+                    const overlayId = `${ev.id}:tribeFeedRefeed:${fid}:${who}`;
+                    idToAction.set(overlayId, {
+                      id: overlayId,
+                      author: who,
+                      ts: ev.ts,
+                      type: 'tribeFeedRefeed',
+                      content: {
+                        type: 'tribeFeedRefeed',
+                        tribeId: baseId,
+                        tribeTitle: baseTitle,
+                        isAnonymous,
+                        feedId: fid,
+                        text: postText
+                      }
+                    });
+                    idToTipId.set(overlayId, overlayId);
+                  }
+                } else if (curRefeeds > prevRefeeds && ev.author) {
+                  const who = ev.author;
+                  const overlayId = `${ev.id}:tribeFeedRefeed:${fid}:${who}`;
+                  idToAction.set(overlayId, {
+                    id: overlayId,
+                    author: who,
+                    ts: ev.ts,
+                    type: 'tribeFeedRefeed',
+                    content: {
+                      type: 'tribeFeedRefeed',
+                      tribeId: baseId,
+                      tribeTitle: baseTitle,
+                      isAnonymous,
+                      feedId: fid,
+                      text: postText
+                    }
+                  });
+                  idToTipId.set(overlayId, overlayId);
+                }
+              }
+
+              prev = ev;
+            }
+          }
 
           continue;
         }
@@ -301,6 +406,7 @@ module.exports = ({ cooler }) => {
           idToTipId.set(ev.id, ev.id);
         }
       }
+
       const latest = [];
       for (const a of idToAction.values()) {
         if (tombstoned.has(a.id)) continue;
@@ -308,6 +414,7 @@ module.exports = ({ cooler }) => {
         const c = a.content || {};
         if (c.root && tombstoned.has(c.root)) continue;
         if (a.type === 'vote' && tombstoned.has(c.vote?.link)) continue;
+        if (a.type === 'spread' && (c.spreadTargetId || c.vote?.link) && tombstoned.has(c.spreadTargetId || c.vote?.link)) continue;
         if (c.key && tombstoned.has(c.key)) continue;
         if (c.branch && tombstoned.has(c.branch)) continue;
         if (c.target && tombstoned.has(c.target)) continue;
@@ -324,6 +431,7 @@ module.exports = ({ cooler }) => {
         }
         latest.push({ ...a, tipId: idToTipId.get(a.id) || a.id });
       }
+
       let deduped = latest.filter(a => !a.tipId || a.tipId === a.id || (a.type === 'tribe' && !parentOf.has(a.id)));
 
       const mediaTypes = new Set(['image','video','audio','document','bookmark']);
@@ -379,6 +487,7 @@ module.exports = ({ cooler }) => {
       }
 
       deduped = Array.from(byKey.values()).map(x => { delete x.__effTs; delete x.__hasImage; return x });
+
       let out;
       if (filter === 'mine') out = deduped.filter(a => a.author === userId);
       else if (filter === 'recent') { const cutoff = Date.now() - 24 * 60 * 60 * 1000; out = deduped.filter(a => (a.ts || 0) >= cutoff) }
@@ -386,6 +495,7 @@ module.exports = ({ cooler }) => {
       else if (filter === 'banking') out = deduped.filter(a => a.type === 'bankWallet' || a.type === 'bankClaim');
       else if (filter === 'karma') out = deduped.filter(a => a.type === 'karmaScore');
       else if (filter === 'tribe') out = deduped.filter(a => a.type === 'tribe' || String(a.type || '').startsWith('tribe'));
+      else if (filter === 'spread') out = deduped.filter(a => a.type === 'spread');
       else if (filter === 'parliament')
         out = deduped.filter(a =>
           ['parliamentCandidature','parliamentTerm','parliamentProposal','parliamentRevocation','parliamentLaw'].includes(a.type)
@@ -398,6 +508,7 @@ module.exports = ({ cooler }) => {
       else if (filter === 'task')
         out = deduped.filter(a => a.type === 'task' || a.type === 'taskAssignment');
       else out = deduped.filter(a => a.type === filter);
+
       out.sort((a, b) => (b.ts || 0) - (a.ts || 0));
       return out;
     }

+ 278 - 174
src/models/parliament_model.js

@@ -13,6 +13,13 @@ module.exports = ({ cooler, services = {} }) => {
   let ssb;
   let userId;
 
+  const CACHE_MS = 250;
+  let logCache = { at: 0, arr: null };
+  let myCache = new Map();
+
+  let electionInFlight = null;
+  let sweepInFlight = null;
+
   const openSsb = async () => {
     if (!ssb) {
       ssb = await cooler.open();
@@ -25,31 +32,78 @@ module.exports = ({ cooler, services = {} }) => {
   const parseISO = (s) => moment(s, moment.ISO_8601, true);
   const ensureArray = (x) => (Array.isArray(x) ? x : x ? [x] : []);
   const stripId = (obj) => {
-  if (!obj || typeof obj !== 'object') return obj;
+    if (!obj || typeof obj !== 'object') return obj;
     const { id, ...rest } = obj;
-  return rest;
+    return rest;
   };
   const normMs = (t) => (t && t < 1e12 ? t * 1000 : t || 0);
 
+  const isExpiredTerm = (t) => {
+    const end = t && t.endAt ? parseISO(t.endAt) : null;
+    if (!end || !end.isValid()) return false;
+    return moment().isSameOrAfter(end);
+  };
+
+  async function publishMsg(content) {
+    const ssbClient = await openSsb();
+    const res = await new Promise((resolve, reject) =>
+      ssbClient.publish(content, (e, r) => (e ? reject(e) : resolve(r)))
+    );
+    logCache = { at: 0, arr: null };
+    myCache.clear();
+    return res;
+  }
+
   async function readLog() {
+    const now = Date.now();
+    if (logCache.arr && now - logCache.at < CACHE_MS) return logCache.arr;
     const ssbClient = await openSsb();
-    return new Promise((res, rej) => {
-      pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, arr) => (err ? rej(err) : res(arr))));
+    const arr = await new Promise((res, rej) => {
+      pull(
+        ssbClient.createLogStream({ limit: logLimit }),
+        pull.collect((err, out) => (err ? rej(err) : res(out || [])))
+      );
     });
+    logCache = { at: now, arr };
+    return arr;
   }
 
-  async function listByType(type) {
-    const msgs = await readLog();
+  async function readMyByTypes(types = [], limit = logLimit) {
+    const ssbClient = await openSsb();
+    const key = `${String(userId)}|${String(limit)}|${types.slice().sort().join(',')}`;
+    const now = Date.now();
+    const hit = myCache.get(key);
+    if (hit && hit.arr && now - hit.at < CACHE_MS) return hit.arr;
+    const set = new Set(types);
+    const arr = await new Promise((res, rej) => {
+      pull(
+        ssbClient.createUserStream({ id: userId, reverse: true }),
+        pull.filter(m => {
+          const c = m && m.value && m.value.content;
+          return c && set.has(c.type);
+        }),
+        pull.take(Number(limit) || logLimit),
+        pull.collect((err, out) => (err ? rej(err) : res(out || [])))
+      );
+    });
+    myCache.set(key, { at: now, arr });
+    return arr;
+  }
+
+  function listByTypeFromMsgs(msgs, type) {
     const tomb = new Set();
     const rep = new Map();
     const children = new Map();
     const map = new Map();
-    for (const m of msgs) {
+
+    for (const m of msgs || []) {
       const k = m.key;
       const v = m.value || {};
       const c = v.content;
       if (!c) continue;
+
       if (c.type === 'tombstone' && c.target) tomb.add(c.target);
+
       if (c.type === type) {
         if (c.replaces) {
           const oldId = c.replaces;
@@ -62,6 +116,7 @@ module.exports = ({ cooler, services = {} }) => {
         map.set(k, { ...c, id: k });
       }
     }
+
     for (const oldId of rep.keys()) map.delete(oldId);
     for (const [oldId, kids] of children.entries()) {
       const winner = rep.get(oldId)?.id || null;
@@ -70,7 +125,13 @@ module.exports = ({ cooler, services = {} }) => {
       }
     }
     for (const tId of tomb) map.delete(tId);
-    return [...map.values()]; 
+    return [...map.values()];
+  }
+
+  async function listByType(type) {
+    const isParl = String(type || '').startsWith('parliament') || type === 'tombstone';
+    const msgs = isParl ? await readMyByTypes([type, 'tombstone'], logLimit) : await readLog();
+    return listByTypeFromMsgs(msgs, type);
   }
 
   async function listTribesAny() {
@@ -257,15 +318,23 @@ module.exports = ({ cooler, services = {} }) => {
   async function listTermsBase(filter = 'all') {
     const all = await listByType('parliamentTerm');
     const collapsed = collapseOverlappingTerms(all);
-    let arr = collapsed.map(t => ({ ...t, status: moment().isAfter(parseISO(t.endAt)) ? 'EXPIRED' : 'ACTIVE' }));
+    let arr = collapsed.map(t => ({ ...t, status: isExpiredTerm(t) ? '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));
   }
 
+  async function getLatestTermAny() {
+    const msgs = await readMyByTypes(['parliamentTerm', 'tombstone'], Math.max(50, Math.min(500, logLimit)));
+    const terms = listByTypeFromMsgs(msgs, 'parliamentTerm');
+    const collapsed = collapseOverlappingTerms(terms);
+    return collapsed[0] || null;
+  }
+
   async function getCurrentTermBase() {
-    const active = await listTermsBase('active');
-    return active[0] || null;
+    const t = await getLatestTermAny();
+    if (!t) return null;
+    return isExpiredTerm(t) ? null : t;
   }
 
   function currentCycleStart(term) {
@@ -273,11 +342,10 @@ module.exports = ({ cooler, services = {} }) => {
   }
 
   async function archiveAllCandidatures() {
-    const ssbClient = await openSsb();
     const all = await listCandidaturesOpenRaw();
     for (const c of all) {
       const tomb = { type: 'tombstone', target: c.id, deletedAt: nowISO(), author: userId };
-      await new Promise((resolve) => ssbClient.publish(tomb, () => resolve()));
+      await publishMsg(tomb);
     }
   }
 
@@ -409,16 +477,6 @@ module.exports = ({ cooler, services = {} }) => {
     return nProp + nLaw;
   }
 
-  async function getGroupMembers(term) {
-    if (!term) return [];
-    if (term.powerType === 'inhabitant') return [term.powerId];
-    if (term.powerType === 'tribe') {
-      const tribe = services.tribes ? await services.tribes.getTribeById(term.powerId) : null;
-      return ensureArray(tribe?.members || []);
-    }
-    return [];
-  }
-
   async function closeExpiredKarmatocracy(term) {
     const termId = term.id || term.startAt;
     const all = await listByType('parliamentProposal');
@@ -441,12 +499,11 @@ module.exports = ({ cooler, services = {} }) => {
     });
     const winner = withKarma[0];
     const losers = withKarma.slice(1);
-    const ssbClient = await openSsb();
     const approve = { ...stripId(winner), replaces: winner.id, status: 'APPROVED', updatedAt: nowISO() };
-    await new Promise((resolve, reject) => ssbClient.publish(approve, (e, r) => (e ? reject(e) : resolve(r))));
+    await publishMsg(approve);
     for (const lo of losers) {
       const rej = { ...stripId(lo), replaces: lo.id, status: 'REJECTED', updatedAt: nowISO() };
-      await new Promise((resolve) => ssbClient.publish(rej, () => resolve()));
+      await publishMsg(rej);
     }
   }
 
@@ -460,10 +517,9 @@ module.exports = ({ cooler, services = {} }) => {
       p.deadline && moment().isAfter(parseISO(p.deadline))
     );
     if (!pending.length) return;
-    const ssbClient = await openSsb();
     for (const p of pending) {
       const updated = { ...stripId(p), replaces: p.id, status: 'APPROVED', updatedAt: nowISO() };
-      await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => (e ? reject(e) : resolve(r))));
+      await publishMsg(updated);
     }
   }
 
@@ -477,10 +533,9 @@ module.exports = ({ cooler, services = {} }) => {
       p.deadline && moment().isAfter(parseISO(p.deadline))
     );
     if (!pending.length) return;
-    const ssbClient = await openSsb();
     for (const p of pending) {
       const updated = { ...stripId(p), replaces: p.id, status: 'APPROVED', updatedAt: nowISO() };
-      await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => (e ? reject(e) : resolve(r))));
+      await publishMsg(updated);
     }
   }
 
@@ -494,17 +549,16 @@ module.exports = ({ cooler, services = {} }) => {
       p.deadline && moment().isAfter(parseISO(p.deadline))
     );
     if (!pending.length) return;
-    const ssbClient = await openSsb();
     for (const p of pending) {
       const updated = { ...stripId(p), replaces: p.id, status: 'APPROVED', updatedAt: nowISO() };
-      await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => (e ? reject(e) : resolve(r))));
+      await publishMsg(updated);
     }
   }
 
   async function createRevocation({ lawId, title, reasons }) {
     const term = await getCurrentTermBase();
     if (!term) throw new Error('No active government');
-    const allowed = await this.canPropose();
+    const allowed = await 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');
@@ -512,7 +566,6 @@ module.exports = ({ cooler, services = {} }) => {
     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 = {
@@ -527,9 +580,7 @@ module.exports = ({ cooler, services = {} }) => {
         deadline,
         createdAt: nowISO()
       };
-      return await new Promise((resolve, reject) =>
-        ssbClient.publish(rev, (e, r) => (e ? reject(e) : resolve(r)))
-      );
+      return await publishMsg(rev);
     }
     const voteMsg = await services.votes.createVote(
       `Revoke: ${title || law.question || ''}`,
@@ -549,9 +600,7 @@ module.exports = ({ cooler, services = {} }) => {
       status: 'OPEN',
       createdAt: nowISO()
     };
-    return await new Promise((resolve, reject) =>
-      ssbClient.publish(rev, (e, r) => (e ? reject(e) : resolve(r)))
-    );
+    return await publishMsg(rev);
   }
 
   async function closeRevocation(revId) {
@@ -567,7 +616,8 @@ module.exports = ({ cooler, services = {} }) => {
     if (method === 'DICTATORSHIP') {
       if (currentStatus === 'APPROVED') return p;
       const updated = { ...p, replaces: revId, status: 'APPROVED', updatedAt: nowISO() };
-      return await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => (e ? reject(e) : resolve(r))));
+      await publishMsg(updated);
+      return updated;
     }
     if (method === 'KARMATOCRACY') return p;
     const v = await services.votes.getVoteById(p.voteId);
@@ -582,7 +632,8 @@ module.exports = ({ cooler, services = {} }) => {
     const desiredStatus = ok ? 'APPROVED' : 'REJECTED';
     if (currentStatus === desiredStatus) return p;
     const updated = { ...p, replaces: revId, status: desiredStatus, updatedAt: nowISO() };
-    return await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => (e ? reject(e) : resolve(r))));
+    await publishMsg(updated);
+    return updated;
   }
 
   async function proposeCandidature({ candidateId, method }) {
@@ -610,8 +661,7 @@ module.exports = ({ cooler, services = {} }) => {
       status: 'OPEN',
       createdAt: nowISO()
     };
-    const ssbClient = await openSsb();
-    return new Promise((resolve, reject) => ssbClient.publish(content, (e, r) => (e ? reject(e) : resolve(r))));
+    return await publishMsg(content);
   }
 
   async function voteCandidature(candidatureMsgId) {
@@ -619,46 +669,38 @@ module.exports = ({ cooler, services = {} }) => {
     const open = await listCandidaturesOpenRaw();
     const already = open.some(c => ensureArray(c.voters).includes(userId));
     if (already) throw new Error('Already voted this cycle');
-    return new Promise((resolve, reject) => {
-      ssbClient.get(candidatureMsgId, (err, msg) => {
-        if (err || !msg || msg.content?.type !== 'parliamentCandidature') return reject(new Error('Candidate not found'));
-        const c = msg.content;
-        if ((c.status || 'OPEN') !== 'OPEN') return reject(new Error('Closed'));
-        const updated = { ...c, replaces: candidatureMsgId, votes: Number(c.votes || 0) + 1, voters: [...ensureArray(c.voters), userId], updatedAt: nowISO() };
-        ssbClient.publish(updated, (e2, r2) => (e2 ? reject(e2) : resolve(r2)));
-      });
-    });
+    const msg = await new Promise((resolve, reject) =>
+      ssbClient.get(candidatureMsgId, (e, m) => (e || !m) ? reject(new Error('Candidate not found')) : resolve(m))
+    );
+    if (msg.content?.type !== 'parliamentCandidature') throw new Error('Candidate not found');
+    const c = msg.content;
+    if ((c.status || 'OPEN') !== 'OPEN') throw new Error('Closed');
+    const updated = { ...c, replaces: candidatureMsgId, votes: Number(c.votes || 0) + 1, voters: [...ensureArray(c.voters), userId], updatedAt: nowISO() };
+    return await publishMsg(updated);
   }
 
   async function createProposal({ title, description }) {
     let term = await getCurrentTermBase();
     if (!term) {
-      await this.resolveElection();
+      await resolveElection();
       term = await getCurrentTermBase();
     }
     if (!term) throw new Error('No active government');
-    const allowed = await this.canPropose();
+    const allowed = await canPropose();
     if (!allowed) throw new Error('You are not in the goverment, yet.');
     if (!title || !title.trim()) throw new Error('Title required');
     if (String(description || '').length > 1000) throw new Error('Description too long');
     const used = await countMyProposalsThisTerm(term);
     if (used >= 3) throw new Error('Proposal limit reached');
     const method = String(term.method || 'DEMOCRACY').toUpperCase();
-    const ssbClient = await openSsb();
-    if (method === 'DICTATORSHIP') {
-      const deadline = moment().add(PROPOSAL_DAYS, 'days').toISOString();
-      const proposal = { type: 'parliamentProposal', title, description: description || '', method, termId: term.id || term.startAt, proposer: userId, status: 'OPEN', deadline, createdAt: nowISO() };
-      return await new Promise((resolve, reject) => ssbClient.publish(proposal, (e, r) => (e ? reject(e) : resolve(r))));
-    }
-    if (method === 'KARMATOCRACY') {
-      const deadline = moment().add(PROPOSAL_DAYS, 'days').toISOString();
+    const deadline = moment().add(PROPOSAL_DAYS, 'days').toISOString();
+    if (method === 'DICTATORSHIP' || method === 'KARMATOCRACY') {
       const proposal = { type: 'parliamentProposal', title, description: description || '', method, termId: term.id || term.startAt, proposer: userId, status: 'OPEN', deadline, createdAt: nowISO() };
-      return await new Promise((resolve, reject) => ssbClient.publish(proposal, (e, r) => (e ? reject(e) : resolve(r))));
+      return await publishMsg(proposal);
     }
-    const deadline = moment().add(PROPOSAL_DAYS, 'days').toISOString();
     const voteMsg = await services.votes.createVote(title, deadline, ['YES', 'NO', 'ABSTENTION'], [`gov:${term.id || term.startAt}`, `govMethod:${method}`, 'proposal']);
     const proposal = { type: 'parliamentProposal', title, description: description || '', 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(proposal, (e, r) => (e ? reject(e) : resolve(r))));
+    return await publishMsg(proposal);
   }
 
   async function closeProposal(proposalId) {
@@ -674,7 +716,8 @@ module.exports = ({ cooler, services = {} }) => {
     if (method === 'DICTATORSHIP') {
       if (currentStatus === 'APPROVED') return p;
       const updated = { ...p, replaces: proposalId, status: 'APPROVED', updatedAt: nowISO() };
-      return await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => (e ? reject(e) : resolve(r))));
+      await publishMsg(updated);
+      return updated;
     }
     if (method === 'KARMATOCRACY') return p;
     const v = await services.votes.getVoteById(p.voteId);
@@ -689,57 +732,69 @@ module.exports = ({ cooler, services = {} }) => {
     const desiredStatus = ok ? 'APPROVED' : 'REJECTED';
     if (currentStatus === desiredStatus) return p;
     const updated = { ...p, replaces: proposalId, status: desiredStatus, updatedAt: nowISO() };
-    return await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => (e ? reject(e) : resolve(r))));
+    await publishMsg(updated);
+    return updated;
   }
 
   async function sweepProposals() {
-    const term = await getCurrentTermBase();
-    if (!term) return;
-    await closeExpiredKarmatocracy(term);
-    await closeExpiredDictatorship(term);
-    const ssbClient = await openSsb();
-    const allProps = await listByType('parliamentProposal');
-    const voteProps = allProps.filter(p => {
-      const m = String(p.method || '').toUpperCase();
-      return (m === 'DEMOCRACY' || m === 'ANARCHY' || m === 'MAJORITY' || m === 'MINORITY') && p.voteId;
-    });
-    for (const p of voteProps) {
-      try {
-        const v = await services.votes.getVoteById(p.voteId);
-        const votesMap = v.votes || {};
-        const sum = Object.values(votesMap).reduce((s, n) => s + Number(n || 0), 0);
-        const total = Number(v.totalVotes ?? v.total ?? sum);
-        const yes = Number(votesMap.YES ?? votesMap.Yes ?? votesMap.yes ?? 0);
-        const closed = v.status === 'CLOSED' || (v.deadline && moment(v.deadline).isBefore(moment()));
-        if (closed) { try { await this.closeProposal(p.id); } catch {} ; continue; }
-        if ((p.status || 'OPEN') === 'OPEN' && passesThreshold(p.method, total, yes)) {
-          const updated = { ...stripId(p), replaces: p.id, status: 'APPROVED', updatedAt: nowISO() };
-          await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => (e ? reject(e) : resolve(r))));
-        }
-      } catch {}
-    }
-    await closeExpiredRevocationKarmatocracy(term);
-    await closeExpiredRevocationDictatorship(term);
-    const revs = await listByType('parliamentRevocation');
-    const voteRevs = revs.filter(p => {
-      const m = String(p.method || '').toUpperCase();
-      return (m === 'DEMOCRACY' || m === 'ANARCHY' || m === 'MAJORITY' || m === 'MINORITY') && p.voteId;
-    });
-    for (const p of voteRevs) {
-      try {
-        const v = await services.votes.getVoteById(p.voteId);
-        const votesMap = v.votes || {};
-        const sum = Object.values(votesMap).reduce((s, n) => s + Number(n || 0), 0);
-        const total = Number(v.totalVotes ?? v.total ?? sum);
-        const yes = Number(votesMap.YES ?? votesMap.Yes ?? votesMap.yes ?? 0);
-        const closed = v.status === 'CLOSED' || (v.deadline && moment(v.deadline).isBefore(moment()));
-        if (closed) { try { await closeRevocation(p.id); } catch {} ; continue; }
-        if ((p.status || 'OPEN') === 'OPEN' && passesThreshold(p.method, total, yes)) {
-          const updated = { ...stripId(p), replaces: p.id, status: 'APPROVED', updatedAt: nowISO() };
-          await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => (e ? reject(e) : resolve(r))));
-        }
-      } catch {}
-    }
+    if (sweepInFlight) return sweepInFlight;
+    sweepInFlight = (async () => {
+      const term = await getCurrentTermBase();
+      if (!term) return;
+      await closeExpiredKarmatocracy(term);
+      await closeExpiredDictatorship(term);
+
+      const allProps = await listByType('parliamentProposal');
+      const voteProps = allProps.filter(p => {
+        const m = String(p.method || '').toUpperCase();
+        return (m === 'DEMOCRACY' || m === 'ANARCHY' || m === 'MAJORITY' || m === 'MINORITY') && p.voteId;
+      });
+
+      for (const p of voteProps) {
+        try {
+          const v = await services.votes.getVoteById(p.voteId);
+          const votesMap = v.votes || {};
+          const sum = Object.values(votesMap).reduce((s, n) => s + Number(n || 0), 0);
+          const total = Number(v.totalVotes ?? v.total ?? sum);
+          const yes = Number(votesMap.YES ?? votesMap.Yes ?? votesMap.yes ?? 0);
+          const deadline = v.deadline || v.endAt || v.expiresAt || null;
+          const closed = v.status === 'CLOSED' || (deadline && moment(deadline).isBefore(moment()));
+          if (closed) { try { await closeProposal(p.id); } catch {} ; continue; }
+          if ((p.status || 'OPEN') === 'OPEN' && passesThreshold(p.method, total, yes)) {
+            const updated = { ...stripId(p), replaces: p.id, status: 'APPROVED', updatedAt: nowISO() };
+            await publishMsg(updated);
+          }
+        } catch {}
+      }
+
+      await closeExpiredRevocationKarmatocracy(term);
+      await closeExpiredRevocationDictatorship(term);
+
+      const revs = await listByType('parliamentRevocation');
+      const voteRevs = revs.filter(p => {
+        const m = String(p.method || '').toUpperCase();
+        return (m === 'DEMOCRACY' || m === 'ANARCHY' || m === 'MAJORITY' || m === 'MINORITY') && p.voteId;
+      });
+
+      for (const p of voteRevs) {
+        try {
+          const v = await services.votes.getVoteById(p.voteId);
+          const votesMap = v.votes || {};
+          const sum = Object.values(votesMap).reduce((s, n) => s + Number(n || 0), 0);
+          const total = Number(v.totalVotes ?? v.total ?? sum);
+          const yes = Number(votesMap.YES ?? votesMap.Yes ?? votesMap.yes ?? 0);
+          const deadline = v.deadline || v.endAt || v.expiresAt || null;
+          const closed = v.status === 'CLOSED' || (deadline && moment(deadline).isBefore(moment()));
+          if (closed) { try { await closeRevocation(p.id); } catch {} ; continue; }
+          if ((p.status || 'OPEN') === 'OPEN' && passesThreshold(p.method, total, yes)) {
+            const updated = { ...stripId(p), replaces: p.id, status: 'APPROVED', updatedAt: nowISO() };
+            await publishMsg(updated);
+          }
+        } catch {}
+      }
+    })().finally(() => { sweepInFlight = null; });
+
+    return sweepInFlight;
   }
 
   async function getActorMeta({ targetType, targetId }) {
@@ -817,7 +872,7 @@ module.exports = ({ cooler, services = {} }) => {
           if (closed) derivedStatus = reached ? 'APPROVED' : 'REJECTED';
           else derivedStatus = 'OPEN';
         } catch {
-           derivedStatus = baseStatus;
+          derivedStatus = baseStatus;
         }
       } else {
         if (baseStatus === 'OPEN' && p.deadline && moment(p.deadline).isBefore(moment())) derivedStatus = 'DISCARDED';
@@ -846,21 +901,21 @@ module.exports = ({ cooler, services = {} }) => {
       let deadline = p.deadline || null;
       let voteClosed = true;
       if (p.voteId && services.votes?.getVoteById) {
-      try {
-        const v = await services.votes.getVoteById(p.voteId);
-        const votesMap = v.votes || {};
-        const sum = Object.values(votesMap).reduce((s, n) => s + Number(n || 0), 0);
-        total = Number(v.totalVotes ?? v.total ?? sum);
-        yes = Number(votesMap.YES ?? votesMap.Yes ?? votesMap.yes ?? 0);
-        deadline = deadline || v.deadline || v.endAt || v.expiresAt || null;
-        voteClosed = v.status === 'CLOSED' || (deadline && moment(deadline).isBefore(moment()));
-        if (!voteClosed) continue;
-      } catch {}
+        try {
+          const v = await services.votes.getVoteById(p.voteId);
+          const votesMap = v.votes || {};
+          const sum = Object.values(votesMap).reduce((s, n) => s + Number(n || 0), 0);
+          total = Number(v.totalVotes ?? v.total ?? sum);
+          yes = Number(votesMap.YES ?? votesMap.Yes ?? votesMap.yes ?? 0);
+          deadline = deadline || v.deadline || v.endAt || v.expiresAt || null;
+          voteClosed = v.status === 'CLOSED' || (deadline && moment(deadline).isBefore(moment()));
+          if (!voteClosed) continue;
+        } catch {}
+      }
+      const needed = requiredVotes(p.method, total);
+      out.push({ ...p, deadline, yes, total, needed });
     }
-    const needed = requiredVotes(p.method, total);
-    out.push({ ...p, deadline, yes, total, needed });
-  }
-  return out;
+    return out;
   }
 
   async function listRevocationsCurrent() {
@@ -874,31 +929,31 @@ module.exports = ({ cooler, services = {} }) => {
       const baseStatus = String(p.status || 'OPEN').toUpperCase();
       let derivedStatus = baseStatus;
       if (p.voteId && services.votes?.getVoteById) {
-      try {
-        const v = await services.votes.getVoteById(p.voteId);
-        const votesMap = v.votes || {};
-        const sum = Object.values(votesMap).reduce((s, n) => s + Number(n || 0), 0);
-        total = Number(v.totalVotes ?? v.total ?? sum);
-        yes = Number(votesMap.YES ?? votesMap.Yes ?? votesMap.yes ?? 0);
-        deadline = deadline || v.deadline || v.endAt || v.expiresAt || null;
-        const closed = v.status === 'CLOSED' || (deadline && moment(deadline).isBefore(moment()));
-        const reached = passesThreshold(p.method, total, yes);
+        try {
+          const v = await services.votes.getVoteById(p.voteId);
+          const votesMap = v.votes || {};
+          const sum = Object.values(votesMap).reduce((s, n) => s + Number(n || 0), 0);
+          total = Number(v.totalVotes ?? v.total ?? sum);
+          yes = Number(votesMap.YES ?? votesMap.Yes ?? votesMap.yes ?? 0);
+          deadline = deadline || v.deadline || v.endAt || v.expiresAt || null;
+          const closed = v.status === 'CLOSED' || (deadline && moment(deadline).isBefore(moment()));
+          const reached = passesThreshold(p.method, total, yes);
           if (closed) derivedStatus = reached ? 'APPROVED' : 'REJECTED';
           else derivedStatus = 'OPEN';
         } catch {
           derivedStatus = baseStatus;
         }
       } else {
-      if (baseStatus === 'OPEN' && p.deadline && moment(p.deadline).isBefore(moment())) derivedStatus = 'DISCARDED';
-    }
-    if (derivedStatus === 'ENACTED' || derivedStatus === 'REJECTED' || derivedStatus === 'DISCARDED') continue;
-    const needed = requiredVotes(p.method, total);
-    const onTrack = passesThreshold(p.method, total, yes);
-    out.push({ ...p, deadline, yes, total, needed, onTrack, derivedStatus });
+        if (baseStatus === 'OPEN' && p.deadline && moment(p.deadline).isBefore(moment())) derivedStatus = 'DISCARDED';
+      }
+      if (derivedStatus === 'ENACTED' || derivedStatus === 'REJECTED' || derivedStatus === 'DISCARDED') continue;
+      const needed = requiredVotes(p.method, total);
+      const onTrack = passesThreshold(p.method, total, yes);
+      out.push({ ...p, deadline, yes, total, needed, onTrack, derivedStatus });
     }
-  return out;
+    return out;
   }
-  
+
   async function listFutureRevocationsCurrent() {
     const term = await getCurrentTermBase();
     if (!term) return [];
@@ -937,14 +992,39 @@ module.exports = ({ cooler, services = {} }) => {
     return all.filter(r => r.status === 'ENACTED').length;
   }
 
+  async function voteSnapshot(voteId) {
+    if (!voteId || !services.votes?.getVoteById) return null;
+    try {
+      const v = await services.votes.getVoteById(voteId);
+      const vm = v?.votes || {};
+      const sum = Object.values(vm).reduce((s, n) => s + Number(n || 0), 0);
+      const total = Number(v.totalVotes ?? v.total ?? sum);
+      return {
+        YES: Number(vm.YES ?? vm.Yes ?? vm.yes ?? 0),
+        NO: Number(vm.NO ?? vm.No ?? vm.no ?? 0),
+        ABSTENTION: Number(vm.ABSTENTION ?? vm.Abstention ?? vm.abstention ?? 0),
+        total
+      };
+    } catch {
+      return null;
+    }
+  }
+
   async function enactApprovedChanges(expiringTerm) {
     if (!expiringTerm) return;
     const termId = expiringTerm.id || expiringTerm.startAt;
-    const ssbClient = await openSsb();
+
     const proposals = await listByType('parliamentProposal');
     const revocations = await listByType('parliamentRevocation');
+
     const approvedProps = proposals.filter(p => p.termId === termId && p.status === 'APPROVED');
     for (const p of approvedProps) {
+      const snap = await voteSnapshot(p.voteId);
+      const votesFinal =
+        (p.votes && Object.keys(p.votes).length ? p.votes : null) ||
+        snap ||
+        { YES: 1, NO: 0, ABSTENTION: 0, total: 1 };
+
       const law = {
         type: 'parliamentLaw',
         question: p.title,
@@ -952,43 +1032,59 @@ module.exports = ({ cooler, services = {} }) => {
         method: p.method,
         proposer: p.proposer,
         termId: p.termId,
-        votes: p.votes || (p.voteId ? {} : { YES: 1, NO: 0, ABSTENTION: 0, total: 1 }),
+        voteId: p.voteId || null,
+        votes: votesFinal,
         proposedAt: p.createdAt,
         proposalId: p.id,
         enactedAt: nowISO()
       };
-      await new Promise((resolve, reject) => ssbClient.publish(law, (e, r) => (e ? reject(e) : resolve(r))));
+
+      await publishMsg(law);
       const updated = { ...stripId(p), replaces: p.id, status: 'ENACTED', updatedAt: nowISO() };
-      await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => (e ? reject(e) : resolve(r))));
+      await publishMsg(updated);
     }
+
     const approvedRevs = revocations.filter(r => r.termId === termId && r.status === 'APPROVED');
     for (const r of approvedRevs) {
       const tomb = { type: 'tombstone', target: r.lawId, deletedAt: nowISO(), author: userId };
-      await new Promise((resolve) => ssbClient.publish(tomb, () => resolve()));
-      const updated = { ...stripId(r), replaces: r.id, status: 'ENACTED', updatedAt: nowISO() };
-      await new Promise((resolve, reject) => ssbClient.publish(updated, (e, rs) => (e ? reject(e) : resolve(rs))));
+      await publishMsg(tomb);
+
+      const snap = await voteSnapshot(r.voteId);
+      const updated = {
+        ...stripId(r),
+        replaces: r.id,
+        status: 'ENACTED',
+        votes: (r.votes && Object.keys(r.votes).length ? r.votes : null) || snap || undefined,
+        updatedAt: nowISO()
+      };
+      await publishMsg(updated);
     }
   }
 
-  async function resolveElection() {
+  async function resolveElectionImpl() {
     const now = moment();
-    const current = await getCurrentTermBase();
-    if (current && now.isBefore(parseISO(current.endAt))) return current;
-    if (current) {
-      try { await enactApprovedChanges(current); } catch {}
+    const latestAny = await getLatestTermAny();
+    if (latestAny && !isExpiredTerm(latestAny)) return latestAny;
+
+    if (latestAny && isExpiredTerm(latestAny)) {
+      try { await enactApprovedChanges(latestAny); } catch {}
     }
+
     const open = await listCandidaturesOpen();
     let chosen = null;
     let totalVotes = 0;
     let winnerVotes = 0;
+
     if (open.length) {
       const pick = await chooseWinnerFromCandidaturesAsync(open);
       chosen = pick && pick.chosen;
       totalVotes = (pick && pick.totalVotes) || 0;
       winnerVotes = (pick && pick.winnerVotes) || 0;
     }
+
     const startAt = now.toISOString();
     const endAt = moment(startAt).add(TERM_DAYS, 'days').toISOString();
+
     if (!chosen) {
       const termAnarchy = {
         type: 'parliamentTerm',
@@ -1005,22 +1101,23 @@ module.exports = ({ cooler, services = {} }) => {
         createdBy: userId,
         createdAt: nowISO()
       };
-      const ssbClient = await openSsb();
-      const resAnarchy = await new Promise((resolve, reject) =>
-        ssbClient.publish(termAnarchy, (e, r) => (e ? reject(e) : resolve(r)))
-      );
+
+      const resAnarchy = await publishMsg(termAnarchy);
       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()));
+        const myId = resAnarchy.key || resAnarchy.id;
+        if (canonical && canonical.id && myId && canonical.id !== myId) {
+          const tomb = { type: 'tombstone', target: myId, deletedAt: nowISO(), author: userId };
+          await publishMsg(tomb);
+          await archiveAllCandidatures();
           return canonical;
         }
       } catch {}
       await archiveAllCandidatures();
       return resAnarchy;
     }
+
     const term = {
       type: 'parliamentTerm',
       method: chosen.method,
@@ -1036,16 +1133,16 @@ module.exports = ({ cooler, services = {} }) => {
       createdBy: userId,
       createdAt: nowISO()
     };
-    const ssbClient = await openSsb();
-    const res = await new Promise((resolve, reject) =>
-      ssbClient.publish(term, (e, r) => (e ? reject(e) : resolve(r)))
-    );
+
+    const res = await publishMsg(term);
     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()));
+      const myId = res.key || res.id;
+      if (canonical && canonical.id && myId && canonical.id !== myId) {
+        const tomb = { type: 'tombstone', target: myId, deletedAt: nowISO(), author: userId };
+        await publishMsg(tomb);
+        await archiveAllCandidatures();
         return canonical;
       }
     } catch {}
@@ -1053,8 +1150,14 @@ module.exports = ({ cooler, services = {} }) => {
     return res;
   }
 
+  async function resolveElection() {
+    if (electionInFlight) return electionInFlight;
+    electionInFlight = resolveElectionImpl().finally(() => { electionInFlight = null; });
+    return electionInFlight;
+  }
+
   async function getGovernmentCard() {
-    let term = await getCurrentTermBase();
+    const term = await getCurrentTermBase();
     if (!term) return null;
     return await computeGovernmentCard({ ...term, id: term.id || term.startAt });
   }
@@ -1114,6 +1217,7 @@ 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));

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

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

+ 1 - 1
src/server/package.json

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

+ 104 - 9
src/views/activity_view.js

@@ -71,6 +71,30 @@ function excerptPostText(content, max = 220) {
   return raw.length > max ? raw.slice(0, max - 1) + '…' : raw;
 }
 
+const decodeMaybe = (s) => {
+    try { return decodeURIComponent(String(s || '')); } catch { return String(s || ''); }
+};
+
+const rewriteHashtagLinks = (html) => {
+    const s = String(html || '');
+    return s.replace(/href=(["'])(?:https?:\/\/[^"']+)?\/hashtag\/([^"'?#]+)([^"']*)\1/g, (m, q, tag, rest) => {
+        const t = decodeMaybe(tag).replace(/^#/, '').trim().toLowerCase();
+        const href = `/search?query=%23${encodeURIComponent(t)}`;
+        return `href=${q}${href}${q}`;
+    });
+};
+
+function renderUrlPreserveNewlines(text) {
+  const s = String(text || '');
+  const lines = s.split(/\r\n|\r|\n/);
+  const out = [];
+  for (let i = 0; i < lines.length; i++) {
+    if (i) out.push(br());
+    out.push(...renderUrl(lines[i]));
+  }
+  return out;
+}
+
 function getThreadIdFromPost(action) {
   const c = action.value?.content || action.content || {};
   const fork = safeMsgId(c.fork);
@@ -153,7 +177,8 @@ function buildActivityItemsWithPostThreads(deduped, allActions) {
   return out;
 }
 
-function renderActionCards(actions, userId) {
+function renderActionCards(actions, userId, allActions) {
+  const all = Array.isArray(allActions) ? allActions : actions;
   const validActions = actions
     .filter(action => {
       const content = action.value?.content || action.content;
@@ -186,7 +211,26 @@ function renderActionCards(actions, userId) {
   }
 
   const seenDocumentTitles = new Set();
-  const items = buildActivityItemsWithPostThreads(deduped, actions);
+  const items = buildActivityItemsWithPostThreads(deduped, all);
+  const spreadOrdinalById = new Map();
+  const spreadsByLink = new Map();
+
+  for (const a of all) {
+    if (!a || a.type !== 'spread') continue;
+    const c = a.value?.content || a.content || {};
+    const link = c.spreadTargetId || c.vote?.link || '';
+    if (!link || !a.id) continue;
+    if (!spreadsByLink.has(link)) spreadsByLink.set(link, []);
+    spreadsByLink.get(link).push(a);
+  }
+
+  for (const list of spreadsByLink.values()) {
+    list.sort((a, b) => (a.ts || 0) - (b.ts || 0) || String(a.id || '').localeCompare(String(b.id || '')));
+    for (let i = 0; i < list.length; i++) {
+      spreadOrdinalById.set(list[i].id, i + 1);
+    }
+  }
+
   const cards = items.map(action => {
     const date = action.ts ? new Date(action.ts).toLocaleString() : "";
     const userLink = action.author
@@ -576,10 +620,17 @@ function renderActionCards(actions, userId) {
       const { text, refeeds } = content;
       if (!isValidFeedText(text)) return null;
       const safeText = cleanFeedText(text);
+      const htmlText = safeText ? rewriteHashtagLinks(renderTextWithStyles(safeText)) : '';
+      const refeedsNum = Number(refeeds || 0) || 0;
       cardBody.push(
         div({ class: 'card-section feed' },
-          div({ class: 'feed-text', innerHTML: renderTextWithStyles(safeText) }),
-          h2({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeFeedRefeeds + ': '), span({ class: 'card-label' }, refeeds))
+          div({ class: 'feed-text', innerHTML: htmlText }),
+          refeedsNum > 0
+            ? h2({ class: 'card-field' },
+                span({ class: 'card-label' }, i18n.tribeFeedRefeeds + ': '),
+                span({ class: 'card-label' }, String(refeedsNum))
+              )
+            : null
         )
       );
     }
@@ -678,7 +729,7 @@ function renderActionCards(actions, userId) {
       const { root, category, title, text, key, rootTitle, rootKey } = content;
       if (!root) {
         const linkKey = key || action.id;
-        const linkText = (title && String(title).trim()) ? title : '(sin título)';
+        const linkText = (title && String(title).trim()) ? title : '';
         cardBody.push(
           div({ class: 'card-section forum' },
             div({ class: 'card-field', style: "font-size:1.12em; margin-bottom:5px;" },
@@ -690,7 +741,7 @@ function renderActionCards(actions, userId) {
       } else {
         const rootId = typeof root === 'string' ? root : (root?.key || root?.id || '');
         const parentForum = actions.find(a => a.type === 'forum' && !a.content?.root && (a.id === rootId || a.content?.key === rootId));
-        const parentTitle = (parentForum?.content?.title && String(parentForum.content.title).trim()) ? parentForum.content.title : ((rootTitle && String(rootTitle).trim()) ? rootTitle : '(sin título)');
+        const parentTitle = (parentForum?.content?.title && String(parentForum.content.title).trim()) ? parentForum.content.title : ((rootTitle && String(rootTitle).trim()) ? rootTitle : '');
         const hrefKey = rootKey || rootId;
         cardBody.push(
           div({ class: 'card-section forum' },
@@ -707,6 +758,43 @@ function renderActionCards(actions, userId) {
       }
     }
 
+    if (type === 'spread') {
+        const { spreadOriginalAuthor, spreadTitle, spreadContentWarning, spreadText, spreadTotalSpreads } = content || {};
+        const spreadsLabel = (i18n.totalspreads || 'Total spreads') + ':';
+        const total = Number(spreadTotalSpreads || 0);
+        cardBody.push(
+            div({ class: 'card-section vote' },
+                spreadTitle
+                    ? h2(
+                        { class: 'post-title', style: 'margin:.25rem 0 .55rem 0; font-size:1.08em;' },
+                        spreadTitle
+                    )
+                    : '',
+                spreadContentWarning ? h2({ class: 'content-warning' }, spreadContentWarning) : '',
+                spreadText
+                    ? div(
+                        {
+                            class: 'post-text',
+                            style: 'white-space:pre-wrap; line-height:1.45; font-size:1.02em; word-break:break-word; overflow-wrap:anywhere;'
+                        },
+                        ...renderUrlPreserveNewlines(spreadText)
+                    )
+                    : '',
+                spreadOriginalAuthor
+                    ? div(
+                        { class: 'card-field' },
+                        span({ class: 'card-value' }, a({ href: `/author/${encodeURIComponent(spreadOriginalAuthor)}`, class: 'user-link' }, spreadOriginalAuthor))
+                    )
+                    : '',
+                div(
+                    { class: 'card-field' },
+                    span({ class: 'card-label' }, spreadsLabel),
+                    span({ class: 'card-value' }, String(total))
+                )
+            )
+        );
+    }
+
     if (type === 'vote') {
       const { vote } = content;
       cardBody.push(
@@ -1204,6 +1292,10 @@ function getViewDetailsAction(type, action) {
     case 'tribeFeedPost':
     case 'tribeFeedRefeed':
     return `/tribe/${encodeURIComponent(action.content?.tribeId || '')}`;
+    case 'spread': {
+      const link = action.content?.spreadTargetId || action.content?.vote?.link || '';
+      return link ? `/thread/${encodeURIComponent(link)}#${encodeURIComponent(link)}` : `/activity`;
+    }
     case 'votes':      return `/votes/${id}`;
     case 'transfer':   return `/transfers/${id}`;
     case 'pixelia':    return `/pixelia`;
@@ -1260,6 +1352,7 @@ exports.activityView = (actions, filter, userId) => {
     { type: 'feed',      label: i18n.typeFeed },
     { type: 'aiExchange',label: i18n.typeAiExchange },
     { type: 'post',      label: i18n.typePost },
+    { type: 'spread',    label: i18n.typeSpread || 'SPREAD' },
     { type: 'pixelia',   label: i18n.typePixelia },
     { type: 'forum',     label: i18n.typeForum },
     { type: 'bookmark',  label: i18n.typeBookmark },
@@ -1292,6 +1385,8 @@ exports.activityView = (actions, filter, userId) => {
     });
   } else if (filter === 'task') {
     filteredActions = actions.filter(action => action.type !== 'tombstone' && (action.type === 'task' || action.type === 'taskAssignment'));
+  } else if (filter === 'spread') {
+    filteredActions = actions.filter(action => action.type === 'spread');
   } else {
     filteredActions = actions.filter(action => (action.type === filter || filter === 'all') && action.type !== 'tombstone');
   }
@@ -1338,7 +1433,7 @@ exports.activityView = (actions, filter, userId) => {
             )
           ),
           div({ style: 'display: flex; flex-direction: column; gap: 8px;' },
-            activityTypes.slice(17, 21).map(({ type, label }) =>
+            activityTypes.slice(17, 22).map(({ type, label }) =>
               form({ method: 'GET', action: '/activity' },
                 input({ type: 'hidden', name: 'filter', value: type }),
                 button({ type: 'submit', class: filter === type ? 'filter-btn active' : 'filter-btn' }, label)
@@ -1346,7 +1441,7 @@ exports.activityView = (actions, filter, userId) => {
             )
           ),
           div({ style: 'display: flex; flex-direction: column; gap: 8px;' },
-            activityTypes.slice(21, 26).map(({ type, label }) =>
+            activityTypes.slice(22, 27).map(({ type, label }) =>
               form({ method: 'GET', action: '/activity' },
                 input({ type: 'hidden', name: 'filter', value: type }),
                 button({ type: 'submit', class: filter === type ? 'filter-btn active' : 'filter-btn' }, label)
@@ -1355,7 +1450,7 @@ exports.activityView = (actions, filter, userId) => {
           )
         )
       ),
-      section({ class: 'feed-container' }, renderActionCards(filteredActions, userId))
+    section({ class: 'feed-container' }, renderActionCards(filteredActions, userId, actions))
     )
   );
 

+ 72 - 68
src/views/feed_view.js

@@ -27,6 +27,20 @@ const extractTags = (text) => {
   return Array.from(new Set(list));
 };
 
+const rewriteHashtagLinks = (html) => {
+    return String(html || '').replace(
+        /href=(["'])\/hashtag\/([^"'?#\s<]+)\1/gi,
+        (m, q, rawTag) => {
+            let t = String(rawTag || '');
+            try { t = decodeURIComponent(t); } catch {}
+            t = t.replace(/[^A-Za-z0-9_]/g, '');
+            const tag = t.toLowerCase();
+            const query = encodeURIComponent(`#${tag}`);
+            return `href=${q}/search?query=${query}${q}`;
+        }
+    );
+};
+
 const generateFilterButtons = (filters, currentFilter, action, extra = {}) => {
   const cur = String(currentFilter || "").toUpperCase();
   const hiddenInputs = (obj) =>
@@ -54,77 +68,67 @@ const renderVotesSummary = (opinions = {}) => {
   );
 };
 
-const renderTagChips = (tags = []) => {
-  const list = Array.isArray(tags) ? tags : [];
-  if (!list.length) return null;
-  return div(
-    { class: "tag-chips" },
-    list.map((t) => a({ class: "tag-chip", href: `/feed?tag=${encodeURIComponent(t)}` }, `#${t}`))
-  );
-};
-
 const renderFeedCard = (feed) => {
-  const content = feed.value.content || {};
-  const rawText = typeof content.text === "string" ? content.text : "";
-  const safeText = rawText.trim();
-  if (!safeText) return null;
-
-  const voteEntries = Object.entries(content.opinions || {});
-  const totalCount = voteEntries.reduce((sum, [, count]) => sum + (Number(count) || 0), 0);
-  const createdAt = formatDate(feed);
-  const me = config?.keys?.id;
-
-  const alreadyRefeeded = Array.isArray(content.refeeds_inhabitants) && me ? content.refeeds_inhabitants.includes(me) : false;
-  const alreadyVoted = Array.isArray(content.opinions_inhabitants) && me ? content.opinions_inhabitants.includes(me) : false;
-
-  const tags = Array.isArray(content.tags) && content.tags.length ? content.tags : extractTags(safeText);
-
-  const authorId = content.author || feed.value.author || "";
-
-  return div(
-    { class: "feed-card" },
-    div(
-      { class: "feed-row" },
-      div(
-        { class: "refeed-column" },
-        h1(`${content.refeeds || 0}`),
-        form(
-          { method: "POST", action: `/feed/refeed/${encodeURIComponent(feed.key)}` },
-          button({ class: alreadyRefeeded ? "refeed-btn active" : "refeed-btn", type: "submit", disabled: !!alreadyRefeeded }, i18n.refeedButton)
-        ),
-        alreadyRefeeded ? p({ class: "muted" }, i18n.alreadyRefeeded) : null
-      ),
-      div(
-        { class: "feed-main" },
-        div({ class: "feed-text", innerHTML: renderTextWithStyles(safeText) }),
-        renderTagChips(tags),
-        h2(`${i18n.totalOpinions}: ${totalCount}`),
-        p(
-          { class: "card-footer" },
-          span({ class: "date-link" }, `${createdAt} ${i18n.performed} `),
-          a({ href: `/author/${encodeURIComponent(authorId)}`, class: "user-link" }, `${authorId}`),
-          content._textEdited ? span({ class: "edited-badge" }, ` · ${i18n.edited || "edited"}`) : null
-        )
-      )
-    ),
-    div(
-      { class: "votes-wrapper" },
-      renderVotesSummary(content.opinions || {}),
-      div(
-        { class: "voting-buttons" },
-        opinionCategories.map((cat) =>
-          form(
-            { method: "POST", action: `/feed/opinions/${encodeURIComponent(feed.key)}/${cat}` },
-            button(
-              { class: alreadyVoted ? "vote-btn disabled" : "vote-btn", type: "submit", disabled: !!alreadyVoted },
-              `${i18n["vote" + cat.charAt(0).toUpperCase() + cat.slice(1)] || cat} [${content.opinions?.[cat] || 0}]`
+    const content = feed.value.content || {};
+    const rawText = typeof content.text === "string" ? content.text : "";
+    const safeText = rawText.trim();
+    if (!safeText) return null;
+
+    const voteEntries = Object.entries(content.opinions || {});
+    const totalCount = voteEntries.reduce((sum, [, count]) => sum + (Number(count) || 0), 0);
+    const createdAt = formatDate(feed);
+    const me = config?.keys?.id;
+
+    const alreadyRefeeded = Array.isArray(content.refeeds_inhabitants) && me ? content.refeeds_inhabitants.includes(me) : false;
+    const alreadyVoted = Array.isArray(content.opinions_inhabitants) && me ? content.opinions_inhabitants.includes(me) : false;
+
+    const authorId = content.author || feed.value.author || "";
+    const refeedsNum = Number(content.refeeds || 0) || 0;
+    const styledHtml = rewriteHashtagLinks(renderTextWithStyles(safeText));
+
+    return div(
+        { class: "feed-card" },
+        div(
+            { class: "feed-row" },
+            div(
+                { class: "refeed-column" },
+                h1(String(refeedsNum)),
+                form(
+                    { method: "POST", action: `/feed/refeed/${encodeURIComponent(feed.key)}` },
+                    button({ class: alreadyRefeeded ? "refeed-btn active" : "refeed-btn", type: "submit", disabled: !!alreadyRefeeded }, i18n.refeedButton)
+                ),
+                alreadyRefeeded ? p({ class: "muted" }, i18n.alreadyRefeeded) : null
+            ),
+            div(
+                { class: "feed-main" },
+                div({ class: "feed-text", innerHTML: styledHtml }),
+                h2(`${i18n.totalOpinions}: ${totalCount}`),
+                p(
+                    { class: "card-footer" },
+                    span({ class: "date-link" }, `${createdAt} ${i18n.performed} `),
+                    a({ href: `/author/${encodeURIComponent(authorId)}`, class: "user-link" }, `${authorId}`),
+                    content._textEdited ? span({ class: "edited-badge" }, ` · ${i18n.edited || "edited"}`) : null
+                )
             )
-          )
+        ),
+        div(
+            { class: "votes-wrapper" },
+            renderVotesSummary(content.opinions || {}),
+            div(
+                { class: "voting-buttons" },
+                opinionCategories.map((cat) =>
+                    form(
+                        { method: "POST", action: `/feed/opinions/${encodeURIComponent(feed.key)}/${cat}` },
+                        button(
+                            { class: alreadyVoted ? "vote-btn disabled" : "vote-btn", type: "submit", disabled: !!alreadyVoted },
+                            `${i18n["vote" + cat.charAt(0).toUpperCase() + cat.slice(1)] || cat} [${content.opinions?.[cat] || 0}]`
+                        )
+                    )
+                )
+            ),
+            alreadyVoted ? p({ class: "muted" }, i18n.alreadyVoted) : null
         )
-      ),
-      alreadyVoted ? p({ class: "muted" }, i18n.alreadyVoted) : null
-    )
-  );
+    );
 };
 
 exports.feedView = (feeds, opts = "ALL") => {

+ 60 - 35
src/views/parliament_view.js

@@ -2,6 +2,8 @@ const { form, button, div, h2, p, section, input, label, br, a, span, table, the
 const moment = require("../server/node_modules/moment");
 const { template, i18n } = require('./main_views');
 
+const TERM_DAYS = 60;
+
 const fmt = (d) => moment(d).format('YYYY-MM-DD HH:mm:ss');
 const timeLeft = (end) => {
   const diff = moment(end).diff(moment());
@@ -28,27 +30,27 @@ 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();
+  const labelTxt = String(i18n[`parliamentMethod${m}`] || m).toUpperCase();
   return span(
     { class: 'method-badge' },
-    label,
+    labelTxt,
     br(),br(),
-    img({ src: methodImageSrc(m), alt: label, class: 'method-badge__icon' })
+    img({ src: methodImageSrc(m), alt: labelTxt, class: 'method-badge__icon' })
   );
 };
 const MethodHero = (method) => {
   const m = String(method || '').toUpperCase();
-  const label = String(i18n[`parliamentMethod${m}`] || m).toUpperCase();
+  const labelTxt = String(i18n[`parliamentMethod${m}`] || m).toUpperCase();
   return span(
     { class: 'method-hero' },
-    label,
+    labelTxt,
     br(),br(),
-    img({ src: methodImageSrc(m), alt: label, class: 'method-hero__icon' })
+    img({ src: methodImageSrc(m), alt: labelTxt, class: 'method-hero__icon' })
   );
 };
-const KPI = (label, value) =>
+const KPI = (labelTxt, value) =>
   div({ class: 'kpi' },
-    span({ class: 'kpi__label' }, label),
+    span({ class: 'kpi__label' }, labelTxt),
     span({ class: 'kpi__value' }, value)
   );
 const CycleInfo = (start, end, labels = {
@@ -71,9 +73,10 @@ 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();
+  const termEnd = g && g.end ? g.end : moment(termStart).add(TERM_DAYS, 'days').toISOString();
   const methodKeyRaw = g && g.method ? String(g.method) : 'ANARCHY';
   const methodKey = methodKeyRaw.toUpperCase();
   const i18nMeth = i18n[`parliamentMethod${methodKey}`];
@@ -113,17 +116,18 @@ 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 termEnd = g && g.end ? g.end : moment(termStart).add(TERM_DAYS, 'days').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 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)
@@ -192,11 +196,13 @@ 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' },
@@ -212,7 +218,7 @@ 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) => {
@@ -231,11 +237,11 @@ const pickLeader = (arr) => {
 
 const CandidatureStats = (cands, govCard, leaderMeta) => {
   if (!cands || !cands.length) return null;
-  const leader      = pickLeader(cands || []);
-  const methodKey   = String(leader.method || '').toUpperCase();
+  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 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'
@@ -449,7 +455,7 @@ 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 => {
@@ -827,10 +833,8 @@ 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();
   return div(
     h2(i18n.parliamentGovernmentCard),
     GovHeader(governmentCard || {}),
@@ -856,7 +860,15 @@ const RevocationsSection = (governmentCard, laws, revocations, futureRevocations
     RevocationsList(revocations || []) || '',
     FutureRevocationsList(futureRevocations || []) || ''
   );
-  
+
+const normalizeGovCard = (governmentCard, inhabitantsTotal) => {
+  const pop = Number(inhabitantsTotal ?? governmentCard?.inhabitantsTotal ?? 0) || 0;
+  if (governmentCard && (governmentCard.method || governmentCard.since || governmentCard.end || governmentCard.powerType)) {
+    return { ...governmentCard, inhabitantsTotal: pop };
+  }
+  return null;
+};
+
 const parliamentView = async (state) => {
   const {
     filter,
@@ -874,14 +886,11 @@ const parliamentView = async (state) => {
     futureRevocations,
     revocationsEnactedCount,
     historicalMetas = {},
-    leadersMetas = {}
+    leadersMetas = {},
+    inhabitantsTotal
   } = state;
-  const LawsSectionWrap = () =>
-    div(
-      LawsStats(laws || [], revocationsEnactedCount || 0),
-      LawsList(laws || [])
-    );
-  const fallbackAnarchy = {
+
+  const fallbackGov = {
     method: 'ANARCHY',
     votesReceived: 0,
     totalVotes: 0,
@@ -890,22 +899,38 @@ const parliamentView = async (state) => {
     declined: 0,
     discarded: 0,
     revocated: 0,
-    efficiency: 0
+    efficiency: 0,
+    powerType: 'none',
+    powerId: null,
+    powerTitle: 'ANARCHY',
+    since: moment().toISOString(),
+    end: moment().add(TERM_DAYS, 'days').toISOString(),
+    inhabitantsTotal: Number(inhabitantsTotal ?? 0) || 0
   };
+
+  const gov = normalizeGovCard(governmentCard, inhabitantsTotal) || fallbackGov;
+
+  const LawsSectionWrap = () =>
+    div(
+      LawsStats(laws || [], revocationsEnactedCount || 0),
+      LawsList(laws || [])
+    );
+
   return template(
     i18n.parliamentTitle,
     section(div({ class: 'tags-header' }, h2(i18n.parliamentTitle), p(i18n.parliamentDescription)), Tabs(filter)),
     section(
-      filter === 'government' ? GovernmentCard(governmentCard || fallbackAnarchy, powerMeta) : null,
-      filter === 'candidatures' ? CandidaturesSection(governmentCard || fallbackAnarchy, candidatures, leaderMeta) : null,
-      filter === 'proposals' ? ProposalsSection(governmentCard || fallbackAnarchy, proposals, futureLaws, canPropose) : null,
+      filter === 'government' ? GovernmentCard(gov, powerMeta) : null,
+      filter === 'candidatures' ? CandidaturesSection(gov, candidatures, leaderMeta) : null,
+      filter === 'proposals' ? ProposalsSection(gov, proposals, futureLaws, canPropose) : null,
       filter === 'laws' ? LawsSectionWrap() : null,
-      filter === 'revocations' ? RevocationsSection(governmentCard || fallbackAnarchy, laws, revocations, futureRevocations) : null,
+      filter === 'revocations' ? RevocationsSection(gov, laws, revocations, futureRevocations) : null,
       filter === 'historical' ? div(HistoricalGovsSummary(historical || []), HistoricalList(historical || [], historicalMetas)) : null,
       filter === 'leaders' ? div(LeadersSummary(leaders || [], candidatures || []), LeadersList(leaders || [], leadersMetas, candidatures || [])) : null,
       filter === 'rules' ? RulesContent() : null
     )
   );
 };
+
 module.exports = { parliamentView, pickLeader };
 

+ 25 - 3
src/views/search_view.js

@@ -4,6 +4,19 @@ const moment = require("../server/node_modules/moment");
 const { renderTextWithStyles } = require('../backend/renderTextWithStyles');
 const { renderUrl } = require('../backend/renderUrl');
 
+const decodeMaybe = (s) => {
+  try { return decodeURIComponent(String(s || '')); } catch { return String(s || ''); }
+};
+
+const rewriteHashtagLinks = (html) => {
+  const s = String(html || '');
+  return s.replace(/href=(["'])(?:https?:\/\/[^"']+)?\/hashtag\/([^"'?#]+)([^"']*)\1/g, (m, q, tag, rest) => {
+    const t = decodeMaybe(tag).replace(/^#/, '').trim().toLowerCase();
+    const href = `/search?query=%23${encodeURIComponent(t)}`;
+    return `href=${q}${href}${q}`;
+  });
+};
+
 const searchView = ({ messages = [], blobs = {}, query = "", type = "", types = [], hashtag = null, results = {}, resultCount = "10" }) => {
   const searchInput = input({
     name: "query",
@@ -91,11 +104,20 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
           content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.description + ':'), span({ class: 'card-value' }, content.description)) : null,
           content.image ? img({ src: `/image/64/${encodeURIComponent(content.image)}` }) : null
         );
-      case 'feed':
+      case 'feed': {
+        const rawText = typeof content.text === 'string' ? content.text.trim() : '';
+        const htmlText = rawText ? rewriteHashtagLinks(renderTextWithStyles(rawText)) : '';
+        const refeedsNum = Number(content.refeeds || 0) || 0;
         return div({ class: 'search-feed' },
-          content.text ? h2({ class: 'card-field' }, span({ class: 'card-value' }, content.text)) : null,
-          h2({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeFeedRefeeds + ':'), span({ class: 'card-value' }, content.refeeds))
+          rawText ? div({ class: 'card-field' }, span({ class: 'card-value', innerHTML: htmlText })) : null,
+          refeedsNum > 0
+            ? h2({ class: 'card-field' },
+                span({ class: 'card-label' }, i18n.tribeFeedRefeeds + ':'),
+                span({ class: 'card-value' }, String(refeedsNum))
+              )
+            : null
         );
+      }
       case 'event':
         return div({ class: 'search-event' },
           content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.eventTitleLabel + ':'), span({ class: 'card-value' }, content.title)) : null,