Browse Source

Oasis release 0.4.5

psy 5 hours ago
parent
commit
039f388a53

+ 3 - 3
README.md

@@ -60,7 +60,7 @@ Oasis is TRULY MODULAR. Here's a list of what comes deployed with the "core".
  + Agenda: Module to manage all your assigned items.
  + AI: Module to talk with a LLM called '42'.
  + Audios: Module to discover and manage audios.
- + Banking: Module to distribute a fair Universal Basic Income (UBI) using commons-treasury.
+ + Banking: Module to determine the real value of ECOIN and distribute a UBI using the common treasury.
  + BlockExplorer: Module to navigate the blockchain.
  + Bookmarks: Module to discover and manage bookmarks.	
  + Cipher: Module to encrypt and decrypt your text symmetrically (using a shared password).	
@@ -84,13 +84,13 @@ Oasis is TRULY MODULAR. Here's a list of what comes deployed with the "core".
  + Summaries: Module to receive summaries of long discussions or posts.	
  + Tags: Module to discover and explore taxonomy patterns (tags).	
  + Tasks: Module to discover and manage tasks.	
- + Threads: Module to receive conversations grouped by topic or question.	
+ + Threads: Module to receive conversations grouped by topic or question.
+ + Topics: Module to receive discussion categories based on shared interests.	
  + Transfers: Module to discover and manage smart-contracts (transfers).	
  + Trending: Module to explore the most popular content.	
  + Tribes: Module to explore or create tribes (groups).	
  + Videos: Module to discover and manage videos.	
  + Wallet: Module to manage your digital assets (ECOin).	
- + Topics: Module to receive discussion categories based on shared interests.
 
 Both the codebase and the inhabitants can generate new modules.
 

+ 21 - 0
docs/CHANGELOG.md

@@ -13,6 +13,27 @@ All notable changes to this project will be documented in this file.
 ### Security
 -->
 
+
+## v0.4.5 - 2025-08-21
+
+### Added
+
+ + Exchange (ECOin current value) for all inhabitants (banking plugin).
+ + Karma SCORE.
+ + Upload a set of images/collections (images plugin).
+ 
+### Fixed
+
+ + Add a new bounty (projects plugin).
+ + Activity duplications.
+ 
+### Changed
+
+- Activity.
+- Avatar.
+- Inhabitants.
+- Stats.
+
 ## v0.4.4 - 2025-08-17
 
 ### Added

+ 29 - 0
docs/PUB/invite-codes.md

@@ -0,0 +1,29 @@
+# Oasis PUBs Invite Codes
+
+Below is a list of **community-generated PUB invitations** you can use to join and find more people. 
+
+---
+
+Invitations are limited, and PUBs are self-hosted servers, so they may no longer be available:
+
+- PUB:"La Plaza" (solarnethub.com):
+
+"solarnethub.com:8008:@HzmUrdZb1vRWCwn3giLx3p/EWKuDiO44gXAaeulz3d4=.ed25519~+WFcDtA3E8pKgRd/FzIstZkwo4K1GEVkeJWKCPhmK5I="
+
+- PUB:"Oasis-Project" (oasis-project.pub):
+
+"oasis-project.pub:8008:@JE5RwEExtXpmKAeJuPl6/OQjj53O+hCFU0KdyzeNJIE=.ed25519~xRWxsSftlXN3gktk5oGS/3IbhICkh0IXSJ0B+7jAGlA="
+
+- PUB:"The Pirate Oasis" (thepirateoasis.com):
+
+"pub.thepirateoasis.com:8008:@70JEzyx6wJGaihhiGzQCANBboV1h0OQHtaQ1ST7FDac=.ed25519~nHSrNVc+ACyNBIkaXTerW/SwE+6h2ug6H7h3+ETTpkI="
+
+- PUB:"Artivismo" (artivismo.net):
+
+"pub.artivismo.net:7723:@XPcWSwd8555YSXQiIR04RY8rOVGTROhy8fpqLmn8rjo=.ed25519~pMO6O+AGSF/u0U8HWOxRV25haDV9psMoqw52+LOK1GA="
+
+---
+
+Contribute by **deploying your own PUB** and adding it to this list. 
+
+The more nodes we have, the more resilient and uncensorable our content will be.

+ 28 - 11
src/backend/backend.js

@@ -694,6 +694,7 @@ router
     const relationship = await friend.getRelationship(feedId);
     const avatarUrl = getAvatarUrl(image);
     const ecoAddress = await bankingModel.getUserAddress(feedId);
+    const { ecoValue, karmaScore } = await bankingModel.getBankingData(feedId);
     ctx.body = authorView({
       feedId,
       messages,
@@ -703,7 +704,8 @@ router
       description,
       avatarUrl,
       relationship,
-      ecoAddress
+      ecoAddress,
+      karmaScore
     });
   })
   .get("/search", async (ctx) => {
@@ -882,7 +884,10 @@ router
       filter,
       ...query
     });
-    ctx.body = await inhabitantsView(inhabitants, filter, query, userId);
+    const addresses = await bankingModel.listAddressesMerged();
+    const addrMap = new Map(addresses.map(x => [x.id, x.address]));
+    const inhabitantsWithAddr = inhabitants.map(u => ({ ...u, ecoAddress: addrMap.get(u.id) || null }));
+    ctx.body = await inhabitantsView(inhabitantsWithAddr, filter, query, userId);
   })
   .get('/inhabitant/:id', async (ctx) => {
     const id = ctx.params.id;
@@ -893,7 +898,7 @@ router
     ctx.body = await inhabitantsProfileView({ about, cv, feed }, currentUserId);
   })
   .get('/tribes', async ctx => {
-    const filter = ctx.query.filter || 'all';
+    const filter = ctx.query.filter || 'recent';
     const search = ctx.query.search || ''; 
     const tribes = await tribesModel.listAll();
     let filteredTribes = tribes;
@@ -950,6 +955,8 @@ router
     const lastPost = await post.latestBy(myFeedId)
     const avatarUrl = getAvatarUrl(image)
     const ecoAddress = await bankingModel.getUserAddress(myFeedId)
+    const { karmaScore, ecoValue } = await bankingModel.getBankingData(myFeedId);
+    
     ctx.body = await authorView({
       feedId: myFeedId,
       messages,
@@ -959,7 +966,8 @@ router
       description,
       avatarUrl,
       relationship: { me: true },
-      ecoAddress
+      ecoAddress,
+      karmaScore
     })
   })
   .get("/profile/edit", async (ctx) => {
@@ -1364,7 +1372,7 @@ router
     const project = await projectsModel.getProjectById(projectId)
     ctx.body = await singleProjectView(project, filter)
   })
-  .get('/banking', async (ctx) => {
+  .get("/banking", async (ctx) => {
     const bankingMod = ctx.cookies.get("bankingMod") || 'on';
     if (bankingMod !== 'on') { 
       ctx.redirect('/modules'); 
@@ -1385,6 +1393,15 @@ router
       data.search = q;
     }
     data.flash = msg || '';
+    const { ecoValue, inflationFactor, ecoInHours, currentSupply, isSynced } = await bankingModel.calculateEcoinValue();
+    data.exchange = {
+      ecoValue: ecoValue,
+      inflationFactor,
+      ecoInHours,
+      currentSupply: currentSupply,
+      totalSupply: 25500000,
+      isSynced: isSynced
+    };
     ctx.body = renderBankingView(data, filter, userId);
   })
   .get("/banking/allocation/:id", async (ctx) => {
@@ -2609,7 +2626,7 @@ router
     await pmModel.sendMessage([job.author], subject, text);
     ctx.redirect('/jobs');
   })
-  .post('/projects/create', koaBody({ multipart: true }), async (ctx) => {
+ .post('/projects/create', koaBody({ multipart: true }), async (ctx) => {
     const b = ctx.request.body || {};
     const imageBlob = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null;
     const bounties =
@@ -2783,10 +2800,10 @@ router
   .post('/projects/bounties/add/:id', koaBody(), async (ctx) => {
     const { title, amount, description, milestoneIndex } = ctx.request.body;
     await projectsModel.addBounty(ctx.params.id, {
-       title,
-       amount,
-       description,
-       milestoneIndex: (milestoneIndex === '' || milestoneIndex === undefined) ? null : parseInt(milestoneIndex, 10)
+        title,
+        amount,
+        description,
+        milestoneIndex: (milestoneIndex === '' || milestoneIndex === undefined) ? null : parseInt(milestoneIndex, 10)
     });
     ctx.redirect(`/projects/${encodeURIComponent(ctx.params.id)}`);
   })
@@ -2808,7 +2825,7 @@ router
   .post('/projects/bounties/claim/:id/:index', koaBody(), async (ctx) => {
     const userId = SSBconfig.config.keys.id;
     await projectsModel.claimBounty(ctx.params.id, parseInt(ctx.params.index, 10), userId);
-    ctx.redirect(`/projects/${encodeURIComponent(ctx.params.id)}`);
+    ctx.redirect(`/projects/${encodeURIComponent(ctx.params.id)}`); 
   })
   .post('/projects/bounties/complete/:id/:index', koaBody(), async (ctx) => {
     const userId = SSBconfig.config.keys.id;

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

@@ -2250,3 +2250,14 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 .addr-form .form-actions .mini-btn:hover {
   background: #ff7f00;
 }
+
+.bank-summary .bank-info-table tbody tr .synced {
+  color: green;
+  font-weight: bold;
+}
+
+.bank-summary .bank-info-table tbody tr .outdated {
+  color: red;
+  font-weight: bold;
+}
+

+ 20 - 8
src/client/assets/translations/oasis_en.js

@@ -594,6 +594,7 @@ module.exports = {
     viewAvatar:        "View Avatar",
     viewCV:            "View CV",
     suggestedSectionTitle: "Suggested",
+    topkarmaSectionTitle: "Top Karma",
     blockedSectionTitle: "Blocked",
     gallerySectionTitle: "GALLERY",
     blockedButton:       "BLOCKED",
@@ -935,7 +936,7 @@ module.exports = {
     forumSendButton: "Send",
     forumVisitForum: "Visit Forum",
     noForums: "No forums found.",
-    // images
+    //images
     imageTitle: "Images",
     imagePluginTitle: "Title",
     imagePluginDescription: "Description",
@@ -1073,6 +1074,7 @@ module.exports = {
     personName:           "Inhabitant Name",
     typeBankWallet:       "BANKING/WALLET",
     typeBankClaim:        "BANKING/UBI",
+    typeKarmaScore:	  "KARMA",
     bankWalletConnected:  "ECOin Wallet",
     bankUbiReceived:      "UBI Received",
     bankTx:               "Tx",
@@ -1347,7 +1349,7 @@ module.exports = {
     //banking
     banking: 'Banking',
     bankingTitle: 'Banking',
-    bankingDescription: 'Universal Basic Income for Oasis inhabitants, distributed per epoch based on participation and trust.',
+    bankingDescription: 'Explore the current value of ECOin and the corresponding UBI allocation, distributed per epoch based on participation and trust.',
     bankOverview: 'Overview',
     bankEpochs: 'Epochs',
     bankRules: 'Rules',
@@ -1401,13 +1403,23 @@ module.exports = {
     search: 'Search!',
     bankLocal: 'Local',
     bankFromOasis: 'Oasis',
-    bankCopy: 'Copy',
-    bankCopied: 'Copied',
     bankMyAddress: 'Your address',
     bankRemoveMyAddress: 'Remove my address',
     bankNotRemovableOasis: 'Addresses cannot be removed locally',
-    bankingUserEngagementScore: "Engagement Score",
+    bankingUserEngagementScore: "KARMA Score",
     bankingFutureUBI: "Estimated UBI Allocation",
+    bankExchange: 'Exchange',
+    bankExchangeCurrentValue: 'ECOin Value (1h)',
+    bankTotalSupply: 'ECOin Total Supply',
+    bankEcoinHours: "ECOin Equivalence in Time",
+    bankHoursOfWork: 'hours',
+    bankExchangeNoData: 'No data available',
+    bankExchangeIndex: 'ECOin Value (1h)',
+    bankInflation: 'ECOin Inflation',
+    bankCurrentSupply: 'ECOin Current Supply',
+    bankingSyncStatus: 'ECOin Status',
+    bankingSyncStatusSynced: 'Synced',
+    bankingSyncStatusOutdated: 'Outdated',
     //stats
     statsTitle: 'Statistics',
     statistics: "Statistics",
@@ -1607,7 +1619,7 @@ module.exports = {
     jobsEmployeeTitle:    "Employee Jobs",
     jobTitle: "Title",
     jobLocation: "Location",
-    jobSalary: "Salary (1h)",
+    jobSalary: "Salary (ECO/1h)",
     jobVacants: "Vacants",
     jobDescription: "Description",
     jobRequirements: "Requirements",
@@ -1627,7 +1639,7 @@ module.exports = {
     jobLocationPresencial: "On-place",
     jobLocationRemote: "Remote",
     jobVacantsPlaceholder: "Number of positions",
-    jobSalaryPlaceholder: "Salary amount (1h)",
+    jobSalaryPlaceholder: "Salary in ECO for 1 dedicated hour",
     jobImage: "Upload Image (jpeg, jpg, png, gif) (max-size: 500px x 400px)",
     jobTasks: "Tasks",
     jobType: "Job Type",
@@ -1823,7 +1835,7 @@ module.exports = {
     modulesProjectsLabel: "Projects",
     modulesProjectsDescription: "Module to explore, crowd-funding and manage projects.",
     modulesBankingLabel: "Banking",
-    modulesBankingDescription: "Module to distribute a fair Universal Basic Income (UBI) using commons-treasury."
+    modulesBankingDescription: "Module to determine the real value of ECOIN and distribute a UBI using the common treasury."
      
      //END
     }

+ 19 - 7
src/client/assets/translations/oasis_es.js

@@ -593,6 +593,7 @@ module.exports = {
     viewAvatar:        "Ver Avatar",
     viewCV:            "Ver CV",
     suggestedSectionTitle: "Sugeridos",
+    topkarmaSectionTitle: "Top Karma",
     blockedSectionTitle: "Bloqueados",
     gallerySectionTitle: "GALERÍA",
     blockedButton:       "BLOQUEADOS",
@@ -1071,6 +1072,7 @@ module.exports = {
     personName:           "Nombre del habitante",
     typeBankWallet:       "BANCA/CARTERA",
     typeBankClaim:        "BANCA/UBI",
+    typeKarmaScore:	  "KARMA",
     bankWalletConnected:  "Cartera ECOin",
     bankUbiReceived:      "UBI recibida",
     bankTx:               "Tx",
@@ -1346,7 +1348,7 @@ module.exports = {
     // banking
     banking: 'Banking',
     bankingTitle: 'Banking',
-    bankingDescription: 'Renta Básica Universal para habitantes de Oasis, distribuida por épocas según participación y confianza.',
+    bankingDescription: 'Explora el valor actual de ECOin y la asignación de RBU correspondiente, distribuida semanalmente en función de la participación y la confianza.',
     bankOverview: 'Resumen',
     bankEpochs: 'Épocas',
     bankRules: 'Reglas',
@@ -1400,13 +1402,23 @@ module.exports = {
     search: 'Buscar!',
     bankLocal: 'Local',
     bankFromOasis: 'Oasis',
-    bankCopy: 'Copiar',
-    bankCopied: 'Copiado',
     bankMyAddress: 'Tu dirección',
     bankRemoveMyAddress: 'Eliminar mi dirección',
     bankNotRemovableOasis: 'Las direcciones no se pueden eliminar localmente',
-    bankingUserEngagementScore: "Puntuación de Compromiso",
+    bankingUserEngagementScore: "Puntuación de KARMA",
     bankingFutureUBI: "Asignación Estimada de UBI",
+    bankExchange: 'Intercambio',
+    bankExchangeCurrentValue: 'Valor de ECOin (1h)',
+    bankTotalSupply: 'Suministro Total de ECOin',
+    bankEcoinHours: 'Equivalencia de ECOin en Tiempo',
+    bankHoursOfWork: 'horas',
+    bankExchangeNoData: 'No hay datos disponibles',
+    bankExchangeIndex: 'Valor de ECOin (1h)',
+    bankInflation: 'Inflación de ECOin',
+    bankCurrentSupply: 'Suministro Actual de ECOin',
+    bankingSyncStatus: 'Estado de ECOin',
+    bankingSyncStatusSynced: 'Sincronizado',
+    bankingSyncStatusOutdated: 'Desactualizado',
     //stats
     statsTitle: 'Estadísticas',
     statistics: "Estadísticas",
@@ -1619,7 +1631,7 @@ module.exports = {
     jobsEmployeeTitle:    "Trabajos de Empleado",
     jobTitle: "Título",
     jobLocation: "Ubicación",
-    jobSalary: "Salario (1h)",
+    jobSalary: "Salario (ECO/1h)",
     jobVacants: "Vacantes",
     jobDescription: "Descripción",
     jobRequirements: "Requisitos",
@@ -1639,7 +1651,7 @@ module.exports = {
     jobLocationPresencial: "Presencial",
     jobLocationRemote: "Remoto",
     jobVacantsPlaceholder: "Número de vacantes",
-    jobSalaryPlaceholder: "Salario (1h)",
+    jobSalaryPlaceholder: "Salario en ECO por 1 hora dedicada",
     jobImage: "Subir Imagen (jpeg, jpg, png, gif) (tamaño máximo: 500px x 400px)",
     jobTasks: "Tareas",
     jobType: "Tipo de Trabajo",
@@ -1835,7 +1847,7 @@ module.exports = {
     modulesProjectsLabel: "Proyectos",
     modulesProjectsDescription: "Módulo para explorar, financiar y gestionar proyectos.",
     modulesBankingLabel: "Banca",
-    modulesBankingDescription: "Módulo para distribuir una Renta Básica Universal (RBU) justa usando la tesorería común.",
+    modulesBankingDescription: "Módulo para conocer el valor real de ECOIN y distribuir una RBU utilizando la tesorería común.",
      
      //END
     }

+ 20 - 8
src/client/assets/translations/oasis_eu.js

@@ -594,6 +594,7 @@ module.exports = {
     viewAvatar:        "Ikusi Abatarra",
     viewCV:            "Ikusi CV-a",
     suggestedSectionTitle: "Gomendatuta",
+    topkarmaSectionTitle: "Top Karma",
     blockedSectionTitle: "Blokeatuta",
     gallerySectionTitle: "GALERIA",
     blockedButton:       "BLOKETATUTA",
@@ -1072,6 +1073,7 @@ module.exports = {
     personName:           "Biztanlearen izena",
     typeBankWallet:       "BANKUA/ZORROA",
     typeBankClaim:        "BANKUA/UBI",
+    typeKarmaScore:	  "KARMA",
     bankWalletConnected:  "ECOin Zorroa",
     bankUbiReceived:      "UBI jasota",
     bankTx:               "Tx",
@@ -1347,7 +1349,7 @@ module.exports = {
     // banking
     banking: 'Banking',
     bankingTitle: 'Banking',
-    bankingDescription: 'Oasis-eko biztanleentzako Oinarrizko Errenta Unibertsala, partaidetzan eta konfiantzan oinarrituta, epeka banatua.',
+    bankingDescription: 'Aztertu ECOin-en egungo balioa eta dagokion RBU esleipena, astero banatzen dena parte-hartzearen eta konfiantzaren arabera.',
     bankOverview: 'Laburpena',
     bankEpochs: 'Epeak',
     bankRules: 'Arauak',
@@ -1401,13 +1403,23 @@ module.exports = {
     search: 'Bilatu!',
     bankLocal: 'Lokala',
     bankFromOasis: 'Oasis',
-    bankCopy: 'Kopiatu',
-    bankCopied: 'Kopiatuta',
     bankMyAddress: 'Zure helbidea',
     bankRemoveMyAddress: 'Nire helbidea kendu',
     bankNotRemovableOasis: 'Oasis helbideak ezin dira lokalki kendu',
-    bankingUserEngagementScore: "Konpromiso Puntuazioa",
-    bankingFutureUBI: "UBIren Estimatutako Esleipena",
+    bankingUserEngagementScore: "KARMA Puntuazioa",
+    bankingFutureUBI: "RBUren Estimatutako Esleipena",
+    bankExchange: 'Trukea',
+    bankExchangeCurrentValue: 'ECOin Balioa (1h)',
+    bankTotalSupply: 'ECOin Hornidura Guztizkoa',
+    bankEcoinHours: 'ECOin Oreka Denboran',
+    bankHoursOfWork: 'lan orduak',
+    bankExchangeNoData: 'Ez dago daturik eskuragarri',
+    bankExchangeIndex: 'ECOin Balioa (1h)',
+    bankInflation: 'ECOin Inflazioa',
+    bankCurrentSupply: 'ECOin Oraingo Hornidura',
+    bankingSyncStatus: 'ECOin Egoera',
+    bankingSyncStatusSynced: 'Sinkronizatuta',
+    bankingSyncStatusOutdated: 'Desegonetik',
     //stats
     statsTitle: 'Estatistikak',
     statistics: "Estatistikak",
@@ -1620,7 +1632,7 @@ module.exports = {
     jobsEmployeeTitle:    "Langile Lanak",
     jobTitle: "Izenburua",
     jobLocation: "Kokalekua",
-    jobSalary: "Soldata (1h)",
+    jobSalary: "Soldata (ECO/1h)",
     jobVacants: "Postu Libreak",
     jobDescription: "Deskribapena",
     jobRequirements: "Eskakizunak",
@@ -1640,7 +1652,7 @@ module.exports = {
     jobLocationPresencial: "Aurrez aurrekoa",
     jobLocationRemote: "Urrunekoa",
     jobVacantsPlaceholder: "Postu kopurua",
-    jobSalaryPlaceholder: "Soldata kopurua",
+    jobSalaryPlaceholder: "Ordubeteko soldata ECO",
     jobImage: "Igo Irudia (jpeg, jpg, png, gif) (gehienez: 500px x 400px)",
     jobTasks: "Eginkizunak",
     jobType: "Lanen Motak",
@@ -1834,7 +1846,7 @@ module.exports = {
     modulesProjectsLabel: "Proiektuak",
     modulesProjectsDescription: "Proiektuak esploratzeko, finantzatzeko eta kudeatzeko modulu.",
     modulesBankingLabel: "Bankua",
-    modulesBankingDescription: "Modulua Oinarrizko Errenta Unibertsala (OEU) bidez banatzeko, komunen diruzaintza erabiliz.",
+    modulesBankingDescription: "ECOINen benetako balioa zehazteko eta UBI bat altxortegi komuna erabiliz banatzeko modulua.",
 
      //END
   }

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

@@ -39,8 +39,8 @@
   },
   "wallet": {
     "url": "http://localhost:7474",
-    "user": "ecoinrpc",
-    "pass": "DLKKWE93203909238dkkKKeowxmIOw0232lsakwL02kUfoEcoinUfonet",
+    "user": "",
+    "pass": "",
     "fee": "1"
   },
   "walletPub": {

+ 63 - 42
src/models/activity_model.js

@@ -7,22 +7,26 @@ const ORDER_MARKET = ['FOR_SALE','OPEN','RESERVED','CLOSED','SOLD'];
 const SCORE_MARKET = s => {
   const i = ORDER_MARKET.indexOf(N(s));
   return i < 0 ? -1 : i;
-}
-
+};
 const ORDER_PROJECT = ['CANCELLED','PAUSED','ACTIVE','COMPLETED'];
 const SCORE_PROJECT = s => {
   const i = ORDER_PROJECT.indexOf(N(s));
   return i < 0 ? -1 : i;
 };
 
+function inferType(c = {}) {
+  if (c.type === 'wallet' && c.coin === 'ECO' && typeof c.address === 'string') return 'bankWallet';
+  if (c.type === 'bankClaim') return 'bankClaim';
+  if (c.type === 'karmaScore') return 'karmaScore';
+  return c.type || '';
+}
+
 module.exports = ({ cooler }) => {
   let ssb;
-
   const openSsb = async () => {
     if (!ssb) ssb = await cooler.open();
     return ssb;
   };
-
   const hasBlob = async (ssbClient, url) => {
     return new Promise((resolve) => {
       ssbClient.blobs.has(url, (err, has) => {
@@ -30,12 +34,10 @@ module.exports = ({ cooler }) => {
       });
     });
   };
-
   return {
     async listFeed(filter = 'all') {
       const ssbClient = await openSsb();
       const userId = ssbClient.id;
-
       const results = await new Promise((resolve, reject) => {
         pull(
           ssbClient.createLogStream({ reverse: true, limit: logLimit }),
@@ -60,7 +62,7 @@ module.exports = ({ cooler }) => {
           id: k,
           author: v?.author,
           ts: v?.timestamp || 0,
-          type: c.type,
+          type: inferType(c),
           content: c
         });
         if (c.replaces) parentOf.set(k, c.replaces);
@@ -84,7 +86,6 @@ module.exports = ({ cooler }) => {
       for (const [root, arr] of groups.entries()) {
         if (!arr.length) continue;
         const type = arr[0].type;
-
         let tip;
         if (type === 'market') {
           tip = arr[0];
@@ -107,50 +108,70 @@ module.exports = ({ cooler }) => {
         } else {
           tip = arr.reduce((best, a) => (a.ts > best.ts ? a : best), arr[0]);
         }
-
         if (tombstoned.has(tip.id)) {
           const nonTomb = arr.filter(a => !tombstoned.has(a.id));
           if (!nonTomb.length) continue;
           tip = nonTomb.reduce((best, a) => (a.ts > best.ts ? a : best), nonTomb[0]);
         }
-
         for (const a of arr) idToTipId.set(a.id, tip.id);
       }
 
-    const latest = [];
-    for (const a of idToAction.values()) {
-      if (tombstoned.has(a.id)) continue;
-      const c = a.content || {};
-      if (c.root && tombstoned.has(c.root)) continue;
-      if (a.type === 'vote' && tombstoned.has(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;
+      const latest = [];
+      for (const a of idToAction.values()) {
+        if (tombstoned.has(a.id)) continue;
+        const c = a.content || {};
+        if (c.root && tombstoned.has(c.root)) continue;
+        if (a.type === 'vote' && tombstoned.has(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;
+        if (a.type === 'document') {
+          const url = c.url;
+          const ok = await hasBlob(ssbClient, url);
+          if (!ok) continue;
+        }
+        latest.push({ ...a, tipId: idToTipId.get(a.id) || a.id });
+      }
 
-      if (a.type === 'document') {
-        const url = c.url;
-        const ok = await hasBlob(ssbClient, url);
-        if (!ok) continue;
+      let deduped = latest.filter(a => !a.tipId || a.tipId === a.id);
+
+      const mediaTypes = new Set(['image','video','audio','document','bookmark']);
+      const perAuthorUnique = new Set(['karmaScore']);
+      const byKey = new Map();
+      for (const a of deduped) {
+        if (mediaTypes.has(a.type)) {
+          const u = a.content?.url || a.content?.title || `${a.type}:${a.id}`;
+          const key = `${a.type}:${u}`;
+          const prev = byKey.get(key);
+          if (!prev || a.ts > prev.ts) byKey.set(key, a);
+        } else if (perAuthorUnique.has(a.type)) {
+          const key = `${a.type}:${a.author}`;
+          const prev = byKey.get(key);
+          if (!prev || a.ts > prev.ts) byKey.set(key, a);
+        } else {
+          const key = `id:${a.id}`;
+          byKey.set(key, a);
+        }
       }
-      latest.push({ ...a, tipId: idToTipId.get(a.id) || a.id });
-    }
-    
-    let out;
-    if (filter === 'mine') {
-      out = latest.filter(a => a.author === userId);
-    } else if (filter === 'recent') {
-      const cutoff = Date.now() - 24 * 60 * 60 * 1000;
-      out = latest.filter(a => (a.ts || 0) >= cutoff);
-    } else if (filter === 'all') {
-      out = latest;
-    } else if (filter === 'banking') {
-      out = latest.filter(a => a.type === 'bankWallet' || a.type === 'bankClaim');
-    } else {
-      out = latest.filter(a => a.type === filter);
-    }
-    out.sort((a, b) => (b.ts || 0) - (a.ts || 0));
-    return out;
+      deduped = Array.from(byKey.values());
+
+      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);
+      } else if (filter === 'all') {
+        out = deduped;
+      } 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 {
+        out = deduped.filter(a => a.type === filter);
+      }
+      out.sort((a, b) => (b.ts || 0) - (a.ts || 0));
+      return out;
     }
   };
 };
-

+ 234 - 59
src/models/banking_model.js

@@ -69,16 +69,37 @@ function writeJson(p, v) {
 
 async function rpcCall(method, params, kind = "user") {
   const cfg = getWalletCfg(kind);
-  if (!cfg?.url) throw new Error(`${kind.toUpperCase()} RPC not configured`);
-  const headers = { "content-type": "application/json" };
+  if (!cfg?.url) {
+    return null; 
+  }
+  const headers = {
+    "Content-Type": "application/json",
+  };
   if (cfg.user || cfg.pass) {
     headers.authorization = "Basic " + Buffer.from(`${cfg.user}:${cfg.pass}`).toString("base64");
   }
-  const res = await fetch(cfg.url, { method: "POST", headers, body: JSON.stringify({ jsonrpc: "1.0", id: "oasis", method, params }) });
-  if (!res.ok) throw new Error(`RPC ${method} failed`);
-  const data = await res.json();
-  if (data.error) throw new Error(data.error.message);
-  return data.result;
+  try {
+    const res = await fetch(cfg.url, {
+      method: "POST",
+      headers: headers,
+      body: JSON.stringify({
+        jsonrpc: "1.0",
+        id: "oasis",
+        method: method,
+        params: params,
+      }),
+    });
+    if (!res.ok) {
+      return null;
+    }
+    const data = await res.json();
+    if (data.error) {
+      return null; 
+    }
+    return data.result; 
+  } catch (err) {
+    return null;
+  }
 }
 
 async function safeGetBalance(kind = "user") {
@@ -389,61 +410,139 @@ module.exports = ({ services } = {}) => {
     });
   }
 
-  async function fetchUserActions(userId) {
-    const me = resolveUserId(userId);
-    const actions = await listAllActions();
-    const authored = actions.filter(a =>
-      (a.author && a.author === me) || (a.value?.author && a.value.author === me)
-    );
-    if (authored.length) return authored;
-    return actions.filter(a => {
-      const c = a.content || {};
-      const fields = [c.author, c.organizer, c.seller, c.about, c.contact];
-      return fields.some(f => f && f === me);
+async function publishKarmaScore(userId, karmaScore) {
+  const ssb = await openSsb();
+  if (!ssb) return false;
+  const timestamp = new Date().toISOString();
+  const content = {
+    type: "karmaScore",
+    karmaScore: karmaScore,
+    userId: userId,
+    timestamp: timestamp,
+  };
+  return new Promise((resolve, reject) => {
+    ssb.publish(content, (err, msg) => {
+      if (err) reject(err);
+      else resolve(msg);
     });
-  }
+  });
+}
 
-  function scoreFromActions(actions) {
-    let score = 0;
-    for (const action of actions) {
-      const t = normalizeType(action);
-      const c = action.content || {};
-      if (t === "post") score += 10;
-      else if (t === "comment") score += 5;
-      else if (t === "like") score += 2;
-      else if (t === "image") score += 8;
-      else if (t === "video") score += 12;
-      else if (t === "audio") score += 8;
-      else if (t === "document") score += 6;
-      else if (t === "bookmark") score += 2;
-      else if (t === "feed") score += 6;
-      else if (t === "forum") score += c.root ? 5 : 10;
-      else if (t === "vote") score += 3 + calculateOpinionScore(c);
-      else if (t === "votes") score += Math.min(10, Number(c.totalVotes || 0));
-      else if (t === "market") score += scoreMarket(c);
-      else if (t === "project") score += scoreProject(c);
-      else if (t === "tribe") score += 6 + Math.min(10, Array.isArray(c.members) ? c.members.length * 0.5 : 0);
-      else if (t === "event") score += 4 + Math.min(10, Array.isArray(c.attendees) ? c.attendees.length : 0);
-      else if (t === "task") score += 3 + priorityBump(c.priority);
-      else if (t === "report") score += 4 + (Array.isArray(c.confirmations) ? c.confirmations.length : 0) + severityBump(c.severity);
-      else if (t === "curriculum") score += 5;
-      else if (t === "aiexchange") score += Array.isArray(c.ctx) ? Math.min(10, c.ctx.length) : 0;
-      else if (t === "job") score += 4 + (Array.isArray(c.subscribers) ? c.subscribers.length : 0);
-      else if (t === "bankclaim") score += Math.min(20, Math.log(1 + Math.max(0, Number(c.amount) || 0)) * 5);
-      else if (t === "bankwallet") score += 2;
-      else if (t === "transfer") score += 1;
-      else if (t === "about") score += 1;
-      else if (t === "contact") score += 1;
-      else if (t === "pub") score += 1;
-    }
-    return Math.max(0, Math.round(score));
-  }
+async function fetchUserActions(userId) {
+  const me = resolveUserId(userId);
+  const actions = await listAllActions();
+  const authored = actions.filter(a =>
+    (a.author && a.author === me) || (a.value?.author && a.value.author === me)
+  );
+  if (authored.length) return authored;
+  return actions.filter(a => {
+    const c = a.content || {};
+    const fields = [c.author, c.organizer, c.seller, c.about, c.contact];
+    return fields.some(f => f && f === me);
+  });
+}
 
-  async function getUserEngagementScore(userId) {
-    const actions = await fetchUserActions(userId);
-    return scoreFromActions(actions);
-  }
+function scoreFromActions(actions) {
+  let score = 0;
+  for (const action of actions) {
+    const t = normalizeType(action);
+    const c = action.content || {};
+    if (t === "post") score += 10;
+    else if (t === "comment") score += 5;
+    else if (t === "like") score += 2;
+    else if (t === "image") score += 8;
+    else if (t === "video") score += 12;
+    else if (t === "audio") score += 8;
+    else if (t === "document") score += 6;
+    else if (t === "bookmark") score += 2;
+    else if (t === "feed") score += 6;
+    else if (t === "forum") score += c.root ? 5 : 10;
+    else if (t === "vote") score += 3 + calculateOpinionScore(c);
+    else if (t === "votes") score += Math.min(10, Number(c.totalVotes || 0));
+    else if (t === "market") score += scoreMarket(c);
+    else if (t === "project") score += scoreProject(c);
+    else if (t === "tribe") score += 6 + Math.min(10, Array.isArray(c.members) ? c.members.length * 0.5 : 0);
+    else if (t === "event") score += 4 + Math.min(10, Array.isArray(c.attendees) ? c.attendees.length : 0);
+    else if (t === "task") score += 3 + priorityBump(c.priority);
+    else if (t === "report") score += 4 + (Array.isArray(c.confirmations) ? c.confirmations.length : 0) + severityBump(c.severity);
+    else if (t === "curriculum") score += 5;
+    else if (t === "aiexchange") score += Array.isArray(c.ctx) ? Math.min(10, c.ctx.length) : 0;
+    else if (t === "job") score += 4 + (Array.isArray(c.subscribers) ? c.subscribers.length : 0);
+    else if (t === "bankclaim") score += Math.min(20, Math.log(1 + Math.max(0, Number(c.amount) || 0)) * 5);
+    else if (t === "bankwallet") score += 2;
+    else if (t === "transfer") score += 1;
+    else if (t === "about") score += 1;
+    else if (t === "contact") score += 1;
+    else if (t === "pub") score += 1;
+  }
+  return Math.max(0, Math.round(score));
+}
 
+async function getUserEngagementScore(userId) {
+  const actions = await fetchUserActions(userId);
+  const karmaScore = scoreFromActions(actions);
+  const previousKarmaScore = await getLastKarmaScore(userId);
+  const lastPublishedTimestamp = await getLastPublishedTimestamp(userId);
+  const currentTimestamp = Date.now();
+  const timeDifference = currentTimestamp - new Date(lastPublishedTimestamp).getTime();
+  const shouldPublish = karmaScore !== previousKarmaScore && timeDifference >= 24 * 60 * 60 * 1000;
+  if (shouldPublish) {
+    await publishKarmaScore(userId, karmaScore);
+  }
+  return karmaScore;
+}
+
+async function getLastKarmaScore(userId) {
+  const ssb = await openSsb();
+  if (!ssb) return 0;
+  return new Promise(resolve => {
+    const source = ssb.messagesByType
+      ? ssb.messagesByType({ type: "karmaScore", reverse: true })
+      : ssb.createLogStream && ssb.createLogStream({ reverse: true });
+    if (!source) return resolve(0);
+    pull(
+      source,
+      pull.filter(msg => {
+        const v = msg.value || msg;
+        const c = v.content || {};
+        return v.author === userId && c.type === "karmaScore" && typeof c.karmaScore !== "undefined";
+      }),
+      pull.take(1),
+      pull.collect((err, arr) => {
+        if (err || !arr || !arr.length) return resolve(0);
+        const v = arr[0].value || arr[0];
+        resolve(v.content.karmaScore || 0);
+      })
+    );
+  });
+}
+
+async function getLastPublishedTimestamp(userId) {
+  const ssb = await openSsb();
+  if (!ssb) return new Date(0).toISOString();
+  return new Promise(resolve => {
+    const source = ssb.messagesByType
+      ? ssb.messagesByType({ type: "karmaScore", reverse: true })
+      : ssb.createLogStream && ssb.createLogStream({ reverse: true });
+    if (!source) return resolve(new Date(0).toISOString());
+    pull(
+      source,
+      pull.filter(msg => {
+        const v = msg.value || msg;
+        const c = v.content || {};
+        return v.author === userId && c.type === "karmaScore";
+      }),
+      pull.take(1),
+      pull.collect((err, arr) => {
+        if (err || !arr || !arr.length) return resolve(new Date(0).toISOString());
+        const v = arr[0].value || arr[0];
+        const c = v.content || {};
+        resolve(c.timestamp || new Date(0).toISOString());
+      })
+    );
+  });
+}
+ 
   function computePoolVars(pubBal, rules) {
     const alphaCap = (rules.alpha || DEFAULT_RULES.alpha) * pubBal;
     const available = Math.max(0, pubBal - (rules.reserveMin || DEFAULT_RULES.reserveMin));
@@ -572,6 +671,80 @@ module.exports = ({ services } = {}) => {
       id: t.id, concept: t.concept, from: t.from, to: t.to, amount: t.amount, status: t.status, createdAt: t.createdAt || new Date().toISOString(), txid: t.txid
     }));
   }
+  
+  async function calculateEcoinValue() {
+    let isSynced = false;
+    let circulatingSupply = 0;
+    try {
+      circulatingSupply = await getCirculatingSupply();
+      isSynced = circulatingSupply > 0;
+    } catch (error) {
+      circulatingSupply = 0;
+      isSynced = false;
+    }
+    const totalSupply = 25500000;
+    const ecoValuePerHour = await calculateEcoValuePerHour(circulatingSupply);
+    const ecoInHours = calculateEcoinHours(circulatingSupply, ecoValuePerHour);
+    const inflationFactor = await calculateInflationFactor(circulatingSupply, totalSupply);
+    return {
+      ecoValue: ecoValuePerHour,
+      ecoInHours: Number(ecoInHours.toFixed(2)),
+      totalSupply: totalSupply,
+      inflationFactor: inflationFactor ? Number(inflationFactor.toFixed(2)) : 0,
+      currentSupply: circulatingSupply,
+      isSynced: isSynced
+    };
+  }
+
+  async function calculateEcoValuePerHour(circulatingSupply) {
+    const issuanceRate = await getIssuanceRate();
+    const inflation = await calculateInflationFactor(circulatingSupply, 25500000);
+    const ecoValuePerHour = (circulatingSupply / 100000) * (1 + inflation / 100);
+    return ecoValuePerHour;
+  }
+
+  function calculateEcoinHours(circulatingSupply, ecoValuePerHour) {
+    const ecoInHours = circulatingSupply / ecoValuePerHour;
+    return ecoInHours;
+  }
+
+  async function calculateInflationFactor(circulatingSupply, totalSupply) {
+    const issuanceRate = await getIssuanceRate();
+    if (circulatingSupply > 0) {
+      const inflationRate = (issuanceRate / circulatingSupply) * 100;
+      return inflationRate;
+    }
+    return 0;
+  }
+
+  async function getIssuanceRate() {
+    try {
+      const result = await rpcCall("getmininginfo", []);
+      const blockValue = result?.blockvalue || 0;
+      const blocks = result?.blocks || 0;
+      return (blockValue / 1e8) * blocks;
+    } catch (error) {
+      return 0.02;
+    }
+  }
+
+  async function getCirculatingSupply() {
+    try {
+      const result = await rpcCall("getinfo", []);
+      return result?.moneysupply || 0;
+    } catch (error) {
+      return 0; 
+    }
+  }
+  
+  async function getBankingData(userId) {
+    const ecoValue = await calculateEcoinValue();
+    const karmaScore = await getUserEngagementScore(userId);
+    return {
+      ecoValue,
+      karmaScore,
+    };
+  }
 
   return {
     DEFAULT_RULES,
@@ -589,7 +762,9 @@ module.exports = ({ services } = {}) => {
     ensureSelfAddressPublished,
     getUserAddress,
     setUserAddress,
-    listAddressesMerged
+    listAddressesMerged,
+    calculateEcoinValue,
+    getBankingData
   };
 };
 

+ 54 - 26
src/models/inhabitants_model.js

@@ -13,6 +13,30 @@ module.exports = ({ cooler }) => {
   let ssb;
   const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
 
+  async function getLastKarmaScore(feedId) {
+    const ssbClient = await openSsb();
+    return new Promise(resolve => {
+      const src = ssbClient.messagesByType
+        ? ssbClient.messagesByType({ type: "karmaScore", reverse: true })
+        : ssbClient.createLogStream && ssbClient.createLogStream({ reverse: true });
+      if (!src) return resolve(0);
+      pull(
+        src,
+        pull.filter(msg => {
+          const v = msg.value || msg;
+          const c = v.content || {};
+          return v.author === feedId && c.type === "karmaScore" && typeof c.karmaScore !== "undefined";
+        }),
+        pull.take(1),
+        pull.collect((err, arr) => {
+          if (err || !arr || !arr.length) return resolve(0);
+          const v = arr[0].value || arr[0];
+          resolve(v.content.karmaScore || 0);
+        })
+      );
+    });
+  }
+
   return {
     async listInhabitants(options = {}) {
       const { filter = 'all', search = '', location = '', language = '', skills = '' } = options;
@@ -58,7 +82,7 @@ module.exports = ({ cooler }) => {
         );
         return users;
       }
-      if (filter === 'all') {
+      if (filter === 'all' || filter === 'TOP KARMA') {
         const feedIds = await new Promise((res, rej) => {
           pull(
             ssbClient.createLogStream({ limit: logLimit }),
@@ -75,9 +99,8 @@ module.exports = ({ cooler }) => {
             pull.collect((err, msgs) => err ? rej(err) : res(msgs))
           );
         });
-
         const uniqueFeedIds = Array.from(new Set(feedIds.map(r => r.value.author).filter(Boolean)));
-        const users = await Promise.all(
+        let users = await Promise.all(
           uniqueFeedIds.map(async (feedId) => {
             const name = await about.name(feedId);
             const description = await about.description(feedId);
@@ -86,21 +109,26 @@ module.exports = ({ cooler }) => {
               typeof image === 'string'
                 ? `/image/256/${encodeURIComponent(image)}`
                 : '/assets/images/default-avatar.png';
-
             return { id: feedId, name, description, photo };
           })
         );
-        const deduplicated = Array.from(new Map(users.filter(u => u && u.id).map(u => [u.id, u])).values());
-        let filtered = deduplicated;
+        users = Array.from(new Map(users.filter(u => u && u.id).map(u => [u.id, u])).values());
         if (search) {
           const q = search.toLowerCase();
-          filtered = filtered.filter(u =>
+          users = users.filter(u =>
             u.name?.toLowerCase().includes(q) ||
             u.description?.toLowerCase().includes(q) ||
             u.id?.toLowerCase().includes(q)
           );
         }
-        return filtered;
+        const withKarma = await Promise.all(users.map(async u => {
+          const karmaScore = await getLastKarmaScore(u.id);
+          return { ...u, karmaScore };
+        }));
+        if (filter === 'TOP KARMA') {
+          return withKarma.sort((a, b) => (b.karmaScore || 0) - (a.karmaScore || 0));
+        }
+        return withKarma;
       }
       if (filter === 'contacts') {
         const all = await this.listInhabitants({ filter: 'all' });
@@ -219,9 +247,9 @@ module.exports = ({ cooler }) => {
       };
     },
     
-      async getLatestAboutById(id) {
-        const ssbClient = await openSsb();
-        const records = await new Promise((res, rej) => {
+    async getLatestAboutById(id) {
+      const ssbClient = await openSsb();
+      const records = await new Promise((res, rej) => {
         pull(
           ssbClient.createUserStream({ id }),
           pull.filter(msg =>
@@ -240,21 +268,21 @@ module.exports = ({ cooler }) => {
       const ssbClient = await openSsb();
       const targetId = id || ssbClient.id;
       const records = await new Promise((res, rej) => {
-      pull(
-      ssbClient.createUserStream({ id: targetId }),
-      pull.filter(msg =>
-        msg.value &&
-        msg.value.content &&
-        typeof msg.value.content.text === 'string' &&
-        msg.value.content?.type !== 'tombstone'
-      ),
-      pull.collect((err, msgs) => err ? rej(err) : res(msgs))
-      );
-    });
-    return records
-    .filter(m => typeof m.value.content.text === 'string')
-    .sort((a, b) => b.value.timestamp - a.value.timestamp)
-    .slice(0, 10);
+        pull(
+          ssbClient.createUserStream({ id: targetId }),
+          pull.filter(msg =>
+            msg.value &&
+            msg.value.content &&
+            typeof msg.value.content.text === 'string' &&
+            msg.value.content?.type !== 'tombstone'
+          ),
+          pull.collect((err, msgs) => err ? rej(err) : res(msgs))
+        );
+      });
+      return records
+        .filter(m => typeof m.value.content.text === 'string')
+        .sort((a, b) => b.value.timestamp - a.value.timestamp)
+        .slice(0, 10);
     },
 
     async getCVByUserId(id) {

+ 19 - 27
src/models/projects_model.js

@@ -262,9 +262,9 @@ module.exports = ({ cooler }) => {
     },
 
     async addBounty(id, bounty) {
-      const tip = await this.getProjectTipId(id)
-      const project = await this.getProjectById(tip)
-      const bounties = Array.isArray(project.bounties) ? project.bounties.slice() : []
+      const tip = await this.getProjectTipId(id);
+      const project = await this.getProjectById(tip);
+      const bounties = Array.isArray(project.bounties) ? project.bounties.slice() : [];
       const clean = {
         title: String(bounty.title || '').trim(),
         amount: Math.max(0, parseFloat(bounty.amount || 0) || 0),
@@ -272,35 +272,27 @@ module.exports = ({ cooler }) => {
         claimedBy: null,
         done: false,
         milestoneIndex: safeMilestoneIndex(project, bounty.milestoneIndex)
-      }
-      bounties.push(clean)
-      return this.updateProject(tip, { bounties })
+      };
+      bounties.push(clean);
+      return this.updateProject(tip, { bounties });
     },
 
     async updateBounty(id, index, patch) {
-      const tip = await this.getProjectTipId(id)
-      const project = await this.getProjectById(tip)
-      const bounties = Array.isArray(project.bounties) ? project.bounties.slice() : []
-      if (!bounties[index]) throw new Error('Bounty not found')
-      if (patch.title !== undefined) bounties[index].title = String(patch.title).trim()
-      if (patch.amount !== undefined) bounties[index].amount = Math.max(0, parseFloat(patch.amount || 0) || 0)
-      if (patch.description !== undefined) bounties[index].description = patch.description || ''
+      const tip = await this.getProjectTipId(id);
+      const project = await this.getProjectById(tip);
+      const bounties = Array.isArray(project.bounties) ? project.bounties.slice() : [];
+      if (!bounties[index]) throw new Error('Bounty not found');
+  
+      if (patch.title !== undefined) bounties[index].title = String(patch.title).trim();
+      if (patch.amount !== undefined) bounties[index].amount = Math.max(0, parseFloat(patch.amount || 0) || 0);
+      if (patch.description !== undefined) bounties[index].description = patch.description || '';
       if (patch.milestoneIndex !== undefined) {
-        const newIdx = patch.milestoneIndex == null ? null : parseInt(patch.milestoneIndex, 10)
-        bounties[index].milestoneIndex = (newIdx == null) ? null : (isNaN(newIdx) ? null : newIdx)
+        const newIdx = patch.milestoneIndex == null ? null : parseInt(patch.milestoneIndex, 10);
+        bounties[index].milestoneIndex = (newIdx == null) ? null : (isNaN(newIdx) ? null : newIdx);
       }
-      if (patch.done !== undefined) bounties[index].done = !!patch.done
-      let autoPatch = {}
-      if (bounties[index].milestoneIndex != null) {
-        const { milestones, progress, changed } =
-          autoCompleteMilestoneIfReady({ ...project, bounties }, bounties[index].milestoneIndex, clampPercent)
-        if (changed) {
-          autoPatch.milestones = milestones
-          autoPatch.progress = progress
-          if (progress >= 100) autoPatch.status = 'COMPLETED'
-        }
-      }
-      return this.updateProject(tip, { bounties, ...autoPatch })
+      if (patch.done !== undefined) bounties[index].done = !!patch.done;
+
+      return this.updateProject(tip, { bounties });
     },
 
     async updateMilestone(id, index, patch) {

+ 19 - 1
src/models/stats_model.js

@@ -26,7 +26,7 @@ module.exports = ({ cooler }) => {
   const types = [
     'bookmark','event','task','votes','report','feed','project',
     'image','audio','video','document','transfer','post','tribe',
-    'market','forum','job','aiExchange'
+    'market','forum','job','aiExchange','karmaScore'
   ];
 
   const getFolderSize = (folderPath) => {
@@ -150,6 +150,24 @@ module.exports = ({ cooler }) => {
       opinions[t] = vals.filter(e => Array.isArray(e.opinions_inhabitants) && e.opinions_inhabitants.length > 0).length || 0;
     }
 
+    const karmaMsgsAll = allMsgs.filter(m => m.value?.content?.type === 'karmaScore' && Number.isFinite(Number(m.value.content.karmaScore)));
+    if (filter === 'MINE') {
+      const mine = karmaMsgsAll.filter(m => m.value.author === userId).sort((a, b) => (b.value.timestamp || 0) - (a.value.timestamp || 0));
+      const myKarma = mine.length ? Number(mine[0].value.content.karmaScore) || 0 : 0;
+      content['karmaScore'] = myKarma;
+    } else {
+      const latestByAuthor = new Map();
+      for (const m of karmaMsgsAll) {
+        const a = m.value.author;
+        const ts = m.value.timestamp || 0;
+        const k = Number(m.value.content.karmaScore) || 0;
+        const prev = latestByAuthor.get(a);
+        if (!prev || ts > prev.ts) latestByAuthor.set(a, { ts, k });
+      }
+      const sumKarma = Array.from(latestByAuthor.values()).reduce((s, x) => s + x.k, 0);
+      content['karmaScore'] = sumKarma;
+    }
+
     const tribeVals = Array.from(tipOf['tribe'].values()).map(v => v.content);
     const memberTribes = tribeVals
       .filter(c => Array.isArray(c.members) && c.members.includes(userId))

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

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

+ 1 - 1
src/server/package.json

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

+ 282 - 266
src/views/activity_view.js

@@ -6,7 +6,6 @@ const { renderUrl } = require('../backend/renderUrl');
 function capitalize(str) {
   return typeof str === 'string' && str.length ? str[0].toUpperCase() + str.slice(1) : '';
 }
-
 function sumAmounts(list = []) {
   return list.reduce((s, x) => s + (parseFloat(x.amount || 0) || 0), 0);
 }
@@ -23,7 +22,7 @@ function renderActionCards(actions, userId) {
       if (content.type === 'event' && content.isPublic === "private") return false;
       if (content.type === 'market') {
         if (content.stock === 0 && content.status !== 'SOLD') {
-          return false; 
+          return false;
         }
       }
       return true;
@@ -31,9 +30,9 @@ function renderActionCards(actions, userId) {
     .sort((a, b) => b.ts - a.ts);
 
   if (!validActions.length) {
-    return div({ class: "no-actions" }, p(i18n.noActions)); 
+    return div({ class: "no-actions" }, p(i18n.noActions));
   }
-  
+
   const seenDocumentTitles = new Set();
 
   return validActions.map(action => {
@@ -52,7 +51,7 @@ function renderActionCards(actions, userId) {
         ? Object.entries(votes).map(([option, count]) => ({ option, count }))
         : [];
       cardBody.push(
-        div({ class: 'card-section votes' }, 
+        div({ class: 'card-section votes' },
           div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.question + ':'), span({ class: 'card-value' }, question)),
           div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.deadline + ':'), span({ class: 'card-value' }, deadline ? new Date(deadline).toLocaleString() : '')),
           div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.voteTotalVotes + ':'), span({ class: 'card-value' }, totalVotes)),
@@ -67,7 +66,7 @@ function renderActionCards(actions, userId) {
     if (type === 'transfer') {
       const { from, to, concept, amount, deadline, status, confirmedBy } = content;
       cardBody.push(
-        div({ class: 'card-section transfer' }, 
+        div({ class: 'card-section transfer' },
           div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.concept + ':'), span({ class: 'card-value' }, concept)),
           div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.amount + ':'), span({ class: 'card-value' }, amount)),
           div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.deadline + ':'), span({ class: 'card-value' }, deadline ? new Date(deadline).toLocaleString() : '')),
@@ -75,7 +74,7 @@ function renderActionCards(actions, userId) {
         )
       );
     }
-    
+
     if (type === 'bankWallet') {
       const { address } = content;
       cardBody.push(
@@ -114,14 +113,14 @@ function renderActionCards(actions, userId) {
     }
 
     if (type === 'pixelia') {
-       const { author } = content;
-       cardBody.push(
-	 div({ class: 'card-section pixelia' },
-	   div({ class: 'card-field' },
-	      a({ href: `/author/${encodeURIComponent(author)}`, class: 'activityVotePost' }, author)
-	   )
-	 )
-       );
+      const { author } = content;
+      cardBody.push(
+        div({ class: 'card-section pixelia' },
+          div({ class: 'card-field' },
+            a({ href: `/author/${encodeURIComponent(author)}`, class: 'activityVotePost' }, author)
+          )
+        )
+      );
     }
 
     if (type === 'tribe') {
@@ -129,10 +128,10 @@ function renderActionCards(actions, userId) {
       const validTags = Array.isArray(tags) ? tags : [];
       cardBody.push(
         div({ class: 'card-section tribe' },
-	h2({ class: 'tribe-title' }, 
-	  a({ href: `/tribe/${encodeURIComponent(action.id)}`, class: "user-link" }, title)
-	),
-          typeof isAnonymous === 'boolean' ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeIsAnonymousLabel+ ':'), span({ class: 'card-value' }, isAnonymous ? i18n.tribePrivate : i18n.tribePublic)) : "",      
+          h2({ class: 'tribe-title' },
+            a({ href: `/tribe/${encodeURIComponent(action.id)}`, class: "user-link" }, title)
+          ),
+          typeof isAnonymous === 'boolean' ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeIsAnonymousLabel+ ':'), span({ class: 'card-value' }, isAnonymous ? i18n.tribePrivate : i18n.tribePublic)) : "",
           inviteMode ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.tribeModeLabel) + ':'), span({ class: 'card-value' }, inviteMode.toUpperCase())) : "",
           typeof isLARP === 'boolean' ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeLARPLabel+ ':'), span({ class: 'card-value' }, isLARP ? i18n.tribeYes : i18n.tribeNo)) : "",
           Array.isArray(members) ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.tribeMembersCount) + ':'), span({ class: 'card-value' }, members.length)) : "",
@@ -155,49 +154,49 @@ function renderActionCards(actions, userId) {
         div({ class: 'card-section curriculum' },
           h2(a({ href: `/author/${encodeURIComponent(author)}`, class: "user-link" }, `@`, name)),
           div(
-          { class: 'card-fields-container' },
-	  createdAt ? 
-	   div(
-	    { class: 'card-field' },
-	    span({ class: 'card-label' }, i18n.cvCreatedAt + ':'),
-	    span({ class: 'card-value' }, moment(createdAt).format('YYYY-MM-DD HH:mm:ss'))
-	  ) 
-	  : "",
-	  updatedAt ? 
-	  div(
-	    { class: 'card-field' },
-	    span({ class: 'card-label' }, i18n.cvUpdatedAt + ':'),
-	    span({ class: 'card-value' }, moment(updatedAt).format('YYYY-MM-DD HH:mm:ss'))
-	  ) 
-     	  : ""
-     	  ),
+            { class: 'card-fields-container' },
+            createdAt ?
+              div(
+                { class: 'card-field' },
+                span({ class: 'card-label' }, i18n.cvCreatedAt + ':'),
+                span({ class: 'card-value' }, moment(createdAt).format('YYYY-MM-DD HH:mm:ss'))
+              )
+              : "",
+            updatedAt ?
+              div(
+                { class: 'card-field' },
+                span({ class: 'card-label' }, i18n.cvUpdatedAt + ':'),
+                span({ class: 'card-value' }, moment(updatedAt).format('YYYY-MM-DD HH:mm:ss'))
+              )
+              : ""
+          ),
           status ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.cvStatusLabel + ':'), span({ class: 'card-value' }, status)) : "",
           preferences ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.cvPreferencesLabel || 'Preferences') + ':'), span({ class: 'card-value' }, preferences)) : "",
           languages ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.cvLanguagesLabel || 'Languages') + ':'), span({ class: 'card-value' }, languages.toUpperCase())) : "",
-	  photo ? 
-	  [
-	    br(),
-	    img({ class: "cv-photo", src: `/blob/${encodeURIComponent(photo)}` }),
-	    br()
-	  ]
-	: "",
-	  p(...renderUrl(description || "")),
-	  personalSkills && personalSkills.length
-	  ? div({ class: 'card-tags' }, personalSkills.map(skill =>
-	      a({ href: `/search?query=%23${encodeURIComponent(skill)}`, class: "tag-link" }, `#${skill}`)
-	  )) : "",
-	  oasisSkills && oasisSkills.length
-	  ? div({ class: 'card-tags' }, oasisSkills.map(skill =>
-	      a({ href: `/search?query=%23${encodeURIComponent(skill)}`, class: "tag-link" }, `#${skill}`)
-	  )) : "",
-	  educationalSkills && educationalSkills.length
-	  ? div({ class: 'card-tags' }, educationalSkills.map(skill =>
-	      a({ href: `/search?query=%23${encodeURIComponent(skill)}`, class: "tag-link" }, `#${skill}`)
-	  )) : "",
-	  professionalSkills && professionalSkills.length
-	  ? div({ class: 'card-tags' }, professionalSkills.map(skill =>
-	      a({ href: `/search?query=%23${encodeURIComponent(skill)}`, class: "tag-link" }, `#${skill}`)
-	  )) : "",
+          photo ?
+            [
+              br(),
+              img({ class: "cv-photo", src: `/blob/${encodeURIComponent(photo)}` }),
+              br()
+            ]
+            : "",
+          p(...renderUrl(description || "")),
+          personalSkills && personalSkills.length
+            ? div({ class: 'card-tags' }, personalSkills.map(skill =>
+                a({ href: `/search?query=%23${encodeURIComponent(skill)}`, class: "tag-link" }, `#${skill}`)
+              )) : "",
+          oasisSkills && oasisSkills.length
+            ? div({ class: 'card-tags' }, oasisSkills.map(skill =>
+                a({ href: `/search?query=%23${encodeURIComponent(skill)}`, class: "tag-link" }, `#${skill}`)
+              )) : "",
+          educationalSkills && educationalSkills.length
+            ? div({ class: 'card-tags' }, educationalSkills.map(skill =>
+                a({ href: `/search?query=%23${encodeURIComponent(skill)}`, class: "tag-link" }, `#${skill}`)
+              )) : "",
+          professionalSkills && professionalSkills.length
+            ? div({ class: 'card-tags' }, professionalSkills.map(skill =>
+                a({ href: `/search?query=%23${encodeURIComponent(skill)}`, class: "tag-link" }, `#${skill}`)
+              )) : ""
         )
       );
     }
@@ -205,7 +204,7 @@ function renderActionCards(actions, userId) {
     if (type === 'image') {
       const { url } = content;
       cardBody.push(
-        div({ class: 'card-section image' },    
+        div({ class: 'card-section image' },
           img({ src: `/blob/${encodeURIComponent(url)}`, class: 'feed-image img-content' })
         )
       );
@@ -214,7 +213,7 @@ function renderActionCards(actions, userId) {
     if (content.type === 'audio') {
       const { url, mimeType, title } = content;
       cardBody.push(
-        div({ class: 'card-section audio' }, 
+        div({ class: 'card-section audio' },
           title?.trim() ? h2({ class: 'audio-title' }, title) : "",
           url
             ? div({ class: "audio-container" },
@@ -224,7 +223,7 @@ function renderActionCards(actions, userId) {
                   type: mimeType
                 })
               )
-            : p(i18n.audioNoFile),
+            : p(i18n.audioNoFile)
         )
       );
     }
@@ -232,7 +231,7 @@ function renderActionCards(actions, userId) {
     if (type === 'video') {
       const { url, mimeType, title } = content;
       cardBody.push(
-        div({ class: 'card-section video' },     
+        div({ class: 'card-section video' },
           title?.trim() ? h2({ class: 'video-title' }, title) : "",
           url
             ? div({ class: "video-container" },
@@ -254,10 +253,10 @@ function renderActionCards(actions, userId) {
       const { url, title, key } = content;
       if (title && seenDocumentTitles.has(title.trim())) {
         return null;
-     }
+      }
       if (title) seenDocumentTitles.add(title.trim());
       cardBody.push(
-        div({ class: 'card-section document' },      
+        div({ class: 'card-section document' },
           title?.trim() ? h2({ class: 'document-title' }, title) : "",
           div({
             id: `pdf-container-${key || url}`,
@@ -271,7 +270,7 @@ function renderActionCards(actions, userId) {
     if (type === 'bookmark') {
       const { url } = content;
       cardBody.push(
-        div({ class: 'card-section bookmark' },       
+        div({ class: 'card-section bookmark' },
           h2(url ? p(a({ href: url, target: '_blank', class: "bookmark-url" }, url)) : "")
         )
       );
@@ -280,15 +279,15 @@ function renderActionCards(actions, userId) {
     if (type === 'event') {
       const { title, description, date, location, price, attendees, organizer, isPublic } = content;
       cardBody.push(
-        div({ class: 'card-section event' },    
-        div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, title)),
-        date ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.date + ':'), span({ class: 'card-value' }, new Date(date).toLocaleString())) : "",
-        location ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.location || 'Location') + ':'), span({ class: 'card-value' }, location)) : "",
-        typeof isPublic === 'boolean' ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.isPublic || 'Public') + ':'), span({ class: 'card-value' }, isPublic ? 'Yes' : 'No')) : "",
-        price ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.price || 'Price') + ':'), span({ class: 'card-value' }, price + " ECO")) : "",
-        br,
-        organizer ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.organizer || 'Organizer') + ': '), a({ class: "user-link", href: `/author/${encodeURIComponent(organizer)}` }, organizer)) : "",
-          Array.isArray(attendees) ? h2({ class: 'card-label' }, (i18n.attendees || 'Attendees') + ': ' + attendees.length) : "",   
+        div({ class: 'card-section event' },
+          div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, title)),
+          date ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.date + ':'), span({ class: 'card-value' }, new Date(date).toLocaleString())) : "",
+          location ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.location || 'Location') + ':'), span({ class: 'card-value' }, location)) : "",
+          typeof isPublic === 'boolean' ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.isPublic || 'Public') + ':'), span({ class: 'card-value' }, isPublic ? 'Yes' : 'No')) : "",
+          price ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.price || 'Price') + ':'), span({ class: 'card-value' }, price + " ECO")) : "",
+          br,
+          organizer ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.organizer || 'Organizer') + ': '), a({ class: "user-link", href: `/author/${encodeURIComponent(organizer)}` }, organizer)) : "",
+          Array.isArray(attendees) ? h2({ class: 'card-label' }, (i18n.attendees || 'Attendees') + ': ' + attendees.length) : ""
         )
       );
     }
@@ -301,7 +300,7 @@ function renderActionCards(actions, userId) {
           priority ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.priority || 'Priority') + ':'), span({ class: 'card-value' }, priority)) : "",
           startTime ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.taskStartTimeLabel || 'Start') + ':'), span({ class: 'card-value' }, new Date(startTime).toLocaleString())) : "",
           endTime ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.taskEndTimeLabel || 'End') + ':'), span({ class: 'card-value' }, new Date(endTime).toLocaleString())) : "",
-          status ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.status + ':'), span({ class: 'card-value' }, status)) : "",
+          status ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.status + ':'), span({ class: 'card-value' }, status)) : ""
         )
       );
     }
@@ -310,7 +309,7 @@ function renderActionCards(actions, userId) {
       const { renderTextWithStyles } = require('../backend/renderTextWithStyles');
       const { text, refeeds } = content;
       cardBody.push(
-        div({ class: 'card-section feed' }, 
+        div({ class: 'card-section feed' },
           div({ class: 'feed-text', innerHTML: renderTextWithStyles(text) }),
           h2({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeFeedRefeeds + ': '), span({ class: 'card-label' }, refeeds))
         )
@@ -322,57 +321,56 @@ function renderActionCards(actions, userId) {
       cardBody.push(
         div({ class: 'card-section post' },
           contentWarning ? h2({ class: 'content-warning' }, contentWarning) : '',
-          p({ innerHTML: text }) 
+          p({ innerHTML: text })
         )
       );
     }
-    
+
     if (type === 'forum') {
-        const { root, category, title, text, key } = content;
-        if (!root) {
-            cardBody.push(
-                div({ class: 'card-section forum' },
-                    div({ class: 'card-field', style: "font-size:1.12em; margin-bottom:5px;" },
-                        span({ class: 'card-label', style: "font-weight:800;color:#ff9800;" }, i18n.title + ': '),
-                        a({ href: `/forum/${encodeURIComponent(key || action.id)}`, style: "font-weight:800;color:#4fc3f7;" }, title)
-                    ),
-                )
+      const { root, category, title, text, key } = content;
+      if (!root) {
+        cardBody.push(
+          div({ class: 'card-section forum' },
+            div({ class: 'card-field', style: "font-size:1.12em; margin-bottom:5px;" },
+              span({ class: 'card-label', style: "font-weight:800;color:#ff9800;" }, i18n.title + ': '),
+              a({ href: `/forum/${encodeURIComponent(key || action.id)}`, style: "font-weight:800;color:#4fc3f7;" }, title)
             )
-        } else {
-            let parentForum = actions.find(a => a.type === 'forum' && !a.content.root && (a.id === root || a.content.key === root));
-            let parentCategory = parentForum?.content?.category || '';
-            let parentTitle = parentForum?.content?.title || '';
-            cardBody.push(
-                div({ class: 'card-section forum' },
-                    div({ class: 'card-field', style: "font-size:1.12em; margin-bottom:5px;" },
-                        span({ class: 'card-label', style: "font-weight:800;color:#ff9800;" }, i18n.title + ': '),
-                        a({ href: `/forum/${encodeURIComponent(root)}`, style: "font-weight:800;color:#4fc3f7;" }, parentTitle)
-                    ),
-                    br(),
-                    div({ class: 'card-field', style: 'margin-bottom:12px;' },
-                        p({ style: "margin:0 0 8px 0; word-break:break-all;" }, ...renderUrl(text))
-                    )
-                )
+          )
+        );
+      } else {
+        let parentForum = actions.find(a => a.type === 'forum' && !a.content.root && (a.id === root || a.content.key === root));
+        let parentTitle = parentForum?.content?.title || '';
+        cardBody.push(
+          div({ class: 'card-section forum' },
+            div({ class: 'card-field', style: "font-size:1.12em; margin-bottom:5px;" },
+              span({ class: 'card-label', style: "font-weight:800;color:#ff9800;" }, i18n.title + ': '),
+              a({ href: `/forum/${encodeURIComponent(root)}`, style: "font-weight:800;color:#4fc3f7;" }, parentTitle)
+            ),
+            br(),
+            div({ class: 'card-field', style: 'margin-bottom:12px;' },
+              p({ style: "margin:0 0 8px 0; word-break:break-all;" }, ...renderUrl(text))
             )
-        }
+          )
+        );
+      }
     }
 
     if (type === 'vote') {
       const { vote } = content;
       cardBody.push(
         div({ class: 'card-section vote' },
-           p(
-        	a({ href: `/thread/${encodeURIComponent(vote.link)}#${encodeURIComponent(vote.link)}`, class: 'activityVotePost' }, vote.link)
-	      )
-	    )
-	  );
-	}
+          p(
+            a({ href: `/thread/${encodeURIComponent(vote.link)}#${encodeURIComponent(vote.link)}`, class: 'activityVotePost' }, vote.link)
+          )
+        )
+      );
+    }
 
     if (type === 'about') {
       const { about, name, image } = content;
       cardBody.push(
         div({ class: 'card-section about' },
-        h2(a({ href: `/author/${encodeURIComponent(about)}`, class: "user-link" }, `@`, name)),
+          h2(a({ href: `/author/${encodeURIComponent(about)}`, class: "user-link" }, `@`, name)),
           image
             ? img({ src: `/blob/${encodeURIComponent(image)}` })
             : img({ src: '/assets/images/default-avatar.png', alt: name })
@@ -383,22 +381,22 @@ function renderActionCards(actions, userId) {
     if (type === 'contact') {
       const { contact } = content;
       cardBody.push(
-	div({ class: 'card-section contact' },
-	   p({ class: 'card-field' }, 
+        div({ class: 'card-section contact' },
+          p({ class: 'card-field' },
             a({ href: `/author/${encodeURIComponent(contact)}`, class: 'activitySpreadInhabitant2' }, contact)
-	   )
-	 )
+          )
+        )
       );
-     }
+    }
 
     if (type === 'pub') {
       const { address } = content;
-      const { host, key } = address;
+      const { host, key } = address || {};
       cardBody.push(
         div({ class: 'card-section pub' },
-	   p({ class: 'card-field' },
-            a({ href: `/author/${encodeURIComponent(key)}`, class: 'activitySpreadInhabitant2' }, key)
-	   )
+          p({ class: 'card-field' },
+            a({ href: `/author/${encodeURIComponent(key || '')}`, class: 'activitySpreadInhabitant2' }, key || '')
+          )
         )
       );
     }
@@ -407,7 +405,7 @@ function renderActionCards(actions, userId) {
       const { item_type, title, price, status, deadline, stock, image, auctions_poll, seller } = content;
       const isSeller = seller && userId && seller === userId;
       cardBody.push(
-        div({ class: 'card-section market' }, 
+        div({ class: 'card-section market' },
           div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemTitle + ':'), span({ class: 'card-value' }, title)),
           div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemType + ':'), span({ class: 'card-value' }, item_type.toUpperCase())),
           div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemStatus + ": " ), span({ class: 'card-value' }, status.toUpperCase())),
@@ -421,7 +419,7 @@ function renderActionCards(actions, userId) {
           div({ class: "market-card price" },
             p(`${i18n.marketItemPrice}: ${price} ECO`)
           ),
-            item_type === 'auction' && status !== 'SOLD' && status !== 'DISCARDED' && !isSeller
+          item_type === 'auction' && status !== 'SOLD' && status !== 'DISCARDED' && !isSeller
             ? div({ class: "auction-info" },
                 auctions_poll && auctions_poll.length > 0
                   ? [
@@ -432,8 +430,8 @@ function renderActionCards(actions, userId) {
                           th(i18n.marketAuctionUser),
                           th(i18n.marketAuctionBidAmount)
                         ),
-                            ...auctions_poll.map(bid => {
-                            const [bidderId, bidAmount, bidTime] = bid.split(':');
+                        ...(auctions_poll || []).map(bid => {
+                          const [bidderId, bidAmount, bidTime] = bid.split(':');
                           return tr(
                             td(moment(bidTime).format('YYYY-MM-DD HH:mm:ss')),
                             td(a({ href: `/author/${encodeURIComponent(userId)}` }, userId)),
@@ -449,7 +447,7 @@ function renderActionCards(actions, userId) {
                   button({ class: "buy-btn", type: "submit" }, i18n.marketPlaceBidButton)
                 )
               ) : "",
-            item_type === 'exchange' && status !== 'SOLD' && status !== 'DISCARDED' && !isSeller
+          item_type === 'exchange' && status !== 'SOLD' && status !== 'DISCARDED' && !isSeller
             ? form({ method: "POST", action: `/market/buy/${encodeURIComponent(action.id)}` },
                 button({ class: "buy-btn", type: "submit" }, i18n.marketActionsBuy)
               ) : ""
@@ -460,22 +458,25 @@ function renderActionCards(actions, userId) {
     if (type === 'report') {
       const { title, confirmations, severity, status } = content;
       cardBody.push(
-        div({ class: 'card-section report' },      
+        div({ class: 'card-section report' },
           div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, title)),
           status ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.status + ':'), span({ class: 'card-value' }, status)) : "",
           severity ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.severity || 'Severity') + ':'), span({ class: 'card-value' }, severity.toUpperCase())) : "",
-          Array.isArray(confirmations) ? h2({ class: 'card-label' }, (i18n.transfersConfirmations) + ': ' + confirmations.length) : "",   
+          Array.isArray(confirmations) ? h2({ class: 'card-label' }, (i18n.transfersConfirmations) + ': ' + confirmations.length) : ""
         )
       );
     }
-    
+
     if (type === 'project') {
-      const { title, status, progress, goal, pledged, deadline, followers, backers } = content;
+      const { title, status, progress, goal, pledged, deadline, followers, backers, milestones, bounty, bountyAmount, bounty_currency } = content;
       const ratio = goal ? Math.min(100, Math.round((parseFloat(pledged || 0) / parseFloat(goal)) * 100)) : 0;
       const displayStatus = String(status || 'ACTIVE').toUpperCase();
       const followersCount = Array.isArray(followers) ? followers.length : 0;
       const backersCount = Array.isArray(backers) ? backers.length : 0;
       const backersTotal = sumAmounts(backers || []);
+      const msCount = Array.isArray(milestones) ? milestones.length : 0;
+      const lastMs = Array.isArray(milestones) && milestones.length ? milestones[milestones.length - 1] : null;
+      const bountyVal = typeof bountyAmount !== 'undefined' ? bountyAmount : (typeof bounty === 'number' ? bounty : null);
       cardBody.push(
         div({ class: 'card-section project' },
           title ? div({ class: 'card-field' },
@@ -511,13 +512,21 @@ function renderActionCards(actions, userId) {
             span({ class: 'card-value' }, `${followersCount}`)
           ),
           div({ class: 'card-field' },
-           span({ class: 'card-label' }, i18n.projectBackers + ':'), 
-           span({ class: 'card-value' }, `${backersCount} · ${backersTotal} ECO`)
-          )
+            span({ class: 'card-label' }, i18n.projectBackers + ':'), 
+            span({ class: 'card-value' }, `${backersCount} · ${backersTotal} ECO`)
+          ),
+          msCount ? div({ class: 'card-field' },
+            span({ class: 'card-label' }, (i18n.projectMilestones || 'Milestones') + ':'), 
+            span({ class: 'card-value' }, `${msCount}${lastMs && lastMs.title ? ' · ' + lastMs.title : ''}`)
+          ) : "",
+          bountyVal != null ? div({ class: 'card-field' },
+            span({ class: 'card-label' }, (i18n.projectBounty || 'Bounty') + ':'), 
+            span({ class: 'card-value' }, `${bountyVal} ${(bounty_currency || 'ECO').toUpperCase()}`)
+          ) : ""
         )
       );
     }
-  
+
     if (type === 'aiExchange') {
       const { ctx } = content;
       cardBody.push(
@@ -528,63 +537,74 @@ function renderActionCards(actions, userId) {
         )
       );
     }
-    
+
+    if (type === 'karmaScore') {
+      const { karmaScore } = content;
+      cardBody.push(
+        div({ class: 'card-section ai-exchange' },
+          div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.bankingUserEngagementScore + ':'),
+            span({ class: 'card-value' }, karmaScore)
+          )
+        )
+      );
+    }
+
     if (type === 'job') {
       const { title, job_type, tasks, location, vacants, salary, status, subscribers } = content;
       cardBody.push(
         div({ class: 'card-section report' },
-            div({ class: 'card-field' },
-                span({ class: 'card-label' }, i18n.title + ':'),
-                span({ class: 'card-value' }, title)
-            ),
-            salary && div({ class: 'card-field' },
-                span({ class: 'card-label' }, i18n.jobSalary + ':'),
-                span({ class: 'card-value' }, salary + ' ECO')
-            ),
-            status && div({ class: 'card-field' },
-                span({ class: 'card-label' }, i18n.jobStatus + ':'),
-                span({ class: 'card-value' }, status.toUpperCase())
-            ),
-            job_type && div({ class: 'card-field' },
-                span({ class: 'card-label' }, i18n.jobType + ':'),
-                span({ class: 'card-value' }, job_type.toUpperCase())
-            ),
-            location && div({ class: 'card-field' },
-                span({ class: 'card-label' }, i18n.jobLocation + ':'),
-                span({ class: 'card-value' }, location.toUpperCase())
-            ),
-            vacants && div({ class: 'card-field' },
-                span({ class: 'card-label' }, i18n.jobVacants + ':'),
-                span({ class: 'card-value' }, vacants)
-            ),
-            div({ class: 'card-field' },
-                span({ class: 'card-label' }, i18n.jobSubscribers + ':'),
-                span({ class: 'card-value' },
-                    Array.isArray(subscribers) && subscribers.length > 0
-                        ? `${subscribers.length}`
-                        : i18n.noSubscribers.toUpperCase()
-                )
-            ),
+          div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.title + ':'),
+            span({ class: 'card-value' }, title)
+          ),
+          salary && div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.jobSalary + ':'),
+            span({ class: 'card-value' }, salary + ' ECO')
+          ),
+          status && div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.jobStatus + ':'),
+            span({ class: 'card-value' }, status.toUpperCase())
+          ),
+          job_type && div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.jobType + ':'),
+            span({ class: 'card-value' }, job_type.toUpperCase())
+          ),
+          location && div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.jobLocation + ':'),
+            span({ class: 'card-value' }, location.toUpperCase())
+          ),
+          vacants && div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.jobVacants + ':'),
+            span({ class: 'card-value' }, vacants)
+          ),
+          div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.jobSubscribers + ':'),
+            span({ class: 'card-value' },
+              Array.isArray(subscribers) && subscribers.length > 0
+                ? `${subscribers.length}`
+                : i18n.noSubscribers.toUpperCase()
+            )
+          )
         )
       );
     }
 
-return div({ class: 'card card-rpg' },
-  div({ class: 'card-header' },
-    h2({ class: 'card-label' }, `[${typeLabel}]`),
-    type !== 'feed' && type !== 'aiExchange' && type !== 'bankWallet' && (!action.tipId || action.tipId === action.id)
-      ? form({ method: "GET", action: getViewDetailsAction(type, action) },
-          button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
-        )
-      : ''
-  ),
-  div({ class: 'card-body' }, ...cardBody),
-  p({ class: 'card-footer' },
-    span({ class: 'date-link' }, `${date} ${i18n.performed} `),
-    a({ href: `/author/${encodeURIComponent(action.author)}`, class: 'user-link' }, `${action.author}`)
-  )
-);
-
+    return div({ class: 'card card-rpg' },
+      div({ class: 'card-header' },
+        h2({ class: 'card-label' }, `[${typeLabel}]`),
+        type !== 'feed' && type !== 'aiExchange' && type !== 'bankWallet' && (!action.tipId || action.tipId === action.id)
+          ? form({ method: "GET", action: getViewDetailsAction(type, action) },
+              button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+            )
+          : ''
+      ),
+      div({ class: 'card-body' }, ...cardBody),
+      p({ class: 'card-footer' },
+        span({ class: 'date-link' }, `${date} ${i18n.performed} `),
+        a({ href: `/author/${encodeURIComponent(action.author)}`, class: 'user-link' }, `${action.author}`)
+      )
+    );
   });
 }
 
@@ -596,6 +616,7 @@ function getViewDetailsAction(type, action) {
     case 'pixelia': return `/pixelia`;
     case 'tribe': return `/tribe/${id}`;
     case 'curriculum': return `/inhabitant/${encodeURIComponent(action.author)}`;
+    case 'karmaScore': return `/author/${encodeURIComponent(action.author)}`;
     case 'image': return `/images/${id}`;
     case 'audio': return `/audios/${id}`;
     case 'video': return `/videos/${id}`;
@@ -616,7 +637,6 @@ function getViewDetailsAction(type, action) {
     case 'report': return `/reports/${id}`;
     case 'bankWallet': return `/wallet`;
     case 'bankClaim': return `/banking${action.content?.epochId ? `/epoch/${encodeURIComponent(action.content.epochId)}` : ''}`;
-
   }
 }
 
@@ -625,39 +645,35 @@ exports.activityView = (actions, filter, userId) => {
   const desc = i18n.activityDesc;
 
   const activityTypes = [
-  { type: 'recent',    label: i18n.typeRecent },
-  { type: 'all',       label: i18n.allButton },
-  { type: 'mine',      label: i18n.mineButton },
-
-  { type: 'banking',   label: i18n.typeBanking },
-  { type: 'market',    label: i18n.typeMarket },
-  { type: 'project',   label: i18n.typeProject },
-  { type: 'job',       label: i18n.typeJob },
-  { type: 'transfer',  label: i18n.typeTransfer },
-
-  { type: 'votes',     label: i18n.typeVotes },
-  { type: 'event',     label: i18n.typeEvent },
-  { type: 'task',      label: i18n.typeTask },
-  { type: 'report',    label: i18n.typeReport },
-
-  { type: 'tribe',     label: i18n.typeTribe },
-  { type: 'about',     label: i18n.typeAbout },
-  { type: 'curriculum',label: i18n.typeCurriculum },
-  { type: 'feed',      label: i18n.typeFeed },
-
-  { type: 'aiExchange', label: i18n.typeAiExchange },
-  { type: 'post',      label: i18n.typePost },
-  { type: 'pixelia',   label: i18n.typePixelia },
-  { type: 'forum',     label: i18n.typeForum },
-  
-  { type: 'bookmark',  label: i18n.typeBookmark },
-  { type: 'image',     label: i18n.typeImage },
-  { type: 'video',     label: i18n.typeVideo },
-  { type: 'audio',     label: i18n.typeAudio },
-  { type: 'document',  label: i18n.typeDocument }
+    { type: 'recent',    label: i18n.typeRecent },
+    { type: 'all',       label: i18n.allButton },
+    { type: 'mine',      label: i18n.mineButton },
+    { type: 'banking',   label: i18n.typeBanking },
+    { type: 'market',    label: i18n.typeMarket },
+    { type: 'project',   label: i18n.typeProject },
+    { type: 'job',       label: i18n.typeJob },
+    { type: 'transfer',  label: i18n.typeTransfer },
+    { type: 'votes',     label: i18n.typeVotes },
+    { type: 'event',     label: i18n.typeEvent },
+    { type: 'task',      label: i18n.typeTask },
+    { type: 'report',    label: i18n.typeReport },
+    { type: 'tribe',     label: i18n.typeTribe },
+    { type: 'about',     label: i18n.typeAbout },
+    { type: 'curriculum',label: i18n.typeCurriculum },
+    { type: 'karmaScore',label: i18n.typeKarmaScore },
+    { type: 'feed',      label: i18n.typeFeed },
+    { type: 'aiExchange',label: i18n.typeAiExchange },
+    { type: 'post',      label: i18n.typePost },
+    { type: 'pixelia',   label: i18n.typePixelia },
+    { type: 'forum',     label: i18n.typeForum },
+    { type: 'bookmark',  label: i18n.typeBookmark },
+    { type: 'image',     label: i18n.typeImage },
+    { type: 'video',     label: i18n.typeVideo },
+    { type: 'audio',     label: i18n.typeAudio },
+    { type: 'document',  label: i18n.typeDocument }
   ];
 
- let filteredActions;
+  let filteredActions;
   if (filter === 'mine') {
     filteredActions = actions.filter(action => action.author === userId && action.type !== 'tombstone');
   } else if (filter === 'recent') {
@@ -670,66 +686,66 @@ exports.activityView = (actions, filter, userId) => {
   }
 
   let html = template(
-  title,
-  section(
-    div({ class: 'tags-header' },
-      h2(i18n.activityList),
-      p(desc)
-    ),
-    form({ method: 'GET', action: '/activity' },
-      div({ class: 'mode-buttons', style: 'display:grid; grid-template-columns: repeat(6, 1fr); gap: 16px; margin-bottom: 24px;' },
-        div({ style: 'display: flex; flex-direction: column; gap: 8px;' },
-          activityTypes.slice(0, 3).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)
+    title,
+    section(
+      div({ class: 'tags-header' },
+        h2(i18n.activityList),
+        p(desc)
+      ),
+      form({ method: 'GET', action: '/activity' },
+        div({ class: 'mode-buttons', style: 'display:grid; grid-template-columns: repeat(6, 1fr); gap: 16px; margin-bottom: 24px;' },
+          div({ style: 'display: flex; flex-direction: column; gap: 8px;' },
+            activityTypes.slice(0, 3).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)
+              )
             )
-          )
-        ),
-        div({ style: 'display: flex; flex-direction: column; gap: 8px;' },
-          activityTypes.slice(3, 8).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)
+          ),
+          div({ style: 'display: flex; flex-direction: column; gap: 8px;' },
+            activityTypes.slice(3, 8).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)
+              )
             )
-          )
-        ),
-        div({ style: 'display: flex; flex-direction: column; gap: 8px;' },
-          activityTypes.slice(8, 12).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)
+          ),
+          div({ style: 'display: flex; flex-direction: column; gap: 8px;' },
+            activityTypes.slice(8, 12).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)
+              )
             )
-          )
-        ),
-        div({ style: 'display: flex; flex-direction: column; gap: 8px;' },
-          activityTypes.slice(12, 16).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)
+          ),
+          div({ style: 'display: flex; flex-direction: column; gap: 8px;' },
+            activityTypes.slice(12, 17).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)
+              )
             )
-          )
-        ),
-        div({ style: 'display: flex; flex-direction: column; gap: 8px;' },
-          activityTypes.slice(16, 20).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)
+          ),
+          div({ style: 'display: flex; flex-direction: column; gap: 8px;' },
+            activityTypes.slice(17, 21).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)
+              )
             )
-          )
-        ),
-        div({ style: 'display: flex; flex-direction: column; gap: 8px;' },
-          activityTypes.slice(20, 25).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)
+          ),
+          div({ style: 'display: flex; flex-direction: column; gap: 8px;' },
+            activityTypes.slice(21, 26).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)
+              )
             )
           )
         )
-      )
-    ),
-    section({ class: 'feed-container' }, renderActionCards(filteredActions, userId))
-  )
+      ),
+      section({ class: 'feed-container' }, renderActionCards(filteredActions, userId))
+    )
   );
 
   const hasDocument = actions.some(a => a && a.type === 'document');

+ 57 - 22
src/views/banking_views.js

@@ -1,8 +1,10 @@
 const { div, h2, p, section, button, form, a, input, span, pre, table, thead, tbody, tr, td, th, br } = require("../server/node_modules/hyperaxe");
 const { template, i18n } = require("../views/main_views");
+const moment = require("../server/node_modules/moment");
 
 const FILTER_LABELS = {
   overview: i18n.bankOverview,
+  exchange: i18n.bankExchange,
   mine: i18n.mine,
   pending: i18n.pending,
   closed: i18n.closed,
@@ -23,6 +25,44 @@ const generateFilterButtons = (filters, currentFilter, action) =>
 
 const kvRow = (label, value) =>
   tr(td({ class: "card-label" }, label), td({ class: "card-value" }, value));
+  
+const fmtIndex = (value) => {
+    return value ? value.toFixed(6) : "0.000000";
+};
+
+const pct = (value) => {
+    if (value === undefined || value === null) return "0.000001%";
+    const formattedValue = (value).toFixed(6); 
+    const sign = value >= 0 ? "+" : "";
+    return `${sign}${formattedValue}%`;
+};
+
+const fmtDate = (timestamp) => {
+    return moment(timestamp).format('YYYY-MM-DD HH:mm:ss');
+};
+
+const renderExchange = (ex) => {
+  if (!ex) return div(p(i18n.bankExchangeNoData));
+  const syncStatus = ex.isSynced ? i18n.bankingSyncStatusSynced : i18n.bankingSyncStatusOutdated;
+  const syncStatusClass = ex.isSynced ? 'synced' : 'outdated';
+  const ecoInHours = ex.isSynced ? ex.ecoInHours : 0;
+  return div(
+    div({ class: "bank-summary" },
+      table({ class: "bank-info-table" },
+        tbody(
+          kvRow(i18n.bankingSyncStatus, 
+            span({ class: syncStatusClass }, syncStatus)
+          ),
+          kvRow(i18n.bankExchangeCurrentValue, `${fmtIndex(ex.ecoValue)} ECO`),
+          kvRow(i18n.bankCurrentSupply, `${Number(ex.currentSupply || 0).toFixed(6)} ECO`),
+          kvRow(i18n.bankTotalSupply, `${Number(ex.totalSupply || 0).toFixed(6)} ECO`),
+          kvRow(i18n.bankEcoinHours, `${ecoInHours} ${i18n.bankHoursOfWork}`),
+          kvRow(i18n.bankInflation, `${ex.inflationFactor.toFixed(2)}%`)
+        )
+      )
+    )
+  );
+};
 
 const renderOverviewSummaryTable = (s, rules) => {
   const score = Number(s.userEngagementScore || 0);
@@ -31,7 +71,6 @@ const renderOverviewSummaryTable = (s, rules) => {
   const w = 1 + score / 100;
   const cap = rules?.caps?.cap_user_epoch ?? 50;
   const future = Math.min(pool * (w / W), cap);
-
   return div({ class: "bank-summary" },
     table({ class: "bank-info-table" },
       tbody(
@@ -232,28 +271,24 @@ const renderAddresses = (data, userId) => {
 };
 
 const renderBankingView = (data, filter, userId) =>
-  template(
-    i18n.banking,
-    section(
-      div({ class: "tags-header" }, h2(i18n.banking), p(i18n.bankingDescription)),
-      generateFilterButtons(["overview", "mine", "pending", "closed", "epochs", "rules", "addresses"], filter, "/banking"),
-      filter === "overview"
-        ? div(
-            renderOverviewSummaryTable(data.summary || {}, data.rules),
-            allocationsTable((data.allocations || []).sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)), userId)
-          )
-        : filter === "epochs"
-          ? renderEpochList(data.epochs || [])
-          : filter === "rules"
-            ? rulesBlock(data.rules)
-            : filter === "addresses"
-              ? renderAddresses(data, userId)
-              : allocationsTable(
-                  (filterAllocations((data.allocations || []).sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)), filter, userId)),
-                  userId
+    template(
+        i18n.banking,
+        section(
+            div({ class: "tags-header" }, h2(i18n.banking), p(i18n.bankingDescription)),
+            generateFilterButtons(["overview", "exchange", "mine", "pending", "closed", "epochs", "rules", "addresses"], filter, "/banking"),
+            filter === "overview"
+                ? div(
+                    renderOverviewSummaryTable(data.summary || {}, data.rules),
+                    allocationsTable((data.allocations || []).sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)), userId)
                 )
-    )
-  );
+                : filter === "exchange"
+                    ? renderExchange(data.exchange)
+                    : allocationsTable(
+                        (filterAllocations((data.allocations || []).sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)), filter, userId)),
+                        userId
+                    )
+        )
+    );
 
 module.exports = { renderBankingView };
 

+ 0 - 1
src/views/image_view.js

@@ -219,4 +219,3 @@ exports.singleImageView = async (image, filter) => {
     )
   );
 };
-

+ 20 - 11
src/views/inhabitants_view.js

@@ -1,16 +1,14 @@
-const { div, h2, p, section, button, form, img, a, textarea, input, br, span } = require("../server/node_modules/hyperaxe");
+const { div, h2, p, section, button, form, img, a, textarea, input, br, span, strong } = require("../server/node_modules/hyperaxe");
 const { template, i18n } = require('./main_views');
 const { renderUrl } = require('../backend/renderUrl');
 
 function resolvePhoto(photoField, size = 256) {
-  if (typeof photoField === 'string' && photoField.startsWith('/image/')) {
+  if (photoField == "/image/256/%260000000000000000000000000000000000000000000%3D.sha256"){
+    return '/assets/images/default-avatar.png';
+  } else {
     return photoField;
   }
-  if (/^&[A-Za-z0-9+/=]+\.sha256$/.test(photoField)) {
-    return `/image/${size}/${encodeURIComponent(photoField)}`;
-  }
-  return '/assets/images/default-avatar.png';
-}
+};
 
 const generateFilterButtons = (filters, currentFilter) =>
   filters.map(mode =>
@@ -41,12 +39,22 @@ const renderInhabitantCard = (user, filter, currentUserId) => {
       filter === 'blocked' && user.isBlocked
         ? p(i18n.blockedLabel) : null,
       p(a({ class: 'user-link', href: `/author/${encodeURIComponent(user.id)}` }, user.id)),
-
+      user.ecoAddress
+        ? div({ class: "eco-wallet" },
+            p(`${i18n.bankWalletConnected}: `, strong(user.ecoAddress))
+          )
+        : div({ class: "eco-wallet" },
+            p(i18n.ecoWalletNotConfigured || "ECOin Wallet not configured")
+          ),
+      p(
+        `${i18n.bankingUserEngagementScore}: `,
+        strong(typeof user.karmaScore === 'number' ? user.karmaScore : 0)
+      ),
       div(
         { class: 'cv-actions', style: 'display:flex; flex-direction:column; gap:8px; margin-top:12px;' },
         isMe
           ? p(i18n.relationshipYou)
-          : (filter === 'CVs' || filter === 'MATCHSKILLS' || filter === 'SUGGESTED')
+          : (filter === 'CVs' || filter === 'MATCHSKILLS' || filter === 'SUGGESTED' || filter === 'TOP KARMA')
             ? form(
                 { method: 'GET', action: `/inhabitant/${encodeURIComponent(user.id)}` },
                 button({ type: 'submit', class: 'btn' }, i18n.inhabitantviewDetails)
@@ -92,10 +100,11 @@ exports.inhabitantsView = (inhabitants, filter, query, currentUserId) => {
                : filter === 'SUGGESTED'   ? i18n.suggestedSectionTitle
                : filter === 'blocked'     ? i18n.blockedSectionTitle
                : filter === 'GALLERY'     ? i18n.gallerySectionTitle
+               : filter === 'TOP KARMA'    ? i18n.topkarmaSectionTitle
                                           : i18n.allInhabitants;
 
   const showCVFilters = filter === 'CVs' || filter === 'MATCHSKILLS';
-  const filters = ['all', 'contacts', 'SUGGESTED', 'blocked', 'CVs', 'MATCHSKILLS', 'GALLERY'];
+  const filters = ['all', 'TOP KARMA', 'contacts', 'SUGGESTED', 'blocked', 'CVs', 'MATCHSKILLS', 'GALLERY'];
 
   return template(
     title,
@@ -169,7 +178,7 @@ exports.inhabitantsProfileView = ({ about = {}, cv = {}, feed = [] }, currentUse
         p(i18n.discoverPeople)
       ),
       div({ class: 'mode-buttons', style: 'display:flex; gap:8px; margin-top:16px;' },
-        ...generateFilterButtons(['all', 'contacts', 'SUGGESTED', 'blocked', 'CVs', 'MATCHSKILLS', 'GALLERY'], 'all')
+        ...generateFilterButtons(['all', 'TOP KARMA', 'contacts', 'SUGGESTED', 'blocked', 'CVs', 'MATCHSKILLS', 'GALLERY'], 'all')
       ),
       div({ class: 'inhabitant-card', style: 'margin-top:32px;' },
         img({ class: 'inhabitant-photo', src: image, alt: name }),

+ 7 - 2
src/views/main_views.js

@@ -425,7 +425,7 @@ const template = (titlePrefix, ...elements) => {
               navLink({ href: "/modules", emoji: "ꗣ", text: i18n.modules }),
               navLink({ href: "/settings", emoji: "⚙", text: i18n.settings })
             )
-          )
+          ),
         ),
         div(
           { class: "top-bar-right" },
@@ -863,7 +863,8 @@ exports.authorView = ({
   lastPost,
   name,
   relationship,
-  ecoAddress
+  ecoAddress,
+  karmaScore = 0
 }) => {
   const mention = `[@${name}](${feedId})`;
   const markdownMention = highlightJs.highlight(mention, { language: "markdown", ignoreIllegals: true }).value;
@@ -968,6 +969,10 @@ exports.authorView = ({
                 ]
           )
         ),
+	p(
+	  `${i18n.bankingUserEngagementScore}: `,
+	  strong(karmaScore !== undefined ? karmaScore : 0)
+	),
 	ecoAddress
 	  ? div({ class: "eco-wallet" },
               p(`${i18n.bankWalletConnected}: `, strong(ecoAddress))

+ 17 - 17
src/views/projects_view.js

@@ -168,32 +168,32 @@ function renderFollowers(project) {
 }
 
 function renderMilestonesAndBounties(project, editable = false) {
-  const milestones = project.milestones || []
-  const bounties = project.bounties || []
-  const unassigned = bounties.filter(b => b.milestoneIndex == null)
+  const milestones = project.milestones || [];
+  const bounties = project.bounties || [];
+  const unassigned = bounties.filter(b => b.milestoneIndex == null);
 
   const blocks = milestones.map((m, idx) => {
-    const { total, count, done } = bountyTotalsForMilestone(project, idx)
-    const items = bounties.filter(b => b.milestoneIndex === idx)
+    const { total, count, done } = bountyTotalsForMilestone(project, idx);
+    const items = bounties.filter(b => b.milestoneIndex === idx);
     return div({ class: 'milestone-with-bounties' },
       div({ class: 'milestone-stats' },
           div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.projectMilestoneStatus + ':'), span({ class: 'card-value' }, m.done ? i18n.projectMilestoneDone.toUpperCase() : i18n.projectMilestoneOpen.toUpperCase())),
           div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.projectBounties + ':'), span({ class: 'card-value' }, `${done}/${count} · ${total} ECO`))
         ),
       div({ class: 'milestone-head' },
-          span({ class: 'milestone-title' }, m.title),br(),br(),
-            span({ class: 'chip chip-pct' }, `${m.targetPercent || 0}%`),
-            m.dueDate ? span({ class: 'chip chip-due' }, `${i18n.projectMilestoneDue}: ${moment(m.dueDate).format('YYYY/MM/DD HH:mm')}`) : null,br(),
-            m.description ? p(...renderUrl(m.description)) : null,
+          span({ class: 'milestone-title' }, m.title),
+          m.dueDate ? span({ class: 'chip chip-due' }, `${i18n.projectMilestoneDue}: ${moment(m.dueDate).format('YYYY/MM/DD HH:mm')}`) : null,
+          m.description ? p(...renderUrl(m.description)) : null,
         (editable && !m.done) ? form({ method: 'POST', action: `/projects/milestones/complete/${encodeURIComponent(project.id)}/${idx}` },
           button({ class: 'btn', type: 'submit' }, i18n.projectMilestoneMarkDone)
         ) : null
       ),
       items.length
         ? ul(items.map(b => {
-            const globalIndex = bounties.indexOf(b)
+            const globalIndex = bounties.indexOf(b);
             return li({ class: 'bounty-item' },
-              field(i18n.projectBountyStatus + ':', b.done ? i18n.projectBountyDone : (b.claimedBy ? i18n.projectBountyClaimed.toUpperCase() : i18n.projectBountyOpen.toUpperCase())),br(),
+              field(i18n.projectBountyStatus + ':', b.done ? i18n.projectBountyDone.toUpperCase() : (b.claimedBy ? i18n.projectBountyClaimed.toUpperCase() : i18n.projectBountyOpen.toUpperCase())),
+              br,
               div({ class: 'bounty-main' },
                 span({ class: 'bounty-title' }, b.title),
                 span({ class: 'bounty-amount' }, `${b.amount} ECO`)
@@ -211,14 +211,14 @@ function renderMilestonesAndBounties(project, editable = false) {
             )
           }))
         : p(i18n.projectNoBounties)
-    )
-  })
+    );
+  });
 
   const unassignedBlock = unassigned.length
     ? div({ class: 'bounty-milestone-block' },
         h2(`${i18n.projectBounties} — ${i18n.projectMilestoneOpen} (no milestone)`),
         ul(unassigned.map(b => {
-          const globalIndex = bounties.indexOf(b)
+          const globalIndex = bounties.indexOf(b);
           return li({ class: 'bounty-item' },
             div({ class: 'bounty-main' },
               span({ class: 'bounty-title' }, b.title),
@@ -248,9 +248,9 @@ function renderMilestonesAndBounties(project, editable = false) {
           )
         }))
       )
-    : null
+    : null;
 
-  return div({ class: 'milestones-bounties' }, ...blocks, unassignedBlock)
+  return div({ class: 'milestones-bounties' }, ...blocks, unassignedBlock);
 }
 
 const renderProjectList = (projects, filter) =>
@@ -525,7 +525,7 @@ exports.singleProjectView = async (project, filter="ALL") => {
                 option({ value: String(idx) }, m.title)
               )
             ), br(), br(),
-            button({ class: 'btn', type: 'submit', disabled: remain <= 0 }, remain > 0 ? i18n.projectBountyCreateButton : i18n.projectNoRemainingBudget)
+            button({ class: 'btn submit-bounty', type: 'submit' }, remain > 0 ? i18n.projectBountyCreateButton : i18n.projectNoRemainingBudget)
           )
         ) : null,
         div({ class: 'card-footer' },

+ 3 - 1
src/views/stats_view.js

@@ -11,7 +11,7 @@ exports.statsView = (stats, filter) => {
   const types = [
     'bookmark', 'event', 'task', 'votes', 'report', 'feed', 'project',
     'image', 'audio', 'video', 'document', 'transfer', 'post', 'tribe',
-    'market', 'forum', 'job', 'aiExchange'
+    'market', 'forum', 'job', 'aiExchange', 'karmaScore'
   ];
   const totalContent = types.reduce((sum, t) => sum + C(stats, t), 0);
   const totalOpinions = types.reduce((sum, t) => sum + O(stats, t), 0);
@@ -90,6 +90,7 @@ exports.statsView = (stats, filter) => {
 
         filter === 'ALL'
           ? div({ class: 'stats-container' }, [
+              div({ style: blockStyle }, h2(`${i18n.bankingUserEngagementScore}: ${C(stats, 'karmaScore')}`)),
               div({ style: blockStyle },
                 h2(i18n.statsActivity7d),
                 table({ style: 'width:100%; border-collapse: collapse;' },
@@ -162,6 +163,7 @@ exports.statsView = (stats, filter) => {
             ])
           : filter === 'MINE'
             ? div({ class: 'stats-container' }, [
+                div({ style: blockStyle }, h2(`${i18n.bankingUserEngagementScore}: ${C(stats, 'karmaScore')}`)),
                 div({ style: blockStyle },
                   h2(i18n.statsActivity7d),
                   table({ style: 'width:100%; border-collapse: collapse;' },

+ 4 - 4
src/views/tribes_view.js

@@ -158,11 +158,11 @@ exports.tribesView = async (tribes, filter, tribeId, query = {}) => {
     : [...searched].sort((a, b) => b.createdAt - a.createdAt);
 
   const title =
+    filter === 'recent' ? i18n.tribeRecentSectionTitle :
     filter === 'mine' ? i18n.tribeMineSectionTitle :
     filter === 'create' ? i18n.tribeCreateSectionTitle :
     filter === 'edit' ? i18n.tribeUpdateSectionTitle :
     filter === 'gallery' ? i18n.tribeGallerySectionTitle :
-    filter === 'recent' ? i18n.tribeRecentSectionTitle :
     filter === 'top' ? i18n.tribeTopSectionTitle :
     filter === 'larp' ? i18n.tribeLarpSectionTitle :
     i18n.tribeAllSectionTitle;
@@ -180,7 +180,7 @@ exports.tribesView = async (tribes, filter, tribeId, query = {}) => {
   );
 
   const modeButtons = div({ class: 'mode-buttons', style: 'display:flex; gap:8px; margin-top:16px;' },
-    ['all','mine','membership','larp','recent','top','gallery'].map(f =>
+    ['recent','all','mine','membership','larp','top','gallery'].map(f =>
     form({ method: 'GET', action: '/tribes' },
       input({ type: 'hidden', name: 'filter', value: f }),
       button({ type: 'submit', class: filter === f ? 'filter-btn active' : 'filter-btn' },
@@ -268,7 +268,7 @@ exports.tribesView = async (tribes, filter, tribeId, query = {}) => {
       p(`${i18n.tribeIsAnonymousLabel}: ${t.isAnonymous ? i18n.tribePrivate : i18n.tribePublic}`),
       p(`${i18n.tribeModeLabel}: ${t.inviteMode.toUpperCase()}`),
       p(`${i18n.tribeLARPLabel}: ${t.isLARP ? i18n.tribeYes : i18n.tribeNo}`),
-      p(`${i18n.tribeLocationLabel}: ${t.location}`),
+      t.location ? p(`${i18n.tribeLocationLabel}: `, ...renderUrl(t.location)) : null,
       img({ src: imageSrc }),
       t.description ? p(...renderUrl(t.description)) : null,
       h2(`${i18n.tribeMembersCount}: ${t.members.length}`),
@@ -381,7 +381,7 @@ exports.tribeView = async (tribe, userId, query) => {
     p(`${i18n.tribeIsAnonymousLabel}: ${tribe.isAnonymous ? i18n.tribePrivate : i18n.tribePublic}`),
     p(`${i18n.tribeModeLabel}: ${tribe.inviteMode.toUpperCase()}`),
     p(`${i18n.tribeLARPLabel}: ${tribe.isLARP ? i18n.tribeYes : i18n.tribeNo}`),
-    p(`${i18n.tribeLocationLabel}: ${tribe.location}`),
+    tribe.location ? p(`${i18n.tribeLocationLabel}: `, ...renderUrl(tribe.location)) : null,
     img({ src: imageSrc, alt: tribe.title }),
     tribe.description ? p(...renderUrl(tribe.description)) : null,
     h2(`${i18n.tribeMembersCount}: ${tribe.members.length}`),