Bläddra i källkod

Oasis release 0.5.9

psy 2 dagar sedan
förälder
incheckning
5e9c3dac80

+ 10 - 0
docs/CHANGELOG.md

@@ -13,6 +13,16 @@ All notable changes to this project will be documented in this file.
 ### Security
 -->
 
+## v0.5.9 - 2025-11-28
+
+### Added
+
+ + Added fixed (also linked) threads into activity feed (Activity plugin).
+ 
+### Fixed
+
+ + Fixed laws stats (Parliament plugin).
+
 ## v0.5.8 - 2025-11-25
 
 ### Fixed

+ 39 - 17
src/backend/backend.js

@@ -78,8 +78,30 @@ function readWalletMap() {
 }
 
 //parliament
+let electionInFlight = null;
+async function ensureTerm() {
+  const current = await parliamentModel.getCurrentTerm().catch(() => null);
+  if (current) return current;
+  if (electionInFlight) return electionInFlight;
+  electionInFlight = (async () => {
+    try { return await parliamentModel.resolveElection(); } catch { return null; }
+    finally { electionInFlight = null; }
+  })();
+  return electionInFlight;
+}
+let sweepInFlight = null;
+async function runSweepOnce() {
+  if (sweepInFlight) return sweepInFlight;
+  sweepInFlight = (async () => {
+    try { await parliamentModel.sweepProposals(); } catch {}
+    finally { sweepInFlight = null; }
+  })();
+  return sweepInFlight;
+}
 async function buildState(filter) {
   const f = (filter || 'government').toLowerCase();
+  await ensureTerm();
+  await runSweepOnce();
   const [govCard, candidatures, proposals, canPropose, laws, historical] = await Promise.all([
     parliamentModel.getGovernmentCard(),
     parliamentModel.listCandidatures('OPEN'),
@@ -1138,18 +1160,17 @@ router
   })
   .get('/parliament', async (ctx) => {
     const mod = ctx.cookies.get('parliamentMod') || 'on';
-    if (mod !== 'on') { ctx.redirect('/modules'); return }
+    if (mod !== 'on') { ctx.redirect('/modules'); return; }
     const filter = (ctx.query.filter || 'government').toLowerCase();
-    let governmentCard = await parliamentModel.getGovernmentCard();
-    if (!governmentCard || !governmentCard.end || moment().isAfter(moment(governmentCard.end))) {
-      await parliamentModel.resolveElection();
-      governmentCard = await parliamentModel.getGovernmentCard();
-    }
+    await ensureTerm();
+    await runSweepOnce();
     const [
+      governmentCard,
       candidatures, proposals, futureLaws, canPropose, laws,
       historical, leaders, revocations, futureRevocations, revocationsEnactedCount,
       inhabitantsAll
-     ] = await Promise.all([
+    ] = await Promise.all([
+      parliamentModel.getGovernmentCard(),
       parliamentModel.listCandidatures('OPEN'),
       parliamentModel.listProposalsCurrent(),
       parliamentModel.listFutureLawsCurrent(),
@@ -1164,7 +1185,12 @@ router
     ]);
     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;
+    const leaderMeta = leader
+      ? await parliamentModel.getActorMeta({
+          targetType: leader.targetType || leader.powerType || 'inhabitant',
+          targetId: leader.targetId || leader.powerId
+        })
+      : null;
     const powerMeta = (governmentCard && (governmentCard.powerType === 'tribe' || governmentCard.powerType === 'inhabitant'))
       ? await parliamentModel.getActorMeta({ targetType: governmentCard.powerType, targetId: governmentCard.powerId })
       : null;
@@ -1172,18 +1198,14 @@ router
     for (const g of (historical || []).slice(0, 12)) {
       if (g.powerType === 'tribe' || g.powerType === 'inhabitant') {
         const k = `${g.powerType}:${g.powerId}`;
-        if (!historicalMetas[k]) {
-          historicalMetas[k] = await parliamentModel.getActorMeta({ targetType: g.powerType, targetId: g.powerId });
-        }
+        if (!historicalMetas[k]) historicalMetas[k] = await parliamentModel.getActorMeta({ targetType: g.powerType, targetId: g.powerId });
       }
     }
     const leadersMetas = {};
     for (const r of (leaders || []).slice(0, 20)) {
       if (r.powerType === 'tribe' || r.powerType === 'inhabitant') {
         const k = `${r.powerType}:${r.powerId}`;
-        if (!leadersMetas[k]) {
-          leadersMetas[k] = await parliamentModel.getActorMeta({ targetType: r.powerType, targetId: r.powerId });
-        }
+        if (!leadersMetas[k]) leadersMetas[k] = await parliamentModel.getActorMeta({ targetType: r.powerType, targetId: r.powerId });
       }
     }
     const govWithPopulation = governmentCard ? { ...governmentCard, inhabitantsTotal } : { inhabitantsTotal };
@@ -1200,10 +1222,10 @@ router
       leaderMeta,
       powerMeta,
       historicalMetas,
-      leadersMetas,  
+      leadersMetas,
       revocations,
       futureRevocations,
-      revocationsEnactedCount 
+      revocationsEnactedCount
     });
   })
   .get('/courts', async (ctx) => {
@@ -3181,7 +3203,7 @@ router
     ctx.redirect('/parliament?filter=proposals');
   })
   .post('/parliament/resolve', koaBody(), async (ctx) => {
-    await parliamentModel.resolveElection().catch(e => ctx.throw(400, String((e && e.message) || e)));
+    await ensureTerm();
     ctx.redirect('/parliament?filter=government');
   })
   .post('/parliament/revocations/create', koaBody(), async (ctx) => {

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

@@ -721,6 +721,35 @@ footer .btn:hover {
   max-width: 100%;
 }
 
+.thread-reply-item {
+    margin-bottom: 18px;
+}
+
+.thread-reply-item:last-child {
+    margin-bottom: 0;
+}
+
+.thread-reply-footer {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    flex-wrap: wrap;
+    margin-top: 10px;
+    padding: 0;
+    border: none !important;
+    background: transparent !important;
+    box-shadow: none !important;
+}
+
+.thread-reply-footer .inline-form {
+    display: inline-flex;
+    margin: 0;
+}
+
+.thread-reply-footer .inline-form button {
+    margin: 0;
+}
+
 .publish-textarea {
   min-height: 10em;
   padding: 0.75em;

+ 155 - 166
src/models/parliament_model.js

@@ -24,6 +24,11 @@ module.exports = ({ cooler, services = {} }) => {
   const nowISO = () => new Date().toISOString();
   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;
+    const { id, ...rest } = obj;
+  return rest;
+  };
   const normMs = (t) => (t && t < 1e12 ? t * 1000 : t || 0);
 
   async function readLog() {
@@ -37,20 +42,35 @@ module.exports = ({ cooler, services = {} }) => {
     const msgs = await readLog();
     const tomb = new Set();
     const rep = new Map();
+    const children = new Map();
     const map = new Map();
     for (const m of msgs) {
       const k = m.key;
-      const c = m.value?.content;
+      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) rep.set(c.replaces, k);
-        map.set(k, { id: k, ...c });
+        if (c.replaces) {
+          const oldId = c.replaces;
+          const ts = normMs(v.timestamp || m.timestamp || Date.now());
+          const prev = rep.get(oldId);
+          if (!prev || ts > prev.ts) rep.set(oldId, { id: k, ts });
+          if (!children.has(oldId)) children.set(oldId, new Set());
+          children.get(oldId).add(k);
+        }
+        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;
+      for (const kid of kids) {
+        if (kid !== winner) map.delete(kid);
+      }
+    }
     for (const tId of tomb) map.delete(tId);
-    return [...map.values()];
+    return [...map.values()]; 
   }
 
   async function listTribesAny() {
@@ -295,24 +315,13 @@ module.exports = ({ cooler, services = {} }) => {
 
   async function summarizePoliciesForTerm(termOrId) {
     let termId = null;
-    let termStartMs = null;
-    let termEndMs = null;
     if (termOrId && typeof termOrId === 'object') {
       termId = termOrId.id || termOrId.startAt;
-      if (termOrId.startAt) termStartMs = new Date(termOrId.startAt).getTime();
-      if (termOrId.endAt) termEndMs = new Date(termOrId.endAt).getTime();
     } else {
       termId = termOrId;
     }
     const proposals = await listByType('parliamentProposal');
-    const mine = proposals.filter(p => {
-      if (termId && p.termId === termId) return true;
-      if (termStartMs != null && termEndMs != null && p.createdAt) {
-        const t = new Date(p.createdAt).getTime();
-        return t >= termStartMs && t <= termEndMs;
-      }
-      return false;
-    });
+    const mine = termId ? proposals.filter(p => p.termId === termId) : [];
     const proposed = mine.length;
     let approved = 0;
     let declined = 0;
@@ -332,30 +341,20 @@ module.exports = ({ cooler, services = {} }) => {
           const closed = v.status === 'CLOSED' || (dl && moment(dl).isBefore(moment()));
           const reached = passesThreshold(p.method, total, yes);
           if (!closed) {
-            if (dl && moment(dl).isBefore(moment()) && !reached) {
-              isDiscarded = true;
-            } else {
-              finalStatus = 'OPEN';
-            }
+            if (dl && moment(dl).isBefore(moment()) && !reached) isDiscarded = true;
+            else finalStatus = 'OPEN';
           } else {
-            if (reached) finalStatus = 'APPROVED';
-            else finalStatus = 'REJECTED';
+            finalStatus = reached ? 'APPROVED' : 'REJECTED';
           }
         } catch {}
       } else {
-        if (baseStatus === 'OPEN' && p.deadline && moment(p.deadline).isBefore(moment())) {
-          isDiscarded = true;
-        }
+        if (baseStatus === 'OPEN' && p.deadline && moment(p.deadline).isBefore(moment())) isDiscarded = true;
       }
       if (isDiscarded) {
         discarded++;
         continue;
       }
-      if (finalStatus === 'ENACTED') {
-        approved++;
-        continue;
-      }
-      if (finalStatus === 'APPROVED') {
+      if (finalStatus === 'ENACTED' || finalStatus === 'APPROVED') {
         approved++;
         continue;
       }
@@ -365,15 +364,9 @@ module.exports = ({ cooler, services = {} }) => {
       }
     }
     const revs = await listByType('parliamentRevocation');
-    const revocated = revs.filter(r => {
-      if (r.status !== 'ENACTED') return false;
-      if (termId && r.termId === termId) return true;
-      if (termStartMs != null && termEndMs != null && r.createdAt) {
-        const t = new Date(r.createdAt).getTime();
-        return t >= termStartMs && t <= termEndMs;
-      }
-      return false;
-    }).length;
+    const revocated = termId
+      ? revs.filter(r => r.status === 'ENACTED' && r.termId === termId).length
+      : 0;
     return { proposed, approved, declined, discarded, revocated };
   }
 
@@ -449,10 +442,10 @@ module.exports = ({ cooler, services = {} }) => {
     const winner = withKarma[0];
     const losers = withKarma.slice(1);
     const ssbClient = await openSsb();
-    const approve = { ...winner, replaces: winner.id, status: 'APPROVED', updatedAt: nowISO() };
+    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))));
     for (const lo of losers) {
-      const rej = { ...lo, replaces: lo.id, status: 'REJECTED', updatedAt: nowISO() };
+      const rej = { ...stripId(lo), replaces: lo.id, status: 'REJECTED', updatedAt: nowISO() };
       await new Promise((resolve) => ssbClient.publish(rej, () => resolve()));
     }
   }
@@ -469,7 +462,7 @@ module.exports = ({ cooler, services = {} }) => {
     if (!pending.length) return;
     const ssbClient = await openSsb();
     for (const p of pending) {
-      const updated = { ...p, replaces: p.id, status: 'APPROVED', updatedAt: nowISO() };
+      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))));
     }
   }
@@ -486,7 +479,7 @@ module.exports = ({ cooler, services = {} }) => {
     if (!pending.length) return;
     const ssbClient = await openSsb();
     for (const p of pending) {
-      const updated = { ...p, replaces: p.id, status: 'APPROVED', updatedAt: nowISO() };
+      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))));
     }
   }
@@ -503,7 +496,7 @@ module.exports = ({ cooler, services = {} }) => {
     if (!pending.length) return;
     const ssbClient = await openSsb();
     for (const p of pending) {
-      const updated = { ...p, replaces: p.id, status: 'APPROVED', updatedAt: nowISO() };
+      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))));
     }
   }
@@ -563,27 +556,32 @@ module.exports = ({ cooler, services = {} }) => {
 
   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)));
+    const msg = await new Promise((resolve, reject) =>
+      ssbClient.get(revId, (e, m) => (e || !m) ? reject(new Error('Revocation not found')) : resolve(m))
+    );
     if (msg.content?.type !== 'parliamentRevocation') throw new Error('Revocation not found');
     const p = msg.content;
-    if (p.method === 'DICTATORSHIP') {
+    const currentStatus = String(p.status || 'OPEN').toUpperCase();
+    if (currentStatus === 'ENACTED' || currentStatus === 'REJECTED' || currentStatus === 'DISCARDED') return p;
+    const method = String(p.method || '').toUpperCase();
+    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))));
     }
-    if (p.method === 'KARMATOCRACY') {
-      return p;
-    }
+    if (method === 'KARMATOCRACY') return p;
     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);
     let ok = false;
-    const m = String(p.method || '').toUpperCase();
-    if (m === 'DEMOCRACY' || m === 'ANARCHY') ok = yes >= democracyThreshold(total);
-    else if (m === 'MAJORITY') ok = yes >= majorityThreshold(total);
-    else if (m === 'MINORITY') ok = yes >= minorityThreshold(total);
-    const updated = { ...p, replaces: revId, status: ok ? 'APPROVED' : 'REJECTED', updatedAt: nowISO() };
+    if (method === 'DEMOCRACY' || method === 'ANARCHY') ok = yes >= democracyThreshold(total);
+    else if (method === 'MAJORITY') ok = yes >= majorityThreshold(total);
+    else if (method === 'MINORITY') ok = yes >= minorityThreshold(total);
+    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))));
   }
 
@@ -665,27 +663,32 @@ module.exports = ({ cooler, services = {} }) => {
 
   async function closeProposal(proposalId) {
     const ssbClient = await openSsb();
-    const msg = await new Promise((resolve, reject) => ssbClient.get(proposalId, (e, m) => (e || !m) ? reject(new Error('Proposal not found')) : resolve(m)));
+    const msg = await new Promise((resolve, reject) =>
+      ssbClient.get(proposalId, (e, m) => (e || !m) ? reject(new Error('Proposal not found')) : resolve(m))
+    );
     if (msg.content?.type !== 'parliamentProposal') throw new Error('Proposal not found');
     const p = msg.content;
-    if (p.method === 'DICTATORSHIP') {
+    const currentStatus = String(p.status || 'OPEN').toUpperCase();
+    if (currentStatus === 'ENACTED' || currentStatus === 'REJECTED' || currentStatus === 'DISCARDED') return p;
+    const method = String(p.method || '').toUpperCase();
+    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))));
     }
-    if (p.method === 'KARMATOCRACY') {
-      return p;
-    }
+    if (method === 'KARMATOCRACY') return p;
     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);
     let ok = false;
-    const m = String(p.method || '').toUpperCase();
-    if (m === 'DEMOCRACY' || m === 'ANARCHY') ok = yes >= democracyThreshold(total);
-    else if (m === 'MAJORITY') ok = yes >= majorityThreshold(total);
-    else if (m === 'MINORITY') ok = yes >= minorityThreshold(total);
-    const updated = { ...p, replaces: proposalId, status: ok ? 'APPROVED' : 'REJECTED', updatedAt: nowISO() };
+    if (method === 'DEMOCRACY' || method === 'ANARCHY') ok = yes >= democracyThreshold(total);
+    else if (method === 'MAJORITY') ok = yes >= majorityThreshold(total);
+    else if (method === 'MINORITY') ok = yes >= minorityThreshold(total);
+    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))));
   }
 
@@ -710,7 +713,7 @@ module.exports = ({ cooler, services = {} }) => {
         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 = { ...p, replaces: p.id, status: 'APPROVED', updatedAt: nowISO() };
+          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 {}
@@ -732,7 +735,7 @@ module.exports = ({ cooler, services = {} }) => {
         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 = { ...p, replaces: p.id, status: 'APPROVED', updatedAt: nowISO() };
+          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 {}
@@ -793,16 +796,14 @@ module.exports = ({ cooler, services = {} }) => {
 
   async function listProposalsCurrent() {
     const all = await listByType('parliamentProposal');
-    const rows = all
-      .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
+    const rows = all.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
     const out = [];
     for (const p of rows) {
-      const status = String(p.status || 'OPEN').toUpperCase();
-      if (status === 'ENACTED' || status === 'REJECTED' || status === 'DISCARDED') continue;
       let deadline = p.deadline || null;
       let yes = 0;
       let total = 0;
-      let voteClosed = false;
+      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);
@@ -811,42 +812,40 @@ module.exports = ({ cooler, services = {} }) => {
           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) {
-            try { await this.closeProposal(p.id); } catch {}
-            continue;
-          }
+          const closed = v.status === 'CLOSED' || (deadline && moment(deadline).isBefore(moment()));
           const reached = passesThreshold(p.method, total, yes);
-          if (reached && status !== 'APPROVED') {
-            const ssbClient = await openSsb();
-            const updated = { ...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 (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 });
+      out.push({ ...p, deadline, yes, total, needed, onTrack, derivedStatus });
     }
     return out;
   }
 
- async function listFutureLawsCurrent() {
-  const term = await getCurrentTermBase();
-  if (!term) return [];
-  const termId = term.id || term.startAt;
-  const all = await listByType('parliamentProposal');
-  const rows = all
-    .filter(p => p.termId === termId)
-    .filter(p => p.status === 'APPROVED')
-    .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
-  const out = [];
-  for (const p of rows) {
-    let yes = 0;
-    let total = 0;
-    let deadline = p.deadline || null;
-    let voteClosed = true;
-    if (p.voteId && services.votes?.getVoteById) {
+  async function listFutureLawsCurrent() {
+    const term = await getCurrentTermBase();
+    if (!term) return [];
+    const termId = term.id || term.startAt;
+    const all = await listByType('parliamentProposal');
+    const rows = all
+      .filter(p => p.termId === termId)
+      .filter(p => p.status === 'APPROVED')
+      .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
+    const out = [];
+    for (const p of rows) {
+      let yes = 0;
+      let total = 0;
+      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 || {};
@@ -862,21 +861,19 @@ module.exports = ({ cooler, services = {} }) => {
     out.push({ ...p, deadline, yes, total, needed });
   }
   return out;
- }
+  }
 
- async function listRevocationsCurrent() {
-  const all = await listByType('parliamentRevocation');
-  const rows = all
-    .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
-  const out = [];
-  for (const p of rows) {
-    const status = String(p.status || 'OPEN').toUpperCase();
-    if (status === 'ENACTED' || status === 'REJECTED' || status === 'DISCARDED') continue;
-    let deadline = p.deadline || null;
-    let yes = 0;
-    let total = 0;
-    let voteClosed = false;
-    if (p.voteId && services.votes?.getVoteById) {
+  async function listRevocationsCurrent() {
+    const all = await listByType('parliamentRevocation');
+    const rows = all.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
+    const out = [];
+    for (const p of rows) {
+      let deadline = p.deadline || null;
+      let yes = 0;
+      let total = 0;
+      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 || {};
@@ -884,58 +881,56 @@ module.exports = ({ cooler, services = {} }) => {
         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) {
-          try { await closeRevocation(p.id); } catch {}
-          continue;
-        }
+        const closed = v.status === 'CLOSED' || (deadline && moment(deadline).isBefore(moment()));
         const reached = passesThreshold(p.method, total, yes);
-        if (reached && status !== 'APPROVED') {
-          const ssbClient = await openSsb();
-          const updated = { ...p, replaces: p.id, status: 'APPROVED', updatedAt: nowISO() };
-          await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => (e ? reject(e) : resolve(r))));
+          if (closed) derivedStatus = reached ? 'APPROVED' : 'REJECTED';
+          else derivedStatus = 'OPEN';
+        } catch {
+          derivedStatus = baseStatus;
         }
-      } catch {}
+      } 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 });
-  }
+    out.push({ ...p, deadline, yes, total, needed, onTrack, derivedStatus });
+    }
   return out;
-}
+  }
   
-async function listFutureRevocationsCurrent() {
-  const term = await getCurrentTermBase();
-  if (!term) return [];
-  const termId = term.id || term.startAt;
-  const all = await listByType('parliamentRevocation');
-  const rows = all
-    .filter(p => p.termId === termId)
-    .filter(p => p.status === 'APPROVED')
-    .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
-  const out = [];
-  for (const p of rows) {
-    let yes = 0;
-    let total = 0;
-    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 {}
+  async function listFutureRevocationsCurrent() {
+    const term = await getCurrentTermBase();
+    if (!term) return [];
+    const termId = term.id || term.startAt;
+    const all = await listByType('parliamentRevocation');
+    const rows = all
+      .filter(p => p.termId === termId)
+      .filter(p => p.status === 'APPROVED')
+      .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
+    const out = [];
+    for (const p of rows) {
+      let yes = 0;
+      let total = 0;
+      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 {}
+      }
+      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 countRevocationsEnacted() {
     const all = await listByType('parliamentRevocation');
@@ -963,14 +958,14 @@ async function listFutureRevocationsCurrent() {
         enactedAt: nowISO()
       };
       await new Promise((resolve, reject) => ssbClient.publish(law, (e, r) => (e ? reject(e) : resolve(r))));
-      const updated = { ...p, replaces: p.id, status: 'ENACTED', updatedAt: nowISO() };
+      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))));
     }
     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 = { ...r, replaces: r.id, status: 'ENACTED', updatedAt: nowISO() };
+      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))));
     }
   }
@@ -1060,14 +1055,8 @@ async function listFutureRevocationsCurrent() {
 
   async function getGovernmentCard() {
     let term = await getCurrentTermBase();
-    if (!term) {
-      await this.resolveElection();
-      term = await getCurrentTermBase();
-    }
     if (!term) return null;
-    try { await this.sweepProposals(); } catch {}
-    const full = await computeGovernmentCard({ ...term, id: term.id || term.startAt });
-    return full;
+    return await computeGovernmentCard({ ...term, id: term.id || term.startAt });
   }
 
   async function listLaws() {

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

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

+ 1 - 1
src/server/package.json

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

+ 169 - 5
src/views/activity_view.js

@@ -39,6 +39,107 @@ function pickActiveParliamentTerm(terms) {
   return terms.sort(cmp)[0];
 }
 
+function safeMsgId(x) {
+  if (typeof x === 'string') return x;
+  if (x && typeof x === 'object') return x.key || x.id || x.link || '';
+  return '';
+}
+
+function stripHtml(s) {
+  return String(s || '')
+    .replace(/<[^>]*>/g, ' ')
+    .replace(/\s+/g, ' ')
+    .trim();
+}
+
+function excerptPostText(content, max = 220) {
+  const raw = stripHtml(content?.text || '');
+  if (!raw) return '';
+  return raw.length > max ? raw.slice(0, max - 1) + '…' : raw;
+}
+
+function getThreadIdFromPost(action) {
+  const c = action.value?.content || action.content || {};
+  const fork = safeMsgId(c.fork);
+  const root = safeMsgId(c.root);
+  return fork || root || action.id;
+}
+
+function getReplyToIdFromPost(action, byId) {
+  const c = action.value?.content || action.content || {};
+  const root = safeMsgId(c.root);
+  const branch = Array.isArray(c.branch) ? c.branch.filter(x => typeof x === 'string') : [];
+  let best = '';
+  let bestTs = -1;
+  for (const id of branch) {
+    const a = byId.get(id);
+    if (a && (a.ts || 0) > bestTs) { best = id; bestTs = a.ts || 0; }
+  }
+  return best || root || '';
+}
+
+function buildActivityItemsWithPostThreads(deduped, allActions) {
+  const byId = new Map();
+  for (const a of allActions) if (a?.id) byId.set(a.id, a);
+  for (const a of deduped) if (a?.id) byId.set(a.id, a);
+
+  const groups = new Map();
+  const out = [];
+
+  for (const a of deduped) {
+    if (a.type !== 'post') {
+      out.push(a);
+      continue;
+    }
+    const threadId = getThreadIdFromPost(a);
+    if (!groups.has(threadId)) groups.set(threadId, []);
+    groups.get(threadId).push(a);
+  }
+
+  for (const [threadId, posts] of groups.entries()) {
+    const sortedDesc = posts.slice().sort((a, b) => (b.ts || 0) - (a.ts || 0));
+    const hasReplies = sortedDesc.some(p => getThreadIdFromPost(p) !== p.id) || sortedDesc.length > 1;
+
+    if (!hasReplies || sortedDesc.length === 1) {
+      out.push(sortedDesc[0]);
+      continue;
+    }
+
+    const latest = sortedDesc[0];
+    const rootAction = byId.get(threadId);
+    const replies = sortedDesc
+      .filter(p => p.id !== threadId)
+      .slice()
+      .sort((a, b) => (a.ts || 0) - (b.ts || 0));
+
+    out.push({
+      id: `thread:${threadId}`,
+      type: 'postThread',
+      author: latest.author,
+      ts: latest.ts,
+      content: {
+        threadId,
+        root: rootAction
+          ? {
+              id: rootAction.id,
+              author: rootAction.author,
+              text: excerptPostText(rootAction.value?.content || rootAction.content || {}, 240)
+            }
+          : null,
+        replies: replies.map(p => ({
+          id: p.id,
+          author: p.author,
+          ts: p.ts,
+          text: excerptPostText(p.value?.content || p.content || {}, 200)
+        }))
+      }
+    });
+  }
+
+  out.sort((a, b) => (b.ts || 0) - (a.ts || 0));
+  return out;
+}
+
 function renderActionCards(actions, userId) {
   const validActions = actions
     .filter(action => {
@@ -70,15 +171,14 @@ function renderActionCards(actions, userId) {
   }
 
   const seenDocumentTitles = new Set();
-
-  const cards = deduped.map(action => {
+  const items = buildActivityItemsWithPostThreads(deduped, actions);
+  const cards = items.map(action => {
     const date = action.ts ? new Date(action.ts).toLocaleString() : "";
     const userLink = action.author
       ? a({ href: `/author/${encodeURIComponent(action.author)}` }, action.author)
       : 'unknown';
     const type = action.type || 'unknown';
     let skip = false;
-
     let headerText;
     if (type.startsWith('parliament')) {
       const sub = type.replace('parliament', '');
@@ -443,6 +543,14 @@ function renderActionCards(actions, userId) {
       } else {
         bodyNode = p({ class: 'post-text' }, ...renderUrl(rawText));
       }
+      const byId = new Map(actions.map(a => [a.id, a]));
+      const threadId = getThreadIdFromPost(action);
+      const replyToId = getReplyToIdFromPost(action, byId);
+      if (threadId && threadId !== action.id) {
+        const ctxHref = `/thread/${encodeURIComponent(threadId)}#${encodeURIComponent(replyToId || threadId)}`;
+        const parent = byId.get(replyToId) || byId.get(threadId);
+        const parentAuthor = parent?.author;
+      }
       cardBody.push(
         div({ class: 'card-section post' },
           contentWarning ? h2({ class: 'content-warning' }, contentWarning) : '',
@@ -451,6 +559,62 @@ function renderActionCards(actions, userId) {
       );
     }
 
+    if (type === 'postThread') {
+        const c = action.content || {};
+        const threadId = c.threadId;
+        const href = `/thread/${encodeURIComponent(threadId)}#${encodeURIComponent(threadId)}`;
+        const root = c.root;
+        const replies = Array.isArray(c.replies) ? c.replies : [];
+        const repliesAsc = replies.slice().sort((a, b) => (a.ts || 0) - (b.ts || 0));
+        const limit = 5; // max posts when threading
+        const overflow = repliesAsc.length > limit;
+        const show = repliesAsc.slice(Math.max(0, repliesAsc.length - limit));
+        const lastId = repliesAsc.length ? repliesAsc[repliesAsc.length - 1].id : threadId;
+        const viewMoreHref = `/thread/${encodeURIComponent(threadId)}#${encodeURIComponent(lastId)}`;
+        return div({ class: 'card card-rpg post-thread' },
+            div({ class: 'card-header' },
+                h2({ class: 'card-label' }, `[${String(i18n.typePost || 'POST').toUpperCase()} · THREAD]`),
+                form({ method: 'GET', action: href },
+                    button({ type: 'submit', class: 'filter-btn' }, i18n.viewDetails)
+                )
+            ),
+            div({ class: 'card-body' },
+                root && root.text
+                    ? div({ class: 'card-section' },
+                        p({ class: 'post-text' }, ...renderUrl(root.text))
+                    )
+                    : '',
+                div({ class: 'card-section' },
+		show.map(r => {
+		    const commentHref = `/thread/${encodeURIComponent(threadId)}#${encodeURIComponent(r.id)}`;
+		    const rDate = r.ts ? new Date(r.ts).toLocaleString() : '';
+		    return div({ class: 'thread-reply-item' },
+			div({ class: 'thread-reply' },
+			    r.text ? p({ class: 'post-text' }, ...renderUrl(r.text)) : ''
+			),
+			div({ class: 'card-footer thread-reply-footer' },
+			    span({ class: 'date-link' }, rDate),
+			    a({ href: `/author/${encodeURIComponent(r.author)}`, class: 'user-link' }, `${r.author}`),
+			    form({ method: 'GET', action: commentHref, class: 'inline-form' },
+				button({ type: 'submit', class: 'filter-btn' }, i18n.viewDetails)
+			    )
+			)
+		    );
+		}),
+                overflow
+                    ? div({ style: 'display:flex; justify-content:center; margin-top:12px;' },
+                        a({ class: 'filter-btn', href: viewMoreHref }, i18n.continueReading)
+                    )
+                    : ''
+            )
+        ),
+        p({ class: 'card-footer' },
+            span({ class: 'date-link' }, `${action.ts ? new Date(action.ts).toLocaleString() : ''} ${i18n.performed} `),
+            a({ href: `/author/${encodeURIComponent(action.author)}`, class: 'user-link' }, `${action.author}`)
+        )
+        );
+    }
+
     if (type === 'forum') {
       const { root, category, title, text, key, rootTitle, rootKey } = content;
       if (!root) {
@@ -512,7 +676,7 @@ function renderActionCards(actions, userId) {
       cardBody.push(
         div({ class: 'card-section contact' },
           p({ class: 'card-field' },
-            a({ href: `/author/${encodeURIComponent(contact)}`, class: 'activitySpreadInhabitant2' }, contact)
+            a({ href: `/author/${encodeURIComponent(contact)}`}, contact)
           )
         )
       );
@@ -524,7 +688,7 @@ function renderActionCards(actions, userId) {
       cardBody.push(
         div({ class: 'card-section pub' },
           p({ class: 'card-field' },
-            a({ href: `/author/${encodeURIComponent(key || '')}`, class: 'activitySpreadInhabitant2' }, key || '')
+            a({ href: `/author/${encodeURIComponent(key || '')}` }, key || '')
           )
         )
       );