Pārlūkot izejas kodu

Oasis release 0.4.3

psy 1 nedēļu atpakaļ
vecāks
revīzija
e9209617ac
53 mainītis faili ar 3081 papildinājumiem un 1456 dzēšanām
  1. 2 0
      README.md
  2. 9 0
      docs/CHANGELOG.md
  3. 3 1
      src/AI/buildAIContext.js
  4. 194 60
      src/backend/backend.js
  5. 35 0
      src/client/assets/styles/style.css
  6. 96 14
      src/client/assets/translations/oasis_en.js
  7. 101 20
      src/client/assets/translations/oasis_es.js
  8. 80 1
      src/client/assets/translations/oasis_eu.js
  9. 1 1
      src/configs/agenda-config.json
  10. 4 0
      src/configs/config-manager.js
  11. 5 1
      src/configs/oasis-config.json
  12. 97 33
      src/models/activity_model.js
  13. 130 46
      src/models/agenda_model.js
  14. 3 1
      src/models/audios_model.js
  15. 138 52
      src/models/blockchain_model.js
  16. 3 1
      src/models/bookmarking_model.js
  17. 38 13
      src/models/cv_model.js
  18. 3 1
      src/models/documents_model.js
  19. 20 14
      src/models/events_model.js
  20. 3 1
      src/models/feed_model.js
  21. 11 5
      src/models/forum_model.js
  22. 38 31
      src/models/images_model.js
  23. 36 47
      src/models/inhabitants_model.js
  24. 209 0
      src/models/jobs_model.js
  25. 5 2
      src/models/main_models.js
  26. 305 259
      src/models/market_model.js
  27. 45 1
      src/models/opinions_model.js
  28. 4 2
      src/models/pixelia_model.js
  29. 4 1
      src/models/reports_model.js
  30. 3 1
      src/models/search_model.js
  31. 53 29
      src/models/stats_model.js
  32. 3 1
      src/models/tags_model.js
  33. 56 6
      src/models/tasks_model.js
  34. 3 1
      src/models/transfers_model.js
  35. 32 1
      src/models/trending_model.js
  36. 4 2
      src/models/tribes_model.js
  37. 8 9
      src/models/videos_model.js
  38. 52 1
      src/models/votes_model.js
  39. 1 1
      src/server/package-lock.json
  40. 1 1
      src/server/package.json
  41. 76 30
      src/views/activity_view.js
  42. 93 64
      src/views/agenda_view.js
  43. 164 191
      src/views/blockchain_view.js
  44. 1 1
      src/views/event_view.js
  45. 102 109
      src/views/inhabitants_view.js
  46. 315 0
      src/views/jobs_view.js
  47. 136 45
      src/views/main_views.js
  48. 307 349
      src/views/market_view.js
  49. 1 0
      src/views/modules_view.js
  50. 8 2
      src/views/pm_view.js
  51. 18 0
      src/views/settings_view.js
  52. 19 1
      src/views/stats_view.js
  53. 3 3
      src/views/task_view.js

+ 2 - 0
README.md

@@ -60,6 +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.
  + Agenda: Module to manage all your assigned items.
  + AI: Module to talk with a LLM called '42'.
  + AI: Module to talk with a LLM called '42'.
  + Audios: Module to discover and manage audios.
  + Audios: Module to discover and manage audios.
+ + BlockExplorer: Module to navigate the blockchain.
  + Bookmarks: Module to discover and manage bookmarks.	
  + Bookmarks: Module to discover and manage bookmarks.	
  + Cipher: Module to encrypt and decrypt your text symmetrically (using a shared password).	
  + Cipher: Module to encrypt and decrypt your text symmetrically (using a shared password).	
  + Documents: Module to discover and manage documents.	
  + Documents: Module to discover and manage documents.	
@@ -69,6 +70,7 @@ Oasis is TRULY MODULAR. Here's a list of what comes deployed with the "core".
  + Governance: Module to discover and manage votes.	
  + Governance: Module to discover and manage votes.	
  + Images: Module to discover and manage images.	
  + Images: Module to discover and manage images.	
  + Invites: Module to manage and apply invite codes.	
  + Invites: Module to manage and apply invite codes.	
+ + Jobs: Module to discover and manage jobs.	
  + Legacy: Module to manage your secret (private key) quickly and securely.	
  + Legacy: Module to manage your secret (private key) quickly and securely.	
  + Latest: Module to receive the most recent posts and discussions.
  + Latest: Module to receive the most recent posts and discussions.
  + Market: Module to exchange goods or services.
  + Market: Module to exchange goods or services.

+ 9 - 0
docs/CHANGELOG.md

@@ -13,6 +13,15 @@ All notable changes to this project will be documented in this file.
 ### Security
 ### Security
 -->
 -->
 
 
+## v0.4.3 - 2025-08-08
+
+### Added
+
+- Limiter to blockchain logstream retrieval.
+
+  + Jobs: Module to discover and manage jobs.
+  + BlockExplorer: Module to navigate the blockchain.
+
 ## v0.4.0 - 2025-07-29
 ## v0.4.0 - 2025-07-29
 
 
 ### Added
 ### Added

+ 3 - 1
src/AI/buildAIContext.js

@@ -1,5 +1,7 @@
 import pull from 'pull-stream';
 import pull from 'pull-stream';
 import gui from '../client/gui.js';
 import gui from '../client/gui.js';
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 
 const cooler = gui({ offline: false });
 const cooler = gui({ offline: false });
 
 
@@ -52,7 +54,7 @@ async function buildContext(maxItems = 100) {
   const ssb = await cooler.open();
   const ssb = await cooler.open();
   return new Promise((resolve, reject) => {
   return new Promise((resolve, reject) => {
     pull(
     pull(
-      ssb.createLogStream(),
+      ssb.createLogStream({ limit: logLimit }),
       pull.collect((err, msgs) => {
       pull.collect((err, msgs) => {
         if (err) return reject(err);
         if (err) return reject(err);
 
 

+ 194 - 60
src/backend/backend.js

@@ -89,6 +89,7 @@ debug("Current configuration: %O", config);
 debug(`You can save the above to ${defaultConfigFile} to make \
 debug(`You can save the above to ${defaultConfigFile} to make \
 these settings the default. See the readme for details.`);
 these settings the default. See the readme for details.`);
 const { saveConfig, getConfig } = require('../configs/config-manager');
 const { saveConfig, getConfig } = require('../configs/config-manager');
+const configPath = path.join(__dirname, '../configs/oasis-config.json');
 
 
 const oasisCheckPath = "/.well-known/oasis";
 const oasisCheckPath = "/.well-known/oasis";
 
 
@@ -210,6 +211,7 @@ const pixeliaModel = require('../models/pixelia_model')({ cooler, isPublic: conf
 const marketModel = require('../models/market_model')({ cooler, isPublic: config.public });
 const marketModel = require('../models/market_model')({ cooler, isPublic: config.public });
 const forumModel = require('../models/forum_model')({ cooler, isPublic: config.public });
 const forumModel = require('../models/forum_model')({ cooler, isPublic: config.public });
 const blockchainModel = require('../models/blockchain_model')({ cooler, isPublic: config.public });
 const blockchainModel = require('../models/blockchain_model')({ cooler, isPublic: config.public });
+const jobsModel = require('../models/jobs_model')({ cooler, isPublic: config.public });
 
 
 // starting warmup
 // starting warmup
 about._startNameWarmup();
 about._startNameWarmup();
@@ -411,6 +413,7 @@ const { marketView, singleMarketView } = require("../views/market_view");
 const { aiView } = require("../views/AI_view");
 const { aiView } = require("../views/AI_view");
 const { forumView, singleForumView } = require("../views/forum_view");
 const { forumView, singleForumView } = require("../views/forum_view");
 const { renderBlockchainView, renderSingleBlockView } = require("../views/blockchain_view");
 const { renderBlockchainView, renderSingleBlockView } = require("../views/blockchain_view");
+const { jobsView, singleJobsView, renderJobForm } = require("../views/jobs_view");
 
 
 let sharp;
 let sharp;
 
 
@@ -528,7 +531,7 @@ router
     'popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'invites', 'wallet', 
     'popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'invites', 'wallet', 
     'legacy', 'cipher', 'bookmarks', 'videos', 'docs', 'audios', 'tags', 'images', 'trending', 
     'legacy', 'cipher', 'bookmarks', 'videos', 'docs', 'audios', 'tags', 'images', 'trending', 
     'events', 'tasks', 'market', 'tribes', 'governance', 'reports', 'opinions', 'transfers', 
     'events', 'tasks', 'market', 'tribes', 'governance', 'reports', 'opinions', 'transfers', 
-    'feed', 'pixelia', 'agenda', 'ai', 'forum'
+    'feed', 'pixelia', 'agenda', 'ai', 'forum', 'jobs'
     ];
     ];
     const moduleStates = modules.reduce((acc, mod) => {
     const moduleStates = modules.reduce((acc, mod) => {
       acc[`${mod}Mod`] = configMods[`${mod}Mod`];
       acc[`${mod}Mod`] = configMods[`${mod}Mod`];
@@ -677,7 +680,7 @@ router
       return acc;
       return acc;
     }, {});
     }, {});
     ctx.body = await searchView({ results: groupedResults, query, types: [] });
     ctx.body = await searchView({ results: groupedResults, query, types: [] });
-  })
+   })
   .get('/images', async (ctx) => {
   .get('/images', async (ctx) => {
     const imagesMod = ctx.cookies.get("imagesMod") || 'on';
     const imagesMod = ctx.cookies.get("imagesMod") || 'on';
     if (imagesMod !== 'on') {
     if (imagesMod !== 'on') {
@@ -688,16 +691,11 @@ router
     const images = await imagesModel.listAll(filter);
     const images = await imagesModel.listAll(filter);
     ctx.body = await imageView(images, filter, null);
     ctx.body = await imageView(images, filter, null);
    })
    })
-  .get('/images/edit/:id', async (ctx) => {
-    const imagesMod = ctx.cookies.get("imagesMod") || 'on';
-    if (imagesMod !== 'on') {
-      ctx.redirect('/modules');
-      return;
-    }
-    const filter = 'edit';
-    const img = await imagesModel.getImageById(ctx.params.id, false);
-    ctx.body = await imageView([img], filter, ctx.params.id);
-  })
+  .get('/images/edit/:id', async ctx => {
+    const imageId = ctx.params.id;
+    const images = await imagesModel.listAll('all');
+    ctx.body = await imageView(images, 'edit', imageId);
+   })
   .get('/images/:imageId', async ctx => {
   .get('/images/:imageId', async ctx => {
     const imageId = ctx.params.imageId;
     const imageId = ctx.params.imageId;
     const filter = ctx.query.filter || 'all'; 
     const filter = ctx.query.filter || 'all'; 
@@ -771,7 +769,8 @@ router
     ctx.body = await createCVView(cv, true)
     ctx.body = await createCVView(cv, true)
   })
   })
   .get('/pm', async ctx => {
   .get('/pm', async ctx => {
-    ctx.body = await pmView();
+    const { recipients = '' } = ctx.query;
+    ctx.body = await pmView(recipients);
   })
   })
   .get("/inbox", async (ctx) => {
   .get("/inbox", async (ctx) => {
     const inboxMod = ctx.cookies.get("inboxMod") || 'on';
     const inboxMod = ctx.cookies.get("inboxMod") || 'on';
@@ -832,19 +831,20 @@ router
       query.language = ctx.query.language || '';
       query.language = ctx.query.language || '';
       query.skills = ctx.query.skills || '';
       query.skills = ctx.query.skills || '';
     }
     }
+    const userId = SSBconfig.config.keys.id;
     const inhabitants = await inhabitantsModel.listInhabitants({
     const inhabitants = await inhabitantsModel.listInhabitants({
       filter,
       filter,
       ...query
       ...query
     });
     });
-
-    ctx.body = await inhabitantsView(inhabitants, filter, query);
+    ctx.body = await inhabitantsView(inhabitants, filter, query, userId);
   })
   })
   .get('/inhabitant/:id', async (ctx) => {
   .get('/inhabitant/:id', async (ctx) => {
     const id = ctx.params.id;
     const id = ctx.params.id;
-    const about = await inhabitantsModel._getLatestAboutById(id);
+    const about = await inhabitantsModel.getLatestAboutById(id);
     const cv = await inhabitantsModel.getCVByUserId(id);
     const cv = await inhabitantsModel.getCVByUserId(id);
     const feed = await inhabitantsModel.getFeedByUserId(id);
     const feed = await inhabitantsModel.getFeedByUserId(id);
-    ctx.body = await inhabitantsProfileView({ about, cv, feed });
+    const currentUserId = SSBconfig.config.keys.id;
+    ctx.body = await inhabitantsProfileView({ about, cv, feed }, currentUserId);
   })
   })
   .get('/tribes', async ctx => {
   .get('/tribes', async ctx => {
     const filter = ctx.query.filter || 'all';
     const filter = ctx.query.filter || 'all';
@@ -888,7 +888,8 @@ router
   .get('/activity', async ctx => {
   .get('/activity', async ctx => {
     const filter = ctx.query.filter || 'recent';
     const filter = ctx.query.filter || 'recent';
     const actions = await activityModel.listFeed(filter);
     const actions = await activityModel.listFeed(filter);
-    ctx.body = activityView(actions, filter);
+    const userId = SSBconfig.config.keys.id;
+    ctx.body = activityView(actions, filter, userId);
   })
   })
   .get("/profile", async (ctx) => {
   .get("/profile", async (ctx) => {
     const myFeedId = await meta.myFeedId();
     const myFeedId = await meta.myFeedId();
@@ -1212,35 +1213,75 @@ router
     ctx.body = await singleEventView(event, filter);
     ctx.body = await singleEventView(event, filter);
   })
   })
   .get('/votes', async ctx => {
   .get('/votes', async ctx => {
-      const filter = ctx.query.filter || 'all';
-      const voteList = await votesModel.listAll(filter);
-      ctx.body = await voteView(voteList, filter, null);
-   })
-   .get('/votes/:voteId', async ctx => {
-     const voteId = ctx.params.voteId;
-     const vote = await votesModel.getVoteById(voteId);
-     ctx.body = await voteView(vote);
+    const filter = ctx.query.filter || 'all';
+    const voteList = await votesModel.listAll(filter);
+    ctx.body = await voteView(voteList, filter, null);
    })
    })
   .get('/votes/edit/:id', async ctx => {
   .get('/votes/edit/:id', async ctx => {
-      const id = ctx.params.id;
-      const vote = await votesModel.getVoteById(id);
-      ctx.body = await voteView([vote], 'edit', id);
+    const id = ctx.params.id;
+    const vote = await votesModel.getVoteById(id);
+    ctx.body = await voteView([vote], 'edit', id);
+   })
+  .get('/votes/:voteId', async ctx => {
+    const voteId = ctx.params.voteId;
+    const vote = await votesModel.getVoteById(voteId);
+    ctx.body = await voteView(vote);
    })
    })
   .get('/market', async ctx => {
   .get('/market', async ctx => {
+    const marketMod = ctx.cookies.get("marketMod") || 'on';
+    if (marketMod !== 'on') {
+      ctx.redirect('/modules');
+      return;
+    }
     const filter = ctx.query.filter || 'all';
     const filter = ctx.query.filter || 'all';
     const marketItems = await marketModel.listAllItems(filter);
     const marketItems = await marketModel.listAllItems(filter);
     ctx.body = await marketView(marketItems, filter, null);
     ctx.body = await marketView(marketItems, filter, null);
-  })
+   })
   .get('/market/edit/:id', async ctx => {
   .get('/market/edit/:id', async ctx => {
     const id = ctx.params.id;
     const id = ctx.params.id;
     const marketItem = await marketModel.getItemById(id);
     const marketItem = await marketModel.getItemById(id);
     ctx.body = await marketView([marketItem], 'edit', marketItem);
     ctx.body = await marketView([marketItem], 'edit', marketItem);
-  })
+   })
   .get('/market/:itemId', async ctx => {
   .get('/market/:itemId', async ctx => {
     const itemId = ctx.params.itemId;
     const itemId = ctx.params.itemId;
     const filter = ctx.query.filter || 'all'; 
     const filter = ctx.query.filter || 'all'; 
     const item = await marketModel.getItemById(itemId); 
     const item = await marketModel.getItemById(itemId); 
     ctx.body = await singleMarketView(item, filter);
     ctx.body = await singleMarketView(item, filter);
+   })
+  .get('/jobs', async (ctx) => {
+    const jobsMod = ctx.cookies.get("jobsMod") || 'on';
+    if (jobsMod !== 'on') {
+      ctx.redirect('/modules');
+      return;
+    }
+    const filter = ctx.query.filter || 'ALL';
+    const query = {
+      search: ctx.query.search || '',
+    };
+    if (filter === 'CV') {
+      query.location = ctx.query.location || '';
+      query.language = ctx.query.language || '';
+      query.skills = ctx.query.skills || '';
+      const inhabitants = await inhabitantsModel.listInhabitants({ 
+        filter: 'CVs', 
+        ...query 
+      });
+      ctx.body = await jobsView(inhabitants, filter, query);
+      return;
+    }
+    const jobs = await jobsModel.listJobs(filter, ctx.state.user?.id, query);
+    ctx.body = await jobsView(jobs, filter, query);
+  })
+  .get('/jobs/edit/:id', async (ctx) => {
+    const id = ctx.params.id;
+    const job = await jobsModel.getJobById(id);
+    ctx.body = await jobsView([job], 'EDIT');
+  })
+  .get('/jobs/:jobId', async (ctx) => {
+    const jobId = ctx.params.jobId;
+    const filter = ctx.query.filter || 'ALL';
+    const job = await jobsModel.getJobById(jobId);
+    ctx.body = await singleJobsView(job, filter);
   })
   })
   .get('/cipher', async (ctx) => {
   .get('/cipher', async (ctx) => {
     const cipherMod = ctx.cookies.get("cipherMod") || 'on';
     const cipherMod = ctx.cookies.get("cipherMod") || 'on';
@@ -2022,7 +2063,9 @@ router
   .post('/tasks/update/:id', koaBody(), async (ctx) => {
   .post('/tasks/update/:id', koaBody(), async (ctx) => {
     const { title, description, startTime, endTime, priority, location, tags, isPublic } = ctx.request.body;
     const { title, description, startTime, endTime, priority, location, tags, isPublic } = ctx.request.body;
     const taskId = ctx.params.id;
     const taskId = ctx.params.id;
-    const task = await tasksModel.getTaskById(taskId);
+    const parsedTags = Array.isArray(tags)
+      ? tags.filter(Boolean)
+      : (typeof tags === 'string' ? tags.split(',').map(t => t.trim()).filter(Boolean) : []);
     await tasksModel.updateTaskById(taskId, {
     await tasksModel.updateTaskById(taskId, {
       title,
       title,
       description,
       description,
@@ -2030,13 +2073,11 @@ router
       endTime,
       endTime,
       priority,
       priority,
       location,
       location,
-      tags,
-      isPublic,
-      createdAt: task.createdAt,
-      author: task.author
+      tags: parsedTags,
+      isPublic
     });
     });
     ctx.redirect('/tasks?filter=mine');
     ctx.redirect('/tasks?filter=mine');
-   })
+  })
   .post('/tasks/assign/:id', koaBody(), async (ctx) => {
   .post('/tasks/assign/:id', koaBody(), async (ctx) => {
     const taskId = ctx.params.id;
     const taskId = ctx.params.id;
     await tasksModel.toggleAssignee(taskId);
     await tasksModel.toggleAssignee(taskId);
@@ -2116,16 +2157,21 @@ router
     ctx.redirect('/events?filter=mine');
     ctx.redirect('/events?filter=mine');
   })
   })
   .post('/votes/create', koaBody(), async ctx => {
   .post('/votes/create', koaBody(), async ctx => {
-    const { question, deadline, options = 'YES,NO,ABSTENTION', tags = '' } = ctx.request.body;
-    const parsedOptions = options.split(',').map(o => o.trim()).filter(Boolean);
+    const { question, deadline, options, tags = '' } = ctx.request.body;
+    const defaultOptions = ['YES', 'NO', 'ABSTENTION', 'CONFUSED', 'FOLLOW_MAJORITY', 'NOT_INTERESTED'];
+    const parsedOptions = options
+      ? options.split(',').map(o => o.trim()).filter(Boolean)
+      : defaultOptions;
     const parsedTags = tags.split(',').map(t => t.trim()).filter(Boolean);
     const parsedTags = tags.split(',').map(t => t.trim()).filter(Boolean);
     await votesModel.createVote(question, deadline, parsedOptions, parsedTags);
     await votesModel.createVote(question, deadline, parsedOptions, parsedTags);
     ctx.redirect('/votes');
     ctx.redirect('/votes');
-    })
+  })
   .post('/votes/update/:id', koaBody(), async ctx => {
   .post('/votes/update/:id', koaBody(), async ctx => {
     const id = ctx.params.id;
     const id = ctx.params.id;
-    const { question, deadline, options = 'YES,NO,ABSTENTION', tags = '' } = ctx.request.body;
-    const parsedOptions = options.split(',').map(o => o.trim()).filter(Boolean);
+    const { question, deadline, options, tags = '' } = ctx.request.body;
+    const parsedOptions = options
+      ? options.split(',').map(o => o.trim()).filter(Boolean)
+      : undefined;
     const parsedTags = tags ? tags.split(',').map(t => t.trim()).filter(Boolean) : [];
     const parsedTags = tags ? tags.split(',').map(t => t.trim()).filter(Boolean) : [];
     await votesModel.updateVoteById(id, { question, deadline, options: parsedOptions, tags: parsedTags });
     await votesModel.updateVoteById(id, { question, deadline, options: parsedOptions, tags: parsedTags });
     ctx.redirect('/votes?filter=mine');
     ctx.redirect('/votes?filter=mine');
@@ -2150,7 +2196,7 @@ router
       ctx.redirect('/votes');
       ctx.redirect('/votes');
       return;
       return;
     }
     }
-    await opinionsModel.createVote(voteId, category, 'votes');
+    await votesModel.createOpinion(voteId, category);
     ctx.redirect('/votes');
     ctx.redirect('/votes');
   })
   })
   .post('/market/create', koaBody({ multipart: true }), async ctx => {
   .post('/market/create', koaBody({ multipart: true }), async ctx => {
@@ -2220,6 +2266,7 @@ router
   })
   })
   .post('/market/bid/:id', koaBody(), async ctx => {
   .post('/market/bid/:id', koaBody(), async ctx => {
     const id = ctx.params.id;
     const id = ctx.params.id;
+    const userId = SSBconfig.config.keys.id;
     const { bidAmount } = ctx.request.body;
     const { bidAmount } = ctx.request.body;
     const marketItem = await marketModel.getItemById(id);
     const marketItem = await marketModel.getItemById(id);
     await marketModel.addBidToAuction(id, userId, bidAmount);
     await marketModel.addBidToAuction(id, userId, bidAmount);
@@ -2228,7 +2275,91 @@ router
     }
     }
     ctx.redirect('/market?filter=auctions');
     ctx.redirect('/market?filter=auctions');
   })
   })
-
+  .post('/jobs/create', koaBody({ multipart: true }), async (ctx) => {
+   const {
+      job_type,
+      title,
+      description,
+      requirements,
+      languages,
+      job_time,
+      tasks,
+      location,
+      vacants,
+      salary
+    } = ctx.request.body;
+    const imageBlob = ctx.request.files?.image
+      ? await handleBlobUpload(ctx, 'image')
+      : null;
+    await jobsModel.createJob({
+      job_type,
+      title,
+      description,
+      requirements,
+      languages,
+      job_time,
+      tasks,
+      location,
+      vacants: vacants ? parseInt(vacants, 10) : 1,
+      salary: salary != null ? parseFloat(salary) : 0,
+      image: imageBlob
+    });
+    ctx.redirect('/jobs?filter=MINE');
+  })
+  .post('/jobs/update/:id', koaBody({ multipart: true }), async (ctx) => {
+    const id = ctx.params.id;
+    const {
+      job_type,
+      title,
+      description,
+      requirements,
+      languages,
+      job_time,
+      tasks,
+      location,
+      vacants,
+      salary
+    } = ctx.request.body;
+    const imageBlob = ctx.request.files?.image
+      ? await handleBlobUpload(ctx, 'image')
+      : undefined;
+    await jobsModel.updateJob(id, {
+      job_type,
+      title,
+      description,
+      requirements,
+      languages,
+      job_time,
+      tasks,
+      location,
+      vacants: vacants ? parseInt(vacants, 10) : undefined,
+      salary: salary != null && salary !== '' ? parseFloat(salary) : undefined,
+      image: imageBlob
+    });
+    ctx.redirect('/jobs?filter=MINE');
+  })
+  .post('/jobs/delete/:id', koaBody(), async (ctx) => {
+    const id = ctx.params.id;
+    await jobsModel.deleteJob(id);
+    ctx.redirect('/jobs?filter=MINE');
+  })
+  .post('/jobs/status/:id', koaBody(), async (ctx) => {
+    const id = ctx.params.id;
+    const { status } = ctx.request.body;
+    await jobsModel.updateJobStatus(id, String(status).toUpperCase());
+    ctx.redirect('/jobs?filter=MINE');
+  })
+  .post('/jobs/subscribe/:id', koaBody(), async (ctx) => {
+    const id = ctx.params.id;
+    await jobsModel.subscribeToJob(id, config.keys.id);
+    ctx.redirect('/jobs');
+  })
+  .post('/jobs/unsubscribe/:id', koaBody(), async (ctx) => {
+    const id = ctx.params.id;
+    await jobsModel.unsubscribeFromJob(id, config.keys.id);
+    ctx.redirect('/jobs');
+  })
+  
   // UPDATE OASIS
   // UPDATE OASIS
   .post("/update", koaBody(), async (ctx) => {
   .post("/update", koaBody(), async (ctx) => {
     const util = require("node:util");
     const util = require("node:util");
@@ -2246,23 +2377,15 @@ router
     await updateTool();
     await updateTool();
     const referer = new URL(ctx.request.header.referer);
     const referer = new URL(ctx.request.header.referer);
     ctx.redirect(referer.href);
     ctx.redirect(referer.href);
-  }) 
+  })  
   .post("/settings/theme", koaBody(), async (ctx) => {
   .post("/settings/theme", koaBody(), async (ctx) => {
-    const theme = String(ctx.request.body.theme);
+    const theme = String(ctx.request.body.theme || "").trim();
     const currentConfig = getConfig();
     const currentConfig = getConfig();
-    if (theme) {
-        currentConfig.themes.current = theme;
-        const configPath = path.join(__dirname, '../configs', 'oasis-config.json');
-        fs.writeFileSync(configPath, JSON.stringify(currentConfig, null, 2));
-        ctx.cookies.set("theme", theme);
-        ctx.redirect("/settings");
-    } else {
-        currentConfig.themes.current = "Dark-SNH";
-        fs.writeFileSync(path.join(__dirname, 'configs', 'oasis-config.json'), JSON.stringify(currentConfig, null, 2));
-        ctx.cookies.set("theme", "Dark-SNH");
-        ctx.redirect("/settings");
-     }
-   })
+    currentConfig.themes.current = theme || "Dark-SNH";
+    fs.writeFileSync(configPath, JSON.stringify(currentConfig, null, 2));
+    ctx.cookies.set("theme", currentConfig.themes.current);
+    ctx.redirect("/settings");
+  })
   .post("/language", koaBody(), async (ctx) => {
   .post("/language", koaBody(), async (ctx) => {
     const language = String(ctx.request.body.language);
     const language = String(ctx.request.body.language);
     ctx.cookies.set("language", language);
     ctx.cookies.set("language", language);
@@ -2293,6 +2416,17 @@ router
     }
     }
     ctx.redirect("/invites");
     ctx.redirect("/invites");
   })
   })
+  .post("/settings/ssb-logstream", koaBody(), async (ctx) => {
+    const logLimit = parseInt(ctx.request.body.ssb_log_limit, 10);
+    if (!isNaN(logLimit) && logLimit > 0 && logLimit <= 100000) {
+      const configData = fs.readFileSync(configPath, 'utf8');
+      const config = JSON.parse(configData);
+      if (!config.ssbLogStream) config.ssbLogStream = {};
+      config.ssbLogStream.limit = logLimit;
+      fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
+    }
+    ctx.redirect("/settings");
+  })
   .post("/settings/rebuild", async (ctx) => {
   .post("/settings/rebuild", async (ctx) => {
     meta.rebuild();
     meta.rebuild();
     ctx.redirect("/settings");
     ctx.redirect("/settings");
@@ -2302,7 +2436,7 @@ router
     'popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'invites', 'wallet',
     'popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'invites', 'wallet',
     'legacy', 'cipher', 'bookmarks', 'videos', 'docs', 'audios', 'tags', 'images', 'trending',
     'legacy', 'cipher', 'bookmarks', 'videos', 'docs', 'audios', 'tags', 'images', 'trending',
     'events', 'tasks', 'market', 'tribes', 'governance', 'reports', 'opinions', 'transfers',
     'events', 'tasks', 'market', 'tribes', 'governance', 'reports', 'opinions', 'transfers',
-    'feed', 'pixelia', 'agenda', 'ai', 'forum'
+    'feed', 'pixelia', 'agenda', 'ai', 'forum', 'jobs'
     ];
     ];
     const currentConfig = getConfig();
     const currentConfig = getConfig();
     modules.forEach(mod => {
     modules.forEach(mod => {

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

@@ -1236,12 +1236,24 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
     margin-top: 10px;
     margin-top: 10px;
 }
 }
 
 
+.job-actions {
+    display: flex;
+    gap: 10px;
+    margin-top: 10px;
+}
+
 .tribe-actions {
 .tribe-actions {
     display: flex;
     display: flex;
     gap: 10px;
     gap: 10px;
     margin-top: 10px;
     margin-top: 10px;
 }
 }
 
 
+.pm-actions{
+    display: flex;
+    gap: 10px;
+    margin-top: 10px;
+}
+
 .audio-actions {
 .audio-actions {
     display: flex;
     display: flex;
     gap: 10px;
     gap: 10px;
@@ -2081,3 +2093,26 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
     gap: 18px;
     gap: 18px;
   }
   }
 }
 }
+
+/*inbox/pm*/
+.pm-card {
+  border-radius: 10px;
+  margin-bottom: 24px;
+  background: #262626;
+  box-shadow: 0 1px 6px rgba(0,0,0,0.16);
+  padding: 22px 28px;
+}
+
+.pm-header {
+  display: flex;
+  align-items: center;
+  gap: 18px;
+  margin-bottom: 12px;
+  font-size: 1em;
+  color: #ffd54f;
+}
+
+.pm-title {
+  font-weight: bold;
+  margin-bottom: 8px;
+}

+ 96 - 14
src/client/assets/translations/oasis_en.js

@@ -197,6 +197,9 @@ module.exports = {
     legacyDescription: "Manage your secret (private key) quickly and safely.",   
     legacyDescription: "Manage your secret (private key) quickly and safely.",   
     legacyExportButton: "Export",
     legacyExportButton: "Export",
     legacyImportButton: "Import",
     legacyImportButton: "Import",
+    ssbLogStream: "Blokchain",
+    ssbLogStreamDescription: "Configure the message limit for Blockchain streams.",
+    saveSettings: "Save Settings",
     exportTitle: "Export data",
     exportTitle: "Export data",
     exportDescription: "Set password (min 32 characters long) to encrypt your key",
     exportDescription: "Set password (min 32 characters long) to encrypt your key",
     exportDataTitle: "Backup",
     exportDataTitle: "Backup",
@@ -943,7 +946,7 @@ module.exports = {
     imageFilterMine: "MINE",
     imageFilterMine: "MINE",
     imageCreateButton: "Upload Image",
     imageCreateButton: "Upload Image",
     imageEditDescription: "Edit your image details.",
     imageEditDescription: "Edit your image details.",
-    imageCreateDescription: "Create a new image.",
+    imageCreateDescription: "Create Image.",
     imageTagsLabel: "Tags",
     imageTagsLabel: "Tags",
     imageTagsPlaceholder: "Enter tags separated by commas",
     imageTagsPlaceholder: "Enter tags separated by commas",
     imageUpdateButton: "Update",
     imageUpdateButton: "Update",
@@ -1019,27 +1022,29 @@ module.exports = {
     playVideo:            "Play video",
     playVideo:            "Play video",
     typeRecent:           "RECENT",
     typeRecent:           "RECENT",
     errorActivity:        "Error retrieving activity",
     errorActivity:        "Error retrieving activity",
-    typePost:             "POSTS",
-    typeTribe:            "TRIBES",
-    typeAbout:            "INHABITANTS",
+    typePost:             "POST",
+    typeTribe:            "TRIBE",
+    typeAbout:            "INHABITANT",
     typeCurriculum:       "CV",
     typeCurriculum:       "CV",
-    typeImage:            "IMAGES",
-    typeBookmark:         "BOOKMARKS",
-    typeDocument:         "DOCUMENTS",
+    typeImage:            "IMAGE",
+    typeBookmark:         "BOOKMARK",
+    typeDocument:         "DOCUMENT",
     typeVotes:            "GOVERNANCE",
     typeVotes:            "GOVERNANCE",
-    typeAudio:            "AUDIOS",
+    typeAudio:            "AUDIO",
     typeMarket:           "MARKET",
     typeMarket:           "MARKET",
-    typeVideo:            "VIDEOS",
+    typeJob:              "JOB",
+    typeVideo:            "VIDEO",
     typeVote:             "SPREAD",
     typeVote:             "SPREAD",
-    typeEvent:            "EVENTS",
-    typeTransfer:         "TRANSFERS",
+    typeEvent:            "EVENT",
+    typeTransfer:         "TRANSFER",
     typeTask:             "TASKS",
     typeTask:             "TASKS",
     typePixelia: 	  "PIXELIA",
     typePixelia: 	  "PIXELIA",
-    typeForum: 	          "FORUMS",
-    typeReport:           "REPORTS",
+    typeForum: 	          "FORUM",
+    typeReport:           "REPORT",
     typeFeed:             "FEED",
     typeFeed:             "FEED",
     typeContact:          "CONTACT",
     typeContact:          "CONTACT",
     typePub:              "PUB",
     typePub:              "PUB",
+    typeTombstone:	  "TOMBSTONE",
     activitySupport:      "New alliance forged",
     activitySupport:      "New alliance forged",
     activityJoin:         "New PUB joined",
     activityJoin:         "New PUB joined",
     question:             "Question",
     question:             "Question",
@@ -1196,6 +1201,7 @@ module.exports = {
     agendaFilterEvents: "EVENTS",
     agendaFilterEvents: "EVENTS",
     agendaFilterReports: "REPORTS",
     agendaFilterReports: "REPORTS",
     agendaFilterTransfers: "TRANSFERS",
     agendaFilterTransfers: "TRANSFERS",
+    agendaFilterJobs: "JOBS",
     agendaNoItems: "No assignments found.",
     agendaNoItems: "No assignments found.",
     agendaAuthor: "By",
     agendaAuthor: "By",
     agendaDiscardButton: "Discard",
     agendaDiscardButton: "Discard",
@@ -1298,6 +1304,7 @@ module.exports = {
     blockchainBlockInfo: 'Block Information',
     blockchainBlockInfo: 'Block Information',
     blockchainBlockDetails: 'Details of the selected block',
     blockchainBlockDetails: 'Details of the selected block',
     blockchainBack: 'Back to Blockexplorer',
     blockchainBack: 'Back to Blockexplorer',
+    blockchainContentDeleted: "This content has been tombstoned",
     visitContent: "Visit Content",
     visitContent: "Visit Content",
     //stats
     //stats
     statsTitle: 'Statistics',
     statsTitle: 'Statistics',
@@ -1318,14 +1325,22 @@ module.exports = {
     statsDiscoveredTribes: "Tribes",
     statsDiscoveredTribes: "Tribes",
     statsNetworkContent: "Content",
     statsNetworkContent: "Content",
     statsYourMarket: "Market",
     statsYourMarket: "Market",
+    statsYourJob: "Jobs",
+    statsYourTransfer: "Transfers",
+    statsYourForum: "Forums",   
     statsNetworkOpinions: "Opinions",
     statsNetworkOpinions: "Opinions",
     statsDiscoveredMarket: "Market",
     statsDiscoveredMarket: "Market",
+    statsDiscoveredJob: "Jobs",
+    statsDiscoveredTransfer: "Transfers",
+    statsDiscoveredForum: "Forums",
     statsNetworkTombstone: "Tombstones",
     statsNetworkTombstone: "Tombstones",
     statsBookmark: "Bookmarks",
     statsBookmark: "Bookmarks",
     statsEvent: "Events",
     statsEvent: "Events",
     statsTask: "Tasks",
     statsTask: "Tasks",
     statsVotes: "Votes",
     statsVotes: "Votes",
     statsMarket: "Market",
     statsMarket: "Market",
+    statsForum: "Forums",
+    statsJob: "Jobs",
     statsReport: "Reports",
     statsReport: "Reports",
     statsFeed: "Feeds",
     statsFeed: "Feeds",
     statsTribe: "Tribes",
     statsTribe: "Tribes",
@@ -1354,7 +1369,7 @@ module.exports = {
     aiClearHistory: "Clear chat history",
     aiClearHistory: "Clear chat history",
     //market
     //market
     marketMineSectionTitle: "Your Items",
     marketMineSectionTitle: "Your Items",
-    marketCreateSectionTitle: "Create a New Item",
+    marketCreateSectionTitle: "Create Item",
     marketUpdateSectionTitle: "Update",
     marketUpdateSectionTitle: "Update",
     marketAllSectionTitle: "Market",
     marketAllSectionTitle: "Market",
     marketRecentSectionTitle: "Recent Market",
     marketRecentSectionTitle: "Recent Market",
@@ -1400,6 +1415,71 @@ module.exports = {
     marketNoItems: "No items available, yet.",
     marketNoItems: "No items available, yet.",
     marketYourBid: "Your Bid",
     marketYourBid: "Your Bid",
     marketCreateFormImageLabel: "Upload Image (jpeg, jpg, png, gif) (max-size: 500px x 400px)",    
     marketCreateFormImageLabel: "Upload Image (jpeg, jpg, png, gif) (max-size: 500px x 400px)",    
+    //jobs
+    jobsTitle: "Jobs",
+    jobsDescription: "Discover and manage jobs in your network.",
+    jobsFilterRecent: "RECENT",
+    jobsFilterMine: "MINE",
+    jobsFilterAll: "ALL",
+    jobsFilterRemote: "REMOTE",
+    jobsFilterOpen: "OPEN",
+    jobsFilterClosed: "CLOSED",
+    jobsCV: "CVs",
+    jobsCreateJob: "Create Job",
+    jobsRecentTitle: "Recent Jobs",
+    jobsMineTitle: "Your Jobs",
+    jobsAllTitle: "Jobs",
+    jobsRemoteTitle: "Remote Jobs",
+    jobsOpenTitle: "Open Jobs",
+    jobsClosedTitle: "Closed Jobs",
+    jobsCVTitle: "CVs",
+    jobsFilterPresencial: "PRESENCIAL",
+    jobsFilterFreelancer: "FREELANCER",
+    jobsFilterEmployee:   "EMPLOYEE",
+    jobsPresencialTitle:  "Presential Jobs",
+    jobsFreelancerTitle:  "Freelance Jobs",
+    jobsEmployeeTitle:    "Employee Jobs",
+    jobTitle: "Title",
+    jobLocation: "Location",
+    jobSalary: "Salary (1h)",
+    jobVacants: "Vacants",
+    jobDescription: "Description",
+    jobRequirements: "Requirements",
+    jobLanguages: "Languages",
+    jobStatus: "Status",
+    jobStatusOPEN: "OPEN",
+    jobStatusCLOSED: "CLOSED",
+    jobSetOpen: "Set as OPEN",
+    jobSetClosed: "Set as CLOSED",
+    jobSubscribeButton: "Join this offer!",
+    jobUnsubscribeButton: "Leave this offer!",
+    jobTitlePlaceholder: "Enter job title",
+    jobDescriptionPlaceholder: "Describe the job",
+    jobRequirementsPlaceholder: "Enter requirements",
+    jobLanguagesPlaceholder: "English, Spanish, Basque",
+    jobTasksPlaceholder: "List tasks",
+    jobLocationPresencial: "On-place",
+    jobLocationRemote: "Remote",
+    jobVacantsPlaceholder: "Number of positions",
+    jobSalaryPlaceholder: "Salary amount (1h)",
+    jobImage: "Upload Image (jpeg, jpg, png, gif) (max-size: 500px x 400px)",
+    jobTasks: "Tasks",
+    jobType: "Job Type",
+    jobTime: "Job Time",
+    jobSubscribers:  "Subscribers",
+    noSubscribers:   "No subscribers",
+    jobsFilterTop: "TOP",
+    jobsTopTitle: "Top Salary Jobs",
+    createJobButton: "Publish Job",
+    viewDetailsButton: "View Details",
+    noJobsFound: "No job offers found.",
+    jobAuthor: "By",
+    jobTypeFreelance: "Freelancer",
+    jobTypeSalary: "Employee",
+    jobTimePartial: "Part-time",
+    jobTimeComplete: "Full-time",
+    jobsDeleteButton: "DELETE",
+    jobsUpdateButton: "UPDATE",
     //modules
     //modules
     modulesModuleName: "Name",
     modulesModuleName: "Name",
     modulesModuleDescription: "Description",
     modulesModuleDescription: "Description",
@@ -1467,6 +1547,8 @@ module.exports = {
     modulesAIDescription: "Module to talk with a LLM called '42'.",
     modulesAIDescription: "Module to talk with a LLM called '42'.",
     modulesForumLabel: "Forums",
     modulesForumLabel: "Forums",
     modulesForumDescription: "Module to discover and manage forums.",
     modulesForumDescription: "Module to discover and manage forums.",
+    modulesJobsLabel: "Jobs",
+    modulesJobsDescription: "Module to discover and manage jobs.",
      
      
      //END
      //END
     }
     }

+ 101 - 20
src/client/assets/translations/oasis_es.js

@@ -197,6 +197,9 @@ module.exports = {
     legacyDescription: "Maneja tu secreto (llave privada) de forma rápida y segura.",   
     legacyDescription: "Maneja tu secreto (llave privada) de forma rápida y segura.",   
     legacyExportButton: "Exportar",
     legacyExportButton: "Exportar",
     legacyImportButton: "Importar",
     legacyImportButton: "Importar",
+    ssbLogStream: "Blockchain",
+    ssbLogStreamDescription: "Configura el límite de mensajes para los flujos de la blockchain.",
+    saveSettings: "Guardar configuración",
     exportTitle: "Exportar datos",
     exportTitle: "Exportar datos",
     exportDescription: "Establece una contraseña (min 32 caracteres de longitud) para cifrar tu secreto",
     exportDescription: "Establece una contraseña (min 32 caracteres de longitud) para cifrar tu secreto",
     exportDataTitle: "Backup",
     exportDataTitle: "Backup",
@@ -421,7 +424,7 @@ module.exports = {
     cipherDecryptDescription: "Introduce el texto para desencriptar",
     cipherDecryptDescription: "Introduce el texto para desencriptar",
     cipherEncryptedMessageLabel: "Texto Encriptado",
     cipherEncryptedMessageLabel: "Texto Encriptado",
     cipherDecryptedMessageLabel: "Texto Desencriptado",
     cipherDecryptedMessageLabel: "Texto Desencriptado",
-    cipherPasswordUsedLabel: "Contraseña usada para encriptar (¡guárdala!)",
+    cipherPasswordUsedLabel: "Contraseña usada para encriptar (guárdala!)",
     cipherEncryptedTextPlaceholder: "Introduce el texto encriptado...",
     cipherEncryptedTextPlaceholder: "Introduce el texto encriptado...",
     cipherIvLabel: "IV",
     cipherIvLabel: "IV",
     cipherIvPlaceholder: "Introduce el vector de inicialización...",
     cipherIvPlaceholder: "Introduce el vector de inicialización...",
@@ -453,7 +456,7 @@ module.exports = {
     bookmarkCreateButton: "Crear Marcador",
     bookmarkCreateButton: "Crear Marcador",
     existingbookmarksTitle: "Marcadores Existentes",
     existingbookmarksTitle: "Marcadores Existentes",
     nobookmarks: "No hay marcadores disponibles.",
     nobookmarks: "No hay marcadores disponibles.",
-    newbookmarkSuccess: "¡Nuevo marcador creado con éxito!",
+    newbookmarkSuccess: "Nuevo marcador creado con éxito!",
     bookmarkFilterAll: "TODOS",
     bookmarkFilterAll: "TODOS",
     bookmarkFilterMine: "MIOS",
     bookmarkFilterMine: "MIOS",
     bookmarkUpdateButton: "Actualizar",
     bookmarkUpdateButton: "Actualizar",
@@ -942,7 +945,7 @@ module.exports = {
     imageFilterMine: "MIAS",
     imageFilterMine: "MIAS",
     imageCreateButton: "Subir Imagen",
     imageCreateButton: "Subir Imagen",
     imageEditDescription: "Edita los detalles de tu imagen.",
     imageEditDescription: "Edita los detalles de tu imagen.",
-    imageCreateDescription: "Crea una nueva imagen.",
+    imageCreateDescription: "Crea una imagen.",
     imageTagsLabel: "Etiquetas",
     imageTagsLabel: "Etiquetas",
     imageTagsPlaceholder: "Introduce etiquetas separadas por comas",
     imageTagsPlaceholder: "Introduce etiquetas separadas por comas",
     imageUpdateButton: "Actualizar",
     imageUpdateButton: "Actualizar",
@@ -968,7 +971,7 @@ module.exports = {
     //feed
     //feed
     feedTitle:        "Feed",
     feedTitle:        "Feed",
     createFeedTitle:  "Crear Feed",
     createFeedTitle:  "Crear Feed",
-    createFeedButton: "¡Enviar Feed!",
+    createFeedButton: "Enviar Feed!",
     feedPlaceholder:  "¿Qué está pasando? (máximo 280 caracteres)",
     feedPlaceholder:  "¿Qué está pasando? (máximo 280 caracteres)",
     ALLButton:        "Feeds",
     ALLButton:        "Feeds",
     MINEButton:       "Tus Feeds",
     MINEButton:       "Tus Feeds",
@@ -1018,24 +1021,25 @@ module.exports = {
     playVideo:            "Reproducir video",
     playVideo:            "Reproducir video",
     typeRecent:           "RECIENTES",
     typeRecent:           "RECIENTES",
     errorActivity:        "Error al recuperar la actividad",
     errorActivity:        "Error al recuperar la actividad",
-    typePost:             "PUBLICACIONES",
+    typePost:             "PUBLICACIÓN",
     typeTribe:            "TRIBUS",
     typeTribe:            "TRIBUS",
-    typeAbout:            "HABITANTES",
+    typeAbout:            "HABITANTE",
     typeCurriculum:       "CV",
     typeCurriculum:       "CV",
-    typeImage:            "IMÁGENES",
-    typeBookmark:         "MARCADORES",
-    typeDocument:         "DOCUMENTOS",
+    typeImage:            "IMÁGEN",
+    typeBookmark:         "MARCADOR",
+    typeDocument:         "DOCUMENTO",
     typeVotes:            "GOBIERNO",
     typeVotes:            "GOBIERNO",
-    typeAudio:            "AUDIOS",
+    typeAudio:            "AUDIO",
     typeMarket:           "MERCADO",
     typeMarket:           "MERCADO",
-    typeVideo:            "VIDEOS",
+    typeJob:              "TRABAJO",
+    typeVideo:            "VIDEO",
     typeVote:             "PROPAGACIÓN",
     typeVote:             "PROPAGACIÓN",
-    typeEvent:            "EVENTOS",
-    typeTransfer:         "TRANSFERENCIAS",
-    typeTask:             "TAREAS",
+    typeEvent:            "EVENTO",
+    typeTransfer:         "TRANSFERENCIA",
+    typeTask:             "TAREA",
     typePixelia:          "PIXELIA",
     typePixelia:          "PIXELIA",
-    typeForum: 	          "FOROS",
-    typeReport:           "INFORMES",
+    typeForum: 	          "FORO",
+    typeReport:           "REPORT",
     typeFeed:             "FEED",
     typeFeed:             "FEED",
     typeContact:          "CONTACTO",
     typeContact:          "CONTACTO",
     typePub:              "PUB",
     typePub:              "PUB",
@@ -1114,7 +1118,7 @@ module.exports = {
     reportsUpdateStatusButton: "Actualizar Estado",
     reportsUpdateStatusButton: "Actualizar Estado",
     reportsAnonymityOption: "Enviar de forma anónima",
     reportsAnonymityOption: "Enviar de forma anónima",
     reportsAnonymousAuthor: "Anónimo",
     reportsAnonymousAuthor: "Anónimo",
-    reportsConfirmButton: "¡CONFIRMAR INFORME!",
+    reportsConfirmButton: "CONFIRMAR INFORME!",
     reportsConfirmations: "Confirmaciones",
     reportsConfirmations: "Confirmaciones",
     reportsConfirmedSectionTitle: "Informes Confirmados",
     reportsConfirmedSectionTitle: "Informes Confirmados",
     reportsCreateTaskButton: "CREAR TAREA",
     reportsCreateTaskButton: "CREAR TAREA",
@@ -1195,6 +1199,7 @@ module.exports = {
     agendaFilterEvents: "EVENTOS",
     agendaFilterEvents: "EVENTOS",
     agendaFilterReports: "INFORMES",
     agendaFilterReports: "INFORMES",
     agendaFilterTransfers: "TRANSFERENCIAS",
     agendaFilterTransfers: "TRANSFERENCIAS",
+    agendaFilterJobs: "TRABAJOS",
     agendaNoItems: "No se encontraron asignaciones.",
     agendaNoItems: "No se encontraron asignaciones.",
     agendaDiscardButton: "Descartar",
     agendaDiscardButton: "Descartar",
     agendaRestoreButton: "Restaurar",
     agendaRestoreButton: "Restaurar",
@@ -1297,6 +1302,7 @@ module.exports = {
     blockchainBlockInfo: 'Información del bloque',
     blockchainBlockInfo: 'Información del bloque',
     blockchainBlockDetails: 'Detalles del bloque seleccionado', 
     blockchainBlockDetails: 'Detalles del bloque seleccionado', 
     blockchainBack: 'Volver al explorador de bloques',
     blockchainBack: 'Volver al explorador de bloques',
+    blockchainContentDeleted: "Este contenido ha sido eliminado",
     visitContent: 'Visitar Contenido',
     visitContent: 'Visitar Contenido',
     //stats
     //stats
     statsTitle: 'Estadísticas',
     statsTitle: 'Estadísticas',
@@ -1317,14 +1323,22 @@ module.exports = {
     statsDiscoveredTribes: "Tribus",
     statsDiscoveredTribes: "Tribus",
     statsNetworkContent: "Contenido",
     statsNetworkContent: "Contenido",
     statsYourMarket: "Mercado",
     statsYourMarket: "Mercado",
+    statsYourJob: "Trabajos",
+    statsYourTransfer:     "Transferencias",
+    statsYourForum:        "Foros",   
     statsNetworkOpinions: "Opiniones",
     statsNetworkOpinions: "Opiniones",
     statsDiscoveredMarket: "Mercado",
     statsDiscoveredMarket: "Mercado",
+    statsDiscoveredJob: "Trabajos",
+    statsDiscoveredTransfer: "Transferencias",
+    statsDiscoveredForum: "Foros",
     statsNetworkTombstone: "Lápidas",
     statsNetworkTombstone: "Lápidas",
     statsBookmark: "Marcadores",
     statsBookmark: "Marcadores",
     statsEvent: "Eventos",
     statsEvent: "Eventos",
     statsTask: "Tareas",
     statsTask: "Tareas",
     statsVotes: "Votos",
     statsVotes: "Votos",
     statsMarket: "Mercado",
     statsMarket: "Mercado",
+    statsForum: "Foros",
+    statsJob: "Trabajos",
     statsReport: "Informes",
     statsReport: "Informes",
     statsFeed: "Feeds",
     statsFeed: "Feeds",
     statsTribe: "Tribus",
     statsTribe: "Tribus",
@@ -1353,7 +1367,7 @@ module.exports = {
     aiClearHistory: "Borrar historial de chat",
     aiClearHistory: "Borrar historial de chat",
     //market
     //market
     marketMineSectionTitle: "Tus Artículos",
     marketMineSectionTitle: "Tus Artículos",
-    marketCreateSectionTitle: "Crear un Nuevo Artículo",
+    marketCreateSectionTitle: "Crear un Artículo",
     marketUpdateSectionTitle: "Actualizar",
     marketUpdateSectionTitle: "Actualizar",
     marketAllSectionTitle: "Mercado",
     marketAllSectionTitle: "Mercado",
     marketRecentSectionTitle: "Mercado Reciente",
     marketRecentSectionTitle: "Mercado Reciente",
@@ -1389,16 +1403,81 @@ module.exports = {
     marketOutOfStock: "Sin existencias",
     marketOutOfStock: "Sin existencias",
     marketItemBidTime: "Tiempo de Oferta",
     marketItemBidTime: "Tiempo de Oferta",
     marketActionsUpdate: "Actualizar",
     marketActionsUpdate: "Actualizar",
-    marketUpdateButton: "¡Actualizar Artículo!",
+    marketUpdateButton: "Actualizar Artículo!",
     marketActionsDelete: "Eliminar",
     marketActionsDelete: "Eliminar",
     marketActionsSold: "Marcar como Vendido",
     marketActionsSold: "Marcar como Vendido",
-    marketActionsBuy: "¡COMPRAR!",
+    marketActionsBuy: "COMPRAR!",
     marketAuctionBids: "Ofertas Actuales",
     marketAuctionBids: "Ofertas Actuales",
     marketPlaceBidButton: "Hacer Oferta",
     marketPlaceBidButton: "Hacer Oferta",
     marketItemSeller: "Vendedor",
     marketItemSeller: "Vendedor",
     marketNoItems: "No hay artículos disponibles, aún.",
     marketNoItems: "No hay artículos disponibles, aún.",
     marketYourBid: "Tu Oferta",
     marketYourBid: "Tu Oferta",
     marketCreateFormImageLabel: "Subir Imagen (jpeg, jpg, png, gif) (tamaño máximo: 500px x 400px)",
     marketCreateFormImageLabel: "Subir Imagen (jpeg, jpg, png, gif) (tamaño máximo: 500px x 400px)",
+    //jobs
+    jobsTitle: "Ofertas de trabajo",
+    jobsDescription: "Descubre y gestiona ofertas de trabajo en tu red.",
+    jobsFilterRecent: "RECIENTES",
+    jobsFilterMine: "MIOS",
+    jobsFilterAll: "TODOS",
+    jobsFilterRemote: "REMOTOS",
+    jobsFilterOpen: "ABIERTOS",
+    jobsFilterClosed: "CERRADOS",
+    jobsCV: "CVs",
+    jobsCreateJob: "Publicar Trabajo",
+    jobsRecentTitle: "Trabajos Recientes",
+    jobsMineTitle: "Tus Trabajos",
+    jobsAllTitle: "Ofertas de Trabajo",
+    jobsRemoteTitle: "Trabajos Remotos",
+    jobsOpenTitle: "Trabajos Abiertos",
+    jobsClosedTitle: "Trabajos Cerrados",
+    jobsCVTitle: "CVs",
+    jobsFilterPresencial: "PRESENCIAL",
+    jobsFilterFreelancer: "FREELANCE",
+    jobsFilterEmployee:   "EMPLEADO",
+    jobsPresencialTitle:  "Trabajos Presenciales",
+    jobsFreelancerTitle:  "Trabajos Freelance",
+    jobsEmployeeTitle:    "Trabajos de Empleado",
+    jobTitle: "Título",
+    jobLocation: "Ubicación",
+    jobSalary: "Salario (1h)",
+    jobVacants: "Vacantes",
+    jobDescription: "Descripción",
+    jobRequirements: "Requisitos",
+    jobLanguages: "Idiomas",
+    jobStatus: "Estado",
+    jobStatusOPEN: "ABIERTA",
+    jobStatusCLOSED: "CERRADA",
+    jobSetOpen: "Marcar como ABIERTA",
+    jobSetClosed: "Marcar como CERRADA",
+    jobSubscribeButton: "Unirse a esta oferta!",
+    jobUnsubscribeButton: "Salir de esta oferta!",
+    jobTitlePlaceholder: "Introduce el título del trabajo",
+    jobDescriptionPlaceholder: "Describe el puesto",
+    jobRequirementsPlaceholder: "Introduce los requisitos",
+    jobLanguagesPlaceholder: "Inglés, Español, Euskera",
+    jobTasksPlaceholder: "Lista de tareas",
+    jobLocationPresencial: "Presencial",
+    jobLocationRemote: "Remoto",
+    jobVacantsPlaceholder: "Número de vacantes",
+    jobSalaryPlaceholder: "Salario (1h)",
+    jobImage: "Subir Imagen (jpeg, jpg, png, gif) (tamaño máximo: 500px x 400px)",
+    jobTasks: "Tareas",
+    jobType: "Tipo de Trabajo",
+    jobTime: "Tiempo de Trabajo",
+    jobSubscribers:  "Suscriptores",
+    noSubscribers:   "Sin suscriptores",
+    jobsFilterTop: "TOP",
+    jobsTopTitle: "Trabajos Mejor Pagados",
+    createJobButton: "Publicar Trabajo",
+    viewDetailsButton: "Ver Detalles",
+    noJobsFound: "No se encontraron ofertas de trabajo.",
+    jobAuthor: "Por",
+    jobTypeFreelance: "Autónomo",
+    jobTypeSalary: "Empleado",
+    jobTimePartial: "Parcial",
+    jobTimeComplete: "Completo",
+    jobsDeleteButton: "ELIMINAR",
+    jobsUpdateButton: "ACTUALIZAR",
     //modules
     //modules
     modulesModuleName: "Nombre",
     modulesModuleName: "Nombre",
     modulesModuleDescription: "Descripción",
     modulesModuleDescription: "Descripción",
@@ -1466,6 +1545,8 @@ module.exports = {
     modulesAIDescription: "Módulo para hablar con un LLM llamado '42'.",
     modulesAIDescription: "Módulo para hablar con un LLM llamado '42'.",
     modulesForumLabel: "Foros",
     modulesForumLabel: "Foros",
     modulesForumDescription: "Módulo para descubrir y gestionar foros.",
     modulesForumDescription: "Módulo para descubrir y gestionar foros.",
+    modulesJobsLabel: "Trabajos",
+    modulesJobsDescription: "Modulo para descubrir y gestionar ofertas de trabajo.",
      
      
      //END
      //END
     }
     }

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

@@ -197,6 +197,9 @@ module.exports = {
     legacyDescription: "Kudeatu zure sekretua (gako pribatua) bizkor eta segurtasunez.",   
     legacyDescription: "Kudeatu zure sekretua (gako pribatua) bizkor eta segurtasunez.",   
     legacyExportButton: "Esportatu",
     legacyExportButton: "Esportatu",
     legacyImportButton: "Inportatu",
     legacyImportButton: "Inportatu",
+    ssbLogStream: "Blockchain",
+    ssbLogStreamDescription: "Konfiguratu blockchain fluxuetarako mezu-muga.",
+    saveSettings: "Ezarpenak gorde",
     exportTitle: "Esportatu datuak",
     exportTitle: "Esportatu datuak",
     exportDescription: "Ezarri pasahitza (gutxienez 32 karaktere) zure gakoa zifratzeko",
     exportDescription: "Ezarri pasahitza (gutxienez 32 karaktere) zure gakoa zifratzeko",
     exportDataTitle: "Babeskopia",
     exportDataTitle: "Babeskopia",
@@ -1029,6 +1032,7 @@ module.exports = {
     typeVotes:       "BOZKAK",
     typeVotes:       "BOZKAK",
     typeAudio:       "AUIDOAK",
     typeAudio:       "AUIDOAK",
     typeMarket:      "MERKATUA",
     typeMarket:      "MERKATUA",
+    typeJob:         "LANAK",
     typeVideo:       "BIDEOAK",
     typeVideo:       "BIDEOAK",
     typeVote:        "ZABALPENAK",
     typeVote:        "ZABALPENAK",
     typeEvent:       "EKITALDIAK",
     typeEvent:       "EKITALDIAK",
@@ -1196,6 +1200,7 @@ module.exports = {
     agendaFilterEvents: "EKITALDIAK",
     agendaFilterEvents: "EKITALDIAK",
     agendaFilterReports: "TXOSTENAK",
     agendaFilterReports: "TXOSTENAK",
     agendaFilterTransfers: "TRANSFERENTZIAK",
     agendaFilterTransfers: "TRANSFERENTZIAK",
+    agendaFilterJobs: "LANPOSTUAK",
     agendaNoItems: "Esleipenik ez.",
     agendaNoItems: "Esleipenik ez.",
     agendaDiscardButton: "Baztertu",
     agendaDiscardButton: "Baztertu",
     agendaRestoreButton: "Berrezarri",
     agendaRestoreButton: "Berrezarri",
@@ -1298,6 +1303,7 @@ module.exports = {
     blockchainBlockInfo: 'Blokearen informazioa',
     blockchainBlockInfo: 'Blokearen informazioa',
     blockchainBlockDetails: 'Hautatutako blokearen xehetasunak',
     blockchainBlockDetails: 'Hautatutako blokearen xehetasunak',
     blockchainBack: 'Itzuli blokearen azterkira',
     blockchainBack: 'Itzuli blokearen azterkira',
+    blockchainContentDeleted: "Edukia ezabatu egin da",
     visitContent: 'Bisitatu Edukia',
     visitContent: 'Bisitatu Edukia',
     //stats
     //stats
     statsTitle: 'Estatistikak',
     statsTitle: 'Estatistikak',
@@ -1318,14 +1324,22 @@ module.exports = {
     statsDiscoveredTribes: "Tribuak",
     statsDiscoveredTribes: "Tribuak",
     statsNetworkContent:   "Edukia",
     statsNetworkContent:   "Edukia",
     statsYourMarket:       "Merkatua",
     statsYourMarket:       "Merkatua",
+    statsYourJob:          "Lanak",
+    statsYourTransfer:     "Transferentziak",
+    statsYourForum:        "Foroak",   
     statsNetworkOpinions:  "Iritziak",
     statsNetworkOpinions:  "Iritziak",
     statsDiscoveredMarket: "Merkatua",
     statsDiscoveredMarket: "Merkatua",
+    statsDiscoveredJobs:   "Lanak",
+    statsDiscoveredTransfer: "Transferentziak",
+    statsDiscoveredForum: "Foroak",
     statsNetworkTombstone: "Hilarriak",
     statsNetworkTombstone: "Hilarriak",
     statsBookmarks: "Markagailuak",
     statsBookmarks: "Markagailuak",
     statsEvents: "Ekitaldiak",
     statsEvents: "Ekitaldiak",
     statsTasks: "Atazak",
     statsTasks: "Atazak",
     statsVotes: "Bozkak",
     statsVotes: "Bozkak",
     statsMarket: "Merkatua",
     statsMarket: "Merkatua",
+    statsForum: "Foroak",
+    statsJob: "Lanak",
     statsReports: "Txostenak",
     statsReports: "Txostenak",
     statsFeeds: "Jarioak",
     statsFeeds: "Jarioak",
     statsTribes: "Tribuak",
     statsTribes: "Tribuak",
@@ -1400,6 +1414,69 @@ module.exports = {
     marketNoItems: "Elementurik ez, oraindik.",
     marketNoItems: "Elementurik ez, oraindik.",
     marketYourBid: "Zeure eskaintza",
     marketYourBid: "Zeure eskaintza",
     marketCreateFormImageLabel: "Igo Irudia (jpeg, jpg, png, gif) (gehienez: 500px x 400px)",
     marketCreateFormImageLabel: "Igo Irudia (jpeg, jpg, png, gif) (gehienez: 500px x 400px)",
+    //jobs
+    jobsTitle: "Lanak",
+    jobsDescription: "Aurki eta kudeatu zure sarean lan eskaintzak.",
+    jobsFilterRecent: "BERANDUENAK",
+    jobsFilterMine: "NIRE LANEK",
+    jobsFilterAll: "GUZTIAK",
+    jobsFilterRemote: "ERREMOTAK",
+    jobsFilterOpen: "IREKIAK",
+    jobsFilterClosed: "ITXITA",
+    jobsCV: "CV-ak",
+    jobsCreateJob: "Argitaratu Lana",
+    jobsRecentTitle: "Lana Azkenak",
+    jobsMineTitle: "Zure Lankideak",
+    jobsAllTitle: "Lanen Guztiak",
+    jobsRemoteTitle: "Erremotako Lanak",
+    jobsOpenTitle: "Irekitako Lanak",
+    jobsClosedTitle: "Itxitako Lanak",
+    jobsCVTitle: "CV-ak",
+    jobsFilterPresencial: "PRESENTZIALA",
+    jobsFilterFreelancer: "FREELANCE",
+    jobsFilterEmployee:   "LANGILE",
+    jobsPresencialTitle:  "Presentzial Lanak",
+    jobsFreelancerTitle:  "Freelance Lanak",
+    jobsEmployeeTitle:    "Langile Lanak",
+    jobTitle: "Izenburua",
+    jobLocation: "Kokalekua",
+    jobSalary: "Soldata (1h)",
+    jobVacants: "Postu Libreak",
+    jobDescription: "Deskribapena",
+    jobRequirements: "Eskakizunak",
+    jobLanguages: "Hizkuntzak",
+    jobStatus: "Egoera",
+    jobStatusOPEN: "IREKITA",
+    jobStatusCLOSED: "ITXITA",
+    jobSetOpen: "IREKI gisa markatu",
+    jobSetClosed: "ITXI gisa markatu",
+    jobSubscribeButton: "Batu eskaintza honetara!",
+    jobUnsubscribeButton: "Utzi eskaintza hau!",
+    jobTitlePlaceholder: "Idatzi lanaren titulua",
+    jobDescriptionPlaceholder: "Deskribatu lana",
+    jobRequirementsPlaceholder: "Idatzi eskakizunak",
+    jobLanguagesPlaceholder: "Ingelesa, Espainiera, Euskara",
+    jobTasksPlaceholder: "Zeregin zerrenda",
+    jobLocationPresencial: "Aurrez aurrekoa",
+    jobLocationRemote: "Urrunekoa",
+    jobVacantsPlaceholder: "Postu kopurua",
+    jobSalaryPlaceholder: "Soldata kopurua",
+    jobImage: "Igo Irudia (jpeg, jpg, png, gif) (gehienez: 500px x 400px)",
+    jobTasks: "Eginkizunak",
+    jobType: "Lanen Motak",
+    jobTime: "Lanen Denbora",
+    jobSubscribers:  "Harpidetzaileak",
+    noSubscribers:   "Ez da harpidetzailerik",
+    createJobButton: "Argitaratu Lana",
+    viewDetailsButton: "Ikusi Xehetasunak",
+    noJobsFound: "Ez daude lan eskaintzak.",
+    jobAuthor: "Egilea",
+    jobTypeEmployee: "Enplegatu",
+    jobTypeFreelancer: "Lanikide",
+    jobTimePartial: "Partziala",
+    jobTimeComplete: "Osotua",
+    jobsDeleteButton: "EZABATU",
+    jobsUpdateButton: "EGUNERATU",
     //modules
     //modules
     modulesModuleName: "Izena",
     modulesModuleName: "Izena",
     modulesModuleDescription: "Deskribapena",
     modulesModuleDescription: "Deskribapena",
@@ -1466,7 +1543,9 @@ module.exports = {
     modulesAILabel: "AI",
     modulesAILabel: "AI",
     modulesAIDescription: "'42' izeneko LLM batekin hitz egiteko modulua.",
     modulesAIDescription: "'42' izeneko LLM batekin hitz egiteko modulua.",
     modulesForumLabel: "Foroak",
     modulesForumLabel: "Foroak",
-    modulesForumDescription: "Foroak deskubritu eta kudeatzeko modulua."
+    modulesForumDescription: "Foroak deskubritu eta kudeatzeko modulua.",
+    modulesJobsLabel: "Lanpostuak",
+    modulesJobsDescription: "Lan eskaintzak aurkitu eta kudeatzeko modulu.",
 
 
      //END
      //END
   }
   }

+ 1 - 1
src/configs/agenda-config.json

@@ -1,3 +1,3 @@
 {
 {
   "discardedItems": []
   "discardedItems": []
-}
+}

+ 4 - 0
src/configs/config-manager.js

@@ -39,6 +39,7 @@ if (!fs.existsSync(configFilePath)) {
       "agendaMod": "on",
       "agendaMod": "on",
       "aiMod": "on",
       "aiMod": "on",
       "forumMod": "on",
       "forumMod": "on",
+      "jobsMod": "on"
     },
     },
     "wallet": {
     "wallet": {
       "url": "http://localhost:7474",
       "url": "http://localhost:7474",
@@ -48,6 +49,9 @@ if (!fs.existsSync(configFilePath)) {
     },
     },
     "ai": {
     "ai": {
       "prompt": "Provide an informative and precise response."
       "prompt": "Provide an informative and precise response."
+    },
+    "ssbLogStream": {
+      "limit": 1000
     }
     }
   };
   };
   fs.writeFileSync(configFilePath, JSON.stringify(defaultConfig, null, 2));
   fs.writeFileSync(configFilePath, JSON.stringify(defaultConfig, null, 2));

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

@@ -32,7 +32,8 @@
     "pixeliaMod": "on",
     "pixeliaMod": "on",
     "agendaMod": "on",
     "agendaMod": "on",
     "aiMod": "on",
     "aiMod": "on",
-    "forumMod": "on"
+    "forumMod": "on",
+    "jobsMod": "on"
   },
   },
   "wallet": {
   "wallet": {
     "url": "http://localhost:7474",
     "url": "http://localhost:7474",
@@ -42,5 +43,8 @@
   },
   },
   "ai": {
   "ai": {
     "prompt": "Provide an informative and precise response."
     "prompt": "Provide an informative and precise response."
+  },
+  "ssbLogStream": {
+    "limit": 1000
   }
   }
 }
 }

+ 97 - 33
src/models/activity_model.js

@@ -1,4 +1,13 @@
 const pull = require('../server/node_modules/pull-stream');
 const pull = require('../server/node_modules/pull-stream');
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
+
+const N = s => String(s || '').toUpperCase().replace(/\s+/g, '_');
+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;
+};
 
 
 module.exports = ({ cooler }) => {
 module.exports = ({ cooler }) => {
   let ssb;
   let ssb;
@@ -23,56 +32,111 @@ module.exports = ({ cooler }) => {
 
 
       const results = await new Promise((resolve, reject) => {
       const results = await new Promise((resolve, reject) => {
         pull(
         pull(
-          ssbClient.createLogStream({ reverse: true, limit: 1000 }),
+          ssbClient.createLogStream({ reverse: true, limit: logLimit }),
           pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
           pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
         );
         );
       });
       });
 
 
       const tombstoned = new Set();
       const tombstoned = new Set();
-      const replaces = new Map();
-      const latest = new Map();
+      const parentOf = new Map();
+      const idToAction = new Map();
 
 
       for (const msg of results) {
       for (const msg of results) {
         const k = msg.key;
         const k = msg.key;
-        const c = msg.value?.content;
-        const author = msg.value?.author;
+        const v = msg.value;
+        const c = v?.content;
         if (!c?.type) continue;
         if (!c?.type) continue;
         if (c.type === 'tombstone' && c.target) {
         if (c.type === 'tombstone' && c.target) {
           tombstoned.add(c.target);
           tombstoned.add(c.target);
           continue;
           continue;
         }
         }
-        if (c.replaces) replaces.set(c.replaces, k);
-        latest.set(k, { id: k, author, ts: msg.value.timestamp, type: c.type, content: c });
+        idToAction.set(k, {
+          id: k,
+          author: v?.author,
+          ts: v?.timestamp || 0,
+          type: c.type,
+          content: c
+        });
+        if (c.replaces) parentOf.set(k, c.replaces);
       }
       }
 
 
-      for (const oldId of replaces.keys()) latest.delete(oldId);
-      for (const t of tombstoned) latest.delete(t);
+      const rootOf = (id) => {
+        let cur = id;
+        while (parentOf.has(cur)) cur = parentOf.get(cur);
+        return cur;
+      };
 
 
-      const actions = await Promise.all(
-        Array.from(latest.values()).map(async (a) => {
-          if (a.type === 'document') {
-            const url = a.content.url;
-            const validBlob = await hasBlob(ssbClient, url);
-            if (!validBlob) return null;
-          }
-          if (
-            a.type !== 'tombstone' &&
-            !tombstoned.has(a.id) &&
-            !(a.content?.root && tombstoned.has(a.content.root)) &&
-            !(a.type === 'vote' && tombstoned.has(a.content.vote.link))
-          ) {
-            return a;
+      const groups = new Map();
+      for (const [id, action] of idToAction.entries()) {
+        const root = rootOf(id);
+        if (!groups.has(root)) groups.set(root, []);
+        groups.get(root).push(action);
+      }
+
+      const idToTipId = new Map();
+
+      for (const [root, arr] of groups.entries()) {
+        if (!arr.length) continue;
+        const type = arr[0].type;
+
+        let tip;
+        if (type === 'market') {
+          tip = arr[0];
+          let bestScore = SCORE_MARKET(tip.content.status);
+          for (const a of arr) {
+            const s = SCORE_MARKET(a.content.status);
+            if (s > bestScore || (s === bestScore && a.ts > tip.ts)) {
+              tip = a;
+              bestScore = s;
+            }
           }
           }
-          return null;
-        })
-      );
-      const validActions = actions.filter(Boolean);
-      if (filter === 'mine')
-        return validActions
-          .filter(a => a.author === userId)
-          .sort((a, b) => b.ts - a.ts);
-
-      return validActions.sort((a, b) => b.ts - a.ts);
+        } 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;
+
+      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 });
+    }
+
+      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 {
+        out = latest.filter(a => a.type === filter);
+      }
+
+      out.sort((a, b) => (b.ts || 0) - (a.ts || 0));
+
+      return out;
     }
     }
   };
   };
 };
 };
+

+ 130 - 46
src/models/agenda_model.js

@@ -4,6 +4,8 @@ const pull = require('../server/node_modules/pull-stream');
 const moment = require('../server/node_modules/moment');
 const moment = require('../server/node_modules/moment');
 
 
 const agendaConfigPath = path.join(__dirname, '../configs/agenda-config.json');
 const agendaConfigPath = path.join(__dirname, '../configs/agenda-config.json');
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 
 function readAgendaConfig() {
 function readAgendaConfig() {
   if (!fs.existsSync(agendaConfigPath)) {
   if (!fs.existsSync(agendaConfigPath)) {
@@ -20,32 +22,112 @@ module.exports = ({ cooler }) => {
   let ssb;
   let ssb;
   const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
   const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
 
 
-  const fetchItems = (targetType, filterFn) =>
+  const STATUS_ORDER = ['FOR SALE', 'OPEN', 'RESERVED', 'CLOSED', 'SOLD'];
+  const sIdx = s => STATUS_ORDER.indexOf(String(s || '').toUpperCase());
+
+  const fetchItems = (targetType) =>
     new Promise((resolve, reject) => {
     new Promise((resolve, reject) => {
       openSsb().then((ssbClient) => {
       openSsb().then((ssbClient) => {
-        const userId = ssbClient.id;
         pull(
         pull(
-          ssbClient.createLogStream(),
+          ssbClient.createLogStream({ limit: logLimit }),
           pull.collect((err, msgs) => {
           pull.collect((err, msgs) => {
             if (err) return reject(err);
             if (err) return reject(err);
-            const tombstoned = new Set();
-            const replacesMap = new Map();
-            const latestMap = new Map();
-            for (const msg of msgs) {
-              const c = msg.value?.content;
-              const k = msg.key;
+
+            const tomb = new Set();
+            const nodes = new Map();
+            const parent = new Map();
+            const child = new Map();
+
+            for (const m of msgs) {
+              const k = m.key;
+              const v = m.value;
+              const c = v?.content;
               if (!c) continue;
               if (!c) continue;
-              if (c.type === 'tombstone' && c.target) tombstoned.add(c.target);
-              else if (c.type === targetType) {
-                if (c.replaces) replacesMap.set(c.replaces, k);
-                latestMap.set(k, { key: k, value: msg.value });
+              if (c.type === 'tombstone' && c.target) { tomb.add(c.target); continue; }
+              if (c.type !== targetType) continue;
+              nodes.set(k, { key: k, ts: v.timestamp || 0, content: c });
+              if (c.replaces) { parent.set(k, c.replaces); child.set(c.replaces, k); }
+            }
+
+            const rootOf = (id) => { let cur = id; while (parent.has(cur)) cur = parent.get(cur); return cur; };
+
+            const groups = new Map();
+            for (const id of nodes.keys()) {
+              const r = rootOf(id);
+              if (!groups.has(r)) groups.set(r, new Set());
+              groups.get(r).add(id);
+            }
+
+            const statusOrder = ['FOR SALE', 'OPEN', 'RESERVED', 'CLOSED', 'SOLD'];
+            const sIdx = s => statusOrder.indexOf(String(s || '').toUpperCase());
+
+            const out = [];
+
+            for (const [root, ids] of groups.entries()) {
+              const items = Array.from(ids).map(id => nodes.get(id)).filter(n => n && !tomb.has(n.key));
+              if (!items.length) continue;
+
+              let tipId = Array.from(ids).find(id => !child.has(id));
+              let tip = tipId ? nodes.get(tipId) : items.reduce((a, b) => a.ts > b.ts ? a : b);
+
+              if (targetType === 'market') {
+                let chosen = items[0];
+                for (const n of items) {
+                  const a = sIdx(n.content.status);
+                  const b = sIdx(chosen.content.status);
+                  if (a > b || (a === b && n.ts > chosen.ts)) chosen = n;
+                }
+                const c = chosen.content;
+                let status = c.status;
+                if (c.deadline) {
+                  const dl = moment(c.deadline);
+                  if (dl.isValid() && dl.isBefore(moment()) && String(status).toUpperCase() !== 'SOLD') status = 'DISCARDED';
+                }
+                if (status === 'FOR SALE' && (c.stock || 0) === 0) continue;
+
+                out.push({
+                  ...c,
+                  status,
+                  id: chosen.key,
+                  tipId: chosen.key,
+                  createdAt: c.createdAt || chosen.ts
+                });
+                continue;
               }
               }
+
+              if (targetType === 'job') {
+                const latest = items.sort((a, b) => b.ts - a.ts)[0];
+                const withSubsNode = items
+                  .filter(n => Array.isArray(n.content.subscribers))
+                  .sort((a, b) => b.ts - a.ts)[0];
+                const subscribers = withSubsNode ? withSubsNode.content.subscribers : [];
+                const latestWithStatus = items
+                  .filter(n => typeof n.content.status !== 'undefined')
+                  .sort((a, b) => b.ts - a.ts)[0];
+                const resolvedStatus = latestWithStatus
+                  ? latestWithStatus.content.status
+                  : latest.content.status;
+
+                const c = { ...latest.content, status: resolvedStatus, subscribers };
+
+                out.push({
+                  ...c,
+                  id: latest.key,
+                  tipId: latest.key,
+                  createdAt: c.createdAt || latest.ts
+                });
+                continue;
+              }
+
+              out.push({
+                ...tip.content,
+                id: tip.key,
+                tipId: tip.key,
+                createdAt: tip.content.createdAt || tip.ts
+              });
             }
             }
-            for (const [oldId, newId] of replacesMap.entries()) latestMap.delete(oldId);
-            const results = Array.from(latestMap.values()).filter(
-              (msg) => !tombstoned.has(msg.key) && filterFn(msg.value.content, userId)
-            );
-            resolve(results.map(item => ({ ...item.value.content, id: item.key })));
+
+            resolve(out);
           })
           })
         );
         );
       }).catch(reject);
       }).catch(reject);
@@ -58,54 +140,55 @@ module.exports = ({ cooler }) => {
       const ssbClient = await openSsb();
       const ssbClient = await openSsb();
       const userId = ssbClient.id;
       const userId = ssbClient.id;
 
 
-      const [tasks, events, transfers, tribes, marketItems, reports] = await Promise.all([
-        fetchItems('task', (c, id) => Array.isArray(c.assignees) && c.assignees.includes(id)),
-        fetchItems('event', (c, id) => Array.isArray(c.attendees) && c.attendees.includes(id)),
-        fetchItems('transfer', (c, id) => c.from === id || c.to === id),
-        fetchItems('tribe', (c, id) => Array.isArray(c.members) && c.members.includes(id)),
-        fetchItems('market', (c, id) => c.seller === id || (Array.isArray(c.auctions_poll) && c.auctions_poll.some(b => b.split(':')[0] === id))),
-        fetchItems('report', (c, id) => c.author === id || (Array.isArray(c.confirmations) && c.confirmations.includes(id)))
+      const [tasksAll, eventsAll, transfersAll, tribesAll, marketAll, reportsAll, jobsAll] = await Promise.all([
+        fetchItems('task'),
+        fetchItems('event'),
+        fetchItems('transfer'),
+        fetchItems('tribe'),
+        fetchItems('market'),
+        fetchItems('report'),
+        fetchItems('job')
       ]);
       ]);
 
 
+      const tasks = tasksAll.filter(c => Array.isArray(c.assignees) && c.assignees.includes(userId)).map(t => ({ ...t, type: 'task' }));
+      const events = eventsAll.filter(c => Array.isArray(c.attendees) && c.attendees.includes(userId)).map(e => ({ ...e, type: 'event' }));
+      const transfers = transfersAll.filter(c => c.from === userId || c.to === userId).map(tr => ({ ...tr, type: 'transfer' }));
+      const tribes = tribesAll.filter(c => Array.isArray(c.members) && c.members.includes(userId)).map(t => ({ ...t, type: 'tribe', title: t.title }));
+      const marketItems = marketAll.filter(c =>
+        c.seller === userId || (Array.isArray(c.auctions_poll) && c.auctions_poll.some(b => String(b).split(':')[0] === userId))
+      ).map(m => ({ ...m, type: 'market' }));
+      const reports = reportsAll.filter(c => c.author === userId || (Array.isArray(c.confirmations) && c.confirmations.includes(userId))).map(r => ({ ...r, type: 'report' }));
+      const jobs = jobsAll.filter(c => c.author === userId || (Array.isArray(c.subscribers) && c.subscribers.includes(userId))).map(j => ({ ...j, type: 'job', title: j.title }));
+
       let combined = [
       let combined = [
         ...tasks,
         ...tasks,
         ...events,
         ...events,
         ...transfers,
         ...transfers,
-        ...tribes.map(t => ({ ...t, type: 'tribe', title: t.title })),
-        ...marketItems.map(m => ({ ...m, type: 'market' })),
-        ...reports.map(r => ({ ...r, type: 'report' }))
+        ...tribes,
+        ...marketItems,
+        ...reports,
+        ...jobs
       ];
       ];
-      const dedup = {};
-      for (const item of combined) {
-        const dA = item.startTime || item.date || item.deadline || item.createdAt;
-        if (!dedup[item.id]) dedup[item.id] = item;
-        else {
-          const existing = dedup[item.id];
-          const dB = existing.startTime || existing.date || existing.deadline || existing.createdAt;
-          if (new Date(dA) > new Date(dB)) dedup[item.id] = item;
-        }
-      }
-      combined = Object.values(dedup);
 
 
       let filtered;
       let filtered;
       if (filter === 'discarded') {
       if (filter === 'discarded') {
         filtered = combined.filter(i => discardedItems.includes(i.id));
         filtered = combined.filter(i => discardedItems.includes(i.id));
       } else {
       } else {
         filtered = combined.filter(i => !discardedItems.includes(i.id));
         filtered = combined.filter(i => !discardedItems.includes(i.id));
-
         if (filter === 'tasks') filtered = filtered.filter(i => i.type === 'task');
         if (filter === 'tasks') filtered = filtered.filter(i => i.type === 'task');
         else if (filter === 'events') filtered = filtered.filter(i => i.type === 'event');
         else if (filter === 'events') filtered = filtered.filter(i => i.type === 'event');
         else if (filter === 'transfers') filtered = filtered.filter(i => i.type === 'transfer');
         else if (filter === 'transfers') filtered = filtered.filter(i => i.type === 'transfer');
         else if (filter === 'tribes') filtered = filtered.filter(i => i.type === 'tribe');
         else if (filter === 'tribes') filtered = filtered.filter(i => i.type === 'tribe');
         else if (filter === 'market') filtered = filtered.filter(i => i.type === 'market');
         else if (filter === 'market') filtered = filtered.filter(i => i.type === 'market');
         else if (filter === 'reports') filtered = filtered.filter(i => i.type === 'report');
         else if (filter === 'reports') filtered = filtered.filter(i => i.type === 'report');
-        else if (filter === 'open') filtered = filtered.filter(i => i.status === 'OPEN');
-        else if (filter === 'closed') filtered = filtered.filter(i => i.status === 'CLOSED');
+        else if (filter === 'open') filtered = filtered.filter(i => String(i.status).toUpperCase() === 'OPEN');
+        else if (filter === 'closed') filtered = filtered.filter(i => String(i.status).toUpperCase() === 'CLOSED');
+        else if (filter === 'jobs') filtered = filtered.filter(i => i.type === 'job');
       }
       }
 
 
       filtered.sort((a, b) => {
       filtered.sort((a, b) => {
-        const dateA = a.startTime || a.date || a.deadline || a.createdAt;
-        const dateB = b.startTime || b.date || b.deadline || b.createdAt;
+        const dateA = a.startTime || a.date || a.deadline || a.createdAt || 0;
+        const dateB = b.startTime || b.date || b.deadline || b.createdAt || 0;
         return new Date(dateA) - new Date(dateB);
         return new Date(dateA) - new Date(dateB);
       });
       });
 
 
@@ -116,14 +199,15 @@ module.exports = ({ cooler }) => {
         items: filtered,
         items: filtered,
         counts: {
         counts: {
           all: mainItems.length,
           all: mainItems.length,
-          open: mainItems.filter(i => i.status === 'OPEN').length,
-          closed: mainItems.filter(i => i.status === 'CLOSED').length,
+          open: mainItems.filter(i => String(i.status).toUpperCase() === 'OPEN').length,
+          closed: mainItems.filter(i => String(i.status).toUpperCase() === 'CLOSED').length,
           tasks: mainItems.filter(i => i.type === 'task').length,
           tasks: mainItems.filter(i => i.type === 'task').length,
           events: mainItems.filter(i => i.type === 'event').length,
           events: mainItems.filter(i => i.type === 'event').length,
           transfers: mainItems.filter(i => i.type === 'transfer').length,
           transfers: mainItems.filter(i => i.type === 'transfer').length,
           tribes: mainItems.filter(i => i.type === 'tribe').length,
           tribes: mainItems.filter(i => i.type === 'tribe').length,
           market: mainItems.filter(i => i.type === 'market').length,
           market: mainItems.filter(i => i.type === 'market').length,
           reports: mainItems.filter(i => i.type === 'report').length,
           reports: mainItems.filter(i => i.type === 'report').length,
+          jobs: mainItems.filter(i => i.type === 'job').length,
           discarded: discarded.length
           discarded: discarded.length
         }
         }
       };
       };

+ 3 - 1
src/models/audios_model.js

@@ -1,4 +1,6 @@
 const pull = require('../server/node_modules/pull-stream')
 const pull = require('../server/node_modules/pull-stream')
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 
 module.exports = ({ cooler }) => {
 module.exports = ({ cooler }) => {
   let ssb
   let ssb
@@ -79,7 +81,7 @@ module.exports = ({ cooler }) => {
       const ssbClient = await openSsb()
       const ssbClient = await openSsb()
       const messages = await new Promise((res, rej) => {
       const messages = await new Promise((res, rej) => {
         pull(
         pull(
-          ssbClient.createLogStream(),
+          ssbClient.createLogStream({ limit: logLimit }),
           pull.collect((err, msgs) => err ? rej(err) : res(msgs))
           pull.collect((err, msgs) => err ? rej(err) : res(msgs))
         )
         )
       })
       })

+ 138 - 52
src/models/blockchain_model.js

@@ -1,4 +1,7 @@
 const pull = require('../server/node_modules/pull-stream');
 const pull = require('../server/node_modules/pull-stream');
+const { config } = require('../server/SSB_server.js');
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 
 module.exports = ({ cooler }) => {
 module.exports = ({ cooler }) => {
   let ssb;
   let ssb;
@@ -8,28 +11,22 @@ module.exports = ({ cooler }) => {
     return ssb;
     return ssb;
   };
   };
 
 
-  const hasBlob = async (ssbClient, url) => {
-    return new Promise((resolve) => {
-      ssbClient.blobs.has(url, (err, has) => {
-        resolve(!err && has);
-      });
-    });
-  };
+  const hasBlob = async (ssbClient, url) =>
+    new Promise(resolve => ssbClient.blobs.has(url, (err, has) => resolve(!err && has)));
 
 
   return {
   return {
     async listBlockchain(filter = 'all') {
     async listBlockchain(filter = 'all') {
       const ssbClient = await openSsb();
       const ssbClient = await openSsb();
-
-      const results = await new Promise((resolve, reject) => {
+      const results = await new Promise((resolve, reject) =>
         pull(
         pull(
-          ssbClient.createLogStream({ reverse: true, limit: 1000 }),
+          ssbClient.createLogStream({ reverse: true, limit: logLimit }),
           pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
           pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
-        );
-      });
+        )
+      );
 
 
       const tombstoned = new Set();
       const tombstoned = new Set();
-      const replaces = new Map();
-      const blocks = new Map();
+      const idToBlock = new Map();
+      const referencedAsReplaces = new Set();
 
 
       for (const msg of results) {
       for (const msg of results) {
         const k = msg.key;
         const k = msg.key;
@@ -38,64 +35,153 @@ module.exports = ({ cooler }) => {
         if (!c?.type) continue;
         if (!c?.type) continue;
         if (c.type === 'tombstone' && c.target) {
         if (c.type === 'tombstone' && c.target) {
           tombstoned.add(c.target);
           tombstoned.add(c.target);
+          idToBlock.set(k, { id: k, author, ts: msg.value.timestamp, type: c.type, content: c });
           continue;
           continue;
         }
         }
-        if (c.replaces) replaces.set(c.replaces, k);
-        blocks.set(k, { id: k, author, ts: msg.value.timestamp, type: c.type, content: c });
+        if (c.replaces) referencedAsReplaces.add(c.replaces);
+        idToBlock.set(k, { id: k, author, ts: msg.value.timestamp, type: c.type, content: c });
       }
       }
 
 
-      for (const oldId of replaces.keys()) blocks.delete(oldId);
-      for (const t of tombstoned) blocks.delete(t);
+      const tipBlocks = [];
+      for (const [id, block] of idToBlock.entries()) {
+        if (!referencedAsReplaces.has(id) && block.content.replaces) tipBlocks.push(block);
+      }
+      for (const [id, block] of idToBlock.entries()) {
+        if (!block.content.replaces && !referencedAsReplaces.has(id)) tipBlocks.push(block);
+      }
+
+      const groups = {};
+      for (const block of tipBlocks) {
+        const ancestor = block.content.replaces || block.id;
+        if (!groups[ancestor]) groups[ancestor] = [];
+        groups[ancestor].push(block);
+      }
 
 
-      const blockData = await Promise.all(
-        Array.from(blocks.values()).map(async (block) => {
-          if (block.type === 'document') {
-            const url = block.content.url;
-            const validBlob = await hasBlob(ssbClient, url);
-            if (!validBlob) return null;
+      const liveTipIds = new Set();
+      for (const groupBlocks of Object.values(groups)) {
+        let best = groupBlocks[0];
+        for (const block of groupBlocks) {
+          if (block.type === 'market') {
+            const isClosedSold = s => s === 'SOLD' || s === 'CLOSED';
+            if (isClosedSold(block.content.status) && !isClosedSold(best.content.status)) {
+              best = block;
+            } else if ((block.content.status === best.content.status) && block.ts > best.ts) {
+              best = block;
+            }
+          } else if (block.type === 'job' || block.type === 'forum') {
+            if (block.ts > best.ts) best = block;
+          } else {
+            if (block.ts > best.ts) best = block;
           }
           }
-          return block;
-        })
-      );
+        }
+        liveTipIds.add(best.id);
+      }
+
+      const blockData = Array.from(idToBlock.values()).map(block => {
+        const c = block.content;
+        const rootDeleted = c?.type === 'forum' && c.root && tombstoned.has(c.root);
+        return {
+          ...block,
+          isTombstoned: tombstoned.has(block.id),
+          isReplaced: c.replaces
+            ? (!liveTipIds.has(block.id) || tombstoned.has(block.id))
+            : referencedAsReplaces.has(block.id) || tombstoned.has(block.id) || rootDeleted
+        };
+      });
 
 
+      let filtered = blockData;
       if (filter === 'RECENT') {
       if (filter === 'RECENT') {
         const now = Date.now();
         const now = Date.now();
-        return blockData.filter(block => now - block.ts <= 24 * 60 * 60 * 1000);
+        filtered = blockData.filter(b => b && now - b.ts <= 24 * 60 * 60 * 1000);
       }
       }
-
       if (filter === 'MINE') {
       if (filter === 'MINE') {
-        const userId = SSBconfig.config.keys.id;
-        return blockData.filter(block => block.author === userId);
+        filtered = blockData.filter(b => b && b.author === config.keys.id);
       }
       }
 
 
-      return blockData.filter(Boolean);
+      return filtered.filter(Boolean);
     },
     },
 
 
     async getBlockById(id) {
     async getBlockById(id) {
       const ssbClient = await openSsb();
       const ssbClient = await openSsb();
-      return await new Promise((resolve, reject) => {
+      const results = await new Promise((resolve, reject) =>
         pull(
         pull(
-          ssbClient.createLogStream({ reverse: true, limit: 1000 }),
-          pull.find((msg) => msg.key === id, async (err, msg) => {
-            if (err || !msg) return resolve(null);
-            const c = msg.value?.content;
-            if (!c?.type) return resolve(null);
-            if (c.type === 'document') {
-              const url = c.url;
-              const validBlob = await hasBlob(ssbClient, url);
-              if (!validBlob) return resolve(null);
+          ssbClient.createLogStream({ reverse: true, limit: logLimit }),
+          pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
+        )
+      );
+
+      const tombstoned = new Set();
+      const idToBlock = new Map();
+      const referencedAsReplaces = new Set();
+
+      for (const msg of results) {
+        const k = msg.key;
+        const c = msg.value?.content;
+        const author = msg.value?.author;
+        if (!c?.type) continue;
+        if (c.type === 'tombstone' && c.target) {
+          tombstoned.add(c.target);
+          idToBlock.set(k, { id: k, author, ts: msg.value.timestamp, type: c.type, content: c });
+          continue;
+        }
+        if (c.replaces) referencedAsReplaces.add(c.replaces);
+        idToBlock.set(k, { id: k, author, ts: msg.value.timestamp, type: c.type, content: c });
+      }
+
+      const tipBlocks = [];
+      for (const [bid, block] of idToBlock.entries()) {
+        if (!referencedAsReplaces.has(bid) && block.content.replaces) tipBlocks.push(block);
+      }
+      for (const [bid, block] of idToBlock.entries()) {
+        if (!block.content.replaces && !referencedAsReplaces.has(bid)) tipBlocks.push(block);
+      }
+
+      const groups = {};
+      for (const block of tipBlocks) {
+        const ancestor = block.content.replaces || block.id;
+        if (!groups[ancestor]) groups[ancestor] = [];
+        groups[ancestor].push(block);
+      }
+
+      const liveTipIds = new Set();
+      for (const groupBlocks of Object.values(groups)) {
+        let best = groupBlocks[0];
+        for (const block of groupBlocks) {
+          if (block.type === 'market') {
+            const isClosedSold = s => s === 'SOLD' || s === 'CLOSED';
+            if (isClosedSold(block.content.status) && !isClosedSold(best.content.status)) {
+              best = block;
+            } else if ((block.content.status === best.content.status) && block.ts > best.ts) {
+              best = block;
             }
             }
+          } else if (block.type === 'job' || block.type === 'forum') {
+            if (block.ts > best.ts) best = block;
+          } else {
+            if (block.ts > best.ts) best = block;
+          }
+        }
+        liveTipIds.add(best.id);
+      }
 
 
-            resolve({
-              id: msg.key,
-              author: msg.value?.author,
-              ts: msg.value?.timestamp,
-              type: c.type,
-              content: c
-            });
-          })
-        );
-      });
+      const block = idToBlock.get(id);
+      if (!block) return null;
+      if (block.type === 'document') {
+        const valid = await hasBlob(ssbClient, block.content.url);
+        if (!valid) return null;
+      }
+
+      const c = block.content;
+      const rootDeleted = c?.type === 'forum' && c.root && tombstoned.has(c.root);
+      const isTombstoned = tombstoned.has(block.id);
+      const isReplaced = c.replaces
+        ? (!liveTipIds.has(block.id) || tombstoned.has(block.id))
+        : referencedAsReplaces.has(block.id) || tombstoned.has(block.id) || rootDeleted;
+
+      return {
+        ...block,
+        isTombstoned,
+        isReplaced
+      };
     }
     }
   };
   };
 };
 };

+ 3 - 1
src/models/bookmarking_model.js

@@ -1,5 +1,7 @@
 const pull = require('../server/node_modules/pull-stream')
 const pull = require('../server/node_modules/pull-stream')
 const moment = require('../server/node_modules/moment')
 const moment = require('../server/node_modules/moment')
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 
 module.exports = ({ cooler }) => {
 module.exports = ({ cooler }) => {
   let ssb
   let ssb
@@ -42,7 +44,7 @@ module.exports = ({ cooler }) => {
       const userId = ssbClient.id
       const userId = ssbClient.id
       const results = await new Promise((res, rej) => {
       const results = await new Promise((res, rej) => {
         pull(
         pull(
-          ssbClient.createLogStream(),
+          ssbClient.createLogStream({ limit: logLimit }),
           pull.collect((err, msgs) => err ? rej(err) : res(msgs))
           pull.collect((err, msgs) => err ? rej(err) : res(msgs))
         )
         )
       })
       })

+ 38 - 13
src/models/cv_model.js

@@ -1,4 +1,6 @@
 const pull = require('../server/node_modules/pull-stream');
 const pull = require('../server/node_modules/pull-stream');
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 
 const extractBlobId = str => {
 const extractBlobId = str => {
   if (!str || typeof str !== 'string') return null;
   if (!str || typeof str !== 'string') return null;
@@ -6,11 +8,17 @@ const extractBlobId = str => {
   return match ? match[1] : str.trim();
   return match ? match[1] : str.trim();
 };
 };
 
 
-const parseCSV = str => str ? str.split(',').map(s => s.trim()).filter(Boolean) : [];
+const parseCSV = str => str
+  ? str.split(',').map(s => s.trim()).filter(Boolean)
+  : [];
 
 
 module.exports = ({ cooler }) => {
 module.exports = ({ cooler }) => {
   let ssb;
   let ssb;
-  const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
+
+  const openSsb = async () => {
+    if (!ssb) ssb = await cooler.open();
+    return ssb;
+  };
 
 
   return {
   return {
     type: 'curriculum',
     type: 'curriculum',
@@ -47,16 +55,22 @@ module.exports = ({ cooler }) => {
     async updateCV(id, data, photoBlobId) {
     async updateCV(id, data, photoBlobId) {
       const ssbClient = await openSsb();
       const ssbClient = await openSsb();
       const userId = ssbClient.id;
       const userId = ssbClient.id;
+
       const old = await new Promise((res, rej) =>
       const old = await new Promise((res, rej) =>
         ssbClient.get(id, (err, msg) =>
         ssbClient.get(id, (err, msg) =>
-          err || !msg?.content ? rej(err || new Error('CV not found')) : res(msg)
+          err || !msg?.content
+            ? rej(err || new Error('CV not found'))
+            : res(msg)
         )
         )
       );
       );
-      if (old.content.author !== userId) throw new Error('Not the author');
+
+      if (old.content.author !== userId) {
+        throw new Error('Not the author');
+      }
 
 
       const tombstone = {
       const tombstone = {
         type: 'tombstone',
         type: 'tombstone',
-        id,
+        target: id,
         deletedAt: new Date().toISOString()
         deletedAt: new Date().toISOString()
       };
       };
 
 
@@ -95,17 +109,25 @@ module.exports = ({ cooler }) => {
     async deleteCVById(id) {
     async deleteCVById(id) {
       const ssbClient = await openSsb();
       const ssbClient = await openSsb();
       const userId = ssbClient.id;
       const userId = ssbClient.id;
+
       const msg = await new Promise((res, rej) =>
       const msg = await new Promise((res, rej) =>
         ssbClient.get(id, (err, msg) =>
         ssbClient.get(id, (err, msg) =>
-          err || !msg?.content ? rej(new Error('CV not found')) : res(msg)
+          err || !msg?.content
+            ? rej(new Error('CV not found'))
+            : res(msg)
         )
         )
       );
       );
-      if (msg.content.author !== userId) throw new Error('Not the author');
+
+      if (msg.content.author !== userId) {
+        throw new Error('Not the author');
+      }
+
       const tombstone = {
       const tombstone = {
         type: 'tombstone',
         type: 'tombstone',
-        id,
+        target: id,
         deletedAt: new Date().toISOString()
         deletedAt: new Date().toISOString()
       };
       };
+
       return new Promise((resolve, reject) => {
       return new Promise((resolve, reject) => {
         ssbClient.publish(tombstone, (err, result) => err ? reject(err) : resolve(result));
         ssbClient.publish(tombstone, (err, result) => err ? reject(err) : resolve(result));
       });
       });
@@ -115,27 +137,30 @@ module.exports = ({ cooler }) => {
       const ssbClient = await openSsb();
       const ssbClient = await openSsb();
       const userId = ssbClient.id;
       const userId = ssbClient.id;
       const authorId = targetUserId || userId;
       const authorId = targetUserId || userId;
+
       return new Promise((resolve, reject) => {
       return new Promise((resolve, reject) => {
         pull(
         pull(
-          ssbClient.createLogStream(),
+          ssbClient.createLogStream({ limit: logLimit }),
           pull.collect((err, msgs) => {
           pull.collect((err, msgs) => {
             if (err) return reject(err);
             if (err) return reject(err);
 
 
             const tombstoned = new Set(
             const tombstoned = new Set(
               msgs
               msgs
-                .filter(m => m.value?.content?.type === 'tombstone' && m.value?.content?.id)
-                .map(m => m.value.content.id)
+                .filter(m => m.value?.content?.type === 'tombstone' && m.value.content.target)
+                .map(m => m.value.content.target)
             );
             );
 
 
             const cvMsgs = msgs
             const cvMsgs = msgs
               .filter(m =>
               .filter(m =>
                 m.value?.content?.type === 'curriculum' &&
                 m.value?.content?.type === 'curriculum' &&
-                m.value?.content?.author === authorId &&
+                m.value.content.author === authorId &&
                 !tombstoned.has(m.key)
                 !tombstoned.has(m.key)
               )
               )
               .sort((a, b) => b.value.timestamp - a.value.timestamp);
               .sort((a, b) => b.value.timestamp - a.value.timestamp);
 
 
-            if (!cvMsgs.length) return resolve(null);
+            if (!cvMsgs.length) {
+              return resolve(null);
+            }
 
 
             const latest = cvMsgs[0];
             const latest = cvMsgs[0];
             resolve({ id: latest.key, ...latest.value.content });
             resolve({ id: latest.key, ...latest.value.content });

+ 3 - 1
src/models/documents_model.js

@@ -1,4 +1,6 @@
 const pull = require('../server/node_modules/pull-stream')
 const pull = require('../server/node_modules/pull-stream')
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 
 const extractBlobId = str => {
 const extractBlobId = str => {
   if (!str || typeof str !== 'string') return null
   if (!str || typeof str !== 'string') return null
@@ -83,7 +85,7 @@ module.exports = ({ cooler }) => {
       const userId = ssbClient.id
       const userId = ssbClient.id
       const messages = await new Promise((res, rej) => {
       const messages = await new Promise((res, rej) => {
         pull(
         pull(
-          ssbClient.createLogStream(),
+          ssbClient.createLogStream({ limit: logLimit }),
           pull.collect((err, msgs) => err ? rej(err) : res(msgs))
           pull.collect((err, msgs) => err ? rej(err) : res(msgs))
         )
         )
       })
       })

+ 20 - 14
src/models/events_model.js

@@ -1,6 +1,8 @@
 const pull = require('../server/node_modules/pull-stream');
 const pull = require('../server/node_modules/pull-stream');
 const moment = require('../server/node_modules/moment');
 const moment = require('../server/node_modules/moment');
 const { config } = require('../server/SSB_server.js');
 const { config } = require('../server/SSB_server.js');
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 
 const userId = config.keys.id;
 const userId = config.keys.id;
 
 
@@ -162,31 +164,35 @@ module.exports = ({ cooler }) => {
       });
       });
     },
     },
 
 
-async listAll(author = null, filter = 'all') {
-  const ssbClient = await openSsb();
-  return new Promise((resolve, reject) => {
-    pull(
-      ssbClient.createLogStream(),
+    async listAll(author = null, filter = 'all') {
+      const ssbClient = await openSsb();
+      return new Promise((resolve, reject) => {
+      pull(
+      ssbClient.createLogStream({ limit: logLimit }),
       pull.collect((err, results) => {
       pull.collect((err, results) => {
         if (err) return reject(new Error("Error listing events: " + err.message));
         if (err) return reject(new Error("Error listing events: " + err.message));
         const tombstoned = new Set();
         const tombstoned = new Set();
         const replaces = new Map();
         const replaces = new Map();
         const byId = new Map();
         const byId = new Map();
+
         for (const r of results) {
         for (const r of results) {
           const k = r.key;
           const k = r.key;
           const c = r.value.content;
           const c = r.value.content;
           if (!c) continue;
           if (!c) continue;
+
           if (c.type === 'tombstone' && c.target) {
           if (c.type === 'tombstone' && c.target) {
             tombstoned.add(c.target);
             tombstoned.add(c.target);
             continue;
             continue;
           }
           }
+
           if (c.type === 'event') {
           if (c.type === 'event') {
-            if (tombstoned.has(k)) continue;
             if (c.replaces) replaces.set(c.replaces, k);
             if (c.replaces) replaces.set(c.replaces, k);
             if (author && c.organizer !== author) continue;
             if (author && c.organizer !== author) continue;
+
             let status = c.status || 'OPEN';
             let status = c.status || 'OPEN';
             const dateM = moment(c.date);
             const dateM = moment(c.date);
             if (dateM.isValid() && dateM.isBefore(moment())) status = 'CLOSED';
             if (dateM.isValid() && dateM.isBefore(moment())) status = 'CLOSED';
+
             byId.set(k, {
             byId.set(k, {
               id: k,
               id: k,
               title: c.title,
               title: c.title,
@@ -204,19 +210,19 @@ async listAll(author = null, filter = 'all') {
             });
             });
           }
           }
         }
         }
-        for (const replaced of replaces.keys()) {
-          byId.delete(replaced);
-        }
+        replaces.forEach((_, oldId) => byId.delete(oldId));
+        tombstoned.forEach((id) => byId.delete(id));
+
         let out = Array.from(byId.values());
         let out = Array.from(byId.values());
         if (filter === 'mine') out = out.filter(e => e.organizer === userId);
         if (filter === 'mine') out = out.filter(e => e.organizer === userId);
         if (filter === 'open') out = out.filter(e => e.status === 'OPEN');
         if (filter === 'open') out = out.filter(e => e.status === 'OPEN');
         if (filter === 'closed') out = out.filter(e => e.status === 'CLOSED');
         if (filter === 'closed') out = out.filter(e => e.status === 'CLOSED');
         resolve(out);
         resolve(out);
-      })
-    );
-  });
-}
-    
+        })
+       );
+     });
+    }
+
   };
   };
 };
 };
 
 

+ 3 - 1
src/models/feed_model.js

@@ -1,4 +1,6 @@
 const pull = require('../server/node_modules/pull-stream');
 const pull = require('../server/node_modules/pull-stream');
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 
 module.exports = ({ cooler }) => {
 module.exports = ({ cooler }) => {
   let ssb;
   let ssb;
@@ -77,7 +79,7 @@ module.exports = ({ cooler }) => {
     const now = Date.now();
     const now = Date.now();
     const messages = await new Promise((res, rej) => {
     const messages = await new Promise((res, rej) => {
       pull(
       pull(
-        ssbClient.createLogStream(),
+        ssbClient.createLogStream({ limit: logLimit }),
         pull.collect((err, msgs) => err ? rej(err) : res(msgs))
         pull.collect((err, msgs) => err ? rej(err) : res(msgs))
       );
       );
     });
     });

+ 11 - 5
src/models/forum_model.js

@@ -1,4 +1,6 @@
 const pull = require('../server/node_modules/pull-stream');
 const pull = require('../server/node_modules/pull-stream');
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 
 module.exports = ({ cooler }) => {
 module.exports = ({ cooler }) => {
   let ssb, userId;
   let ssb, userId;
@@ -15,7 +17,7 @@ module.exports = ({ cooler }) => {
     return new Promise((resolve, reject) => {
     return new Promise((resolve, reject) => {
       const tomb = new Set();
       const tomb = new Set();
       pull(
       pull(
-        ssbClient.createLogStream(),
+        ssbClient.createLogStream({ limit: logLimit }),
         pull.filter(m => m.value.content?.type === 'tombstone' && m.value.content.target),
         pull.filter(m => m.value.content?.type === 'tombstone' && m.value.content.target),
         pull.drain(m => tomb.add(m.value.content.target), err => err ? reject(err) : resolve(tomb))
         pull.drain(m => tomb.add(m.value.content.target), err => err ? reject(err) : resolve(tomb))
       );
       );
@@ -68,7 +70,8 @@ module.exports = ({ cooler }) => {
   async function getMessageById(id) {
   async function getMessageById(id) {
     const ssbClient = await openSsb();
     const ssbClient = await openSsb();
     const msgs = await new Promise((res, rej) =>
     const msgs = await new Promise((res, rej) =>
-      pull(ssbClient.createLogStream(), pull.collect((err, data) => err ? rej(err) : res(data)))
+      pull(ssbClient.createLogStream({ limit: logLimit }), 
+      pull.collect((err, data) => err ? rej(err) : res(data)))
     );
     );
     const msg = msgs.find(m => m.key === id && m.value.content?.type === 'forum');
     const msg = msgs.find(m => m.key === id && m.value.content?.type === 'forum');
     if (!msg) throw new Error('Message not found');
     if (!msg) throw new Error('Message not found');
@@ -154,7 +157,8 @@ module.exports = ({ cooler }) => {
     listAll: async filter => {
     listAll: async filter => {
       const ssbClient = await openSsb();
       const ssbClient = await openSsb();
       const msgs = await new Promise((res, rej) =>
       const msgs = await new Promise((res, rej) =>
-        pull(ssbClient.createLogStream(), pull.collect((err, data) => err ? rej(err) : res(data)))
+        pull(ssbClient.createLogStream({ limit: logLimit }), 
+        pull.collect((err, data) => err ? rej(err) : res(data)))
       );
       );
       const deleted = new Set(
       const deleted = new Set(
         msgs.filter(m => m.value.content?.type === 'tombstone').map(m => m.value.content.target)
         msgs.filter(m => m.value.content?.type === 'tombstone').map(m => m.value.content.target)
@@ -220,7 +224,8 @@ module.exports = ({ cooler }) => {
     getForumById: async id => {
     getForumById: async id => {
       const ssbClient = await openSsb();
       const ssbClient = await openSsb();
       const msgs = await new Promise((res, rej) =>
       const msgs = await new Promise((res, rej) =>
-        pull(ssbClient.createLogStream(), pull.collect((err, data) => err ? rej(err) : res(data)))
+        pull(ssbClient.createLogStream({ limit: logLimit }), 
+        pull.collect((err, data) => err ? rej(err) : res(data)))
       );
       );
       const deleted = new Set(
       const deleted = new Set(
         msgs.filter(m => m.value.content?.type === 'tombstone').map(m => m.value.content.target)
         msgs.filter(m => m.value.content?.type === 'tombstone').map(m => m.value.content.target)
@@ -241,7 +246,8 @@ module.exports = ({ cooler }) => {
     getMessagesByForumId: async forumId => {
     getMessagesByForumId: async forumId => {
       const ssbClient = await openSsb();
       const ssbClient = await openSsb();
       const msgs = await new Promise((res, rej) =>
       const msgs = await new Promise((res, rej) =>
-        pull(ssbClient.createLogStream(), pull.collect((err, data) => err ? rej(err) : res(data)))
+        pull(ssbClient.createLogStream({ limit: logLimit }), 
+        pull.collect((err, data) => err ? rej(err) : res(data)))
       );
       );
       const deleted = new Set(
       const deleted = new Set(
         msgs.filter(m => m.value.content?.type === 'tombstone').map(m => m.value.content.target)
         msgs.filter(m => m.value.content?.type === 'tombstone').map(m => m.value.content.target)

+ 38 - 31
src/models/images_model.js

@@ -1,4 +1,6 @@
 const pull = require('../server/node_modules/pull-stream')
 const pull = require('../server/node_modules/pull-stream')
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 
 module.exports = ({ cooler }) => {
 module.exports = ({ cooler }) => {
   let ssb
   let ssb
@@ -44,16 +46,16 @@ module.exports = ({ cooler }) => {
           const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(Boolean) : oldMsg.content.tags
           const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(Boolean) : oldMsg.content.tags
           const match = blobMarkdown?.match(/\(([^)]+)\)/)
           const match = blobMarkdown?.match(/\(([^)]+)\)/)
           const blobId = match ? match[1] : blobMarkdown
           const blobId = match ? match[1] : blobMarkdown
-          const updated = {
-            ...oldMsg.content,
-            replaces: id,
-            url: blobId || oldMsg.content.url,
-            tags,
-            title: title || '',
-            description: description || '',
-            meme: !!meme,
-            updatedAt: new Date().toISOString()
-          }
+	  const updated = {
+	    ...oldMsg.content,
+	    replaces: id,
+	    url: blobId || oldMsg.content.url,
+	    tags,
+	    title: title ?? oldMsg.content.title,
+	    description: description ?? oldMsg.content.description,
+	    meme: meme != null ? !!meme : !!oldMsg.content.meme,
+	    updatedAt: new Date().toISOString()
+	  }
           ssbClient.publish(updated, (err2, result) => err2 ? reject(err2) : resolve(result))
           ssbClient.publish(updated, (err2, result) => err2 ? reject(err2) : resolve(result))
         })
         })
       })
       })
@@ -61,19 +63,28 @@ module.exports = ({ cooler }) => {
 
 
     async deleteImageById(id) {
     async deleteImageById(id) {
       const ssbClient = await openSsb()
       const ssbClient = await openSsb()
-      return new Promise((resolve, reject) => {
-        ssbClient.get(id, (err, msg) => {
-          if (err || !msg || msg.content?.type !== 'image') return reject(new Error('Image not found'))
-          if (msg.content.author !== userId) return reject(new Error('Not the author'))
-          const tombstone = {
-            type: 'tombstone',
-            target: id,
-            deletedAt: new Date().toISOString(),
-            author: userId
-          }
-          ssbClient.publish(tombstone, (err2, res) => err2 ? reject(err2) : resolve(res))
-        })
+      const author = ssbClient.id
+      const getMsg = (mid) => new Promise((resolve, reject) => {
+        ssbClient.get(mid, (err, msg) => err || !msg ? reject(new Error('Image not found')) : resolve(msg))
       })
       })
+      const publishTomb = (target) => new Promise((resolve, reject) => {
+        ssbClient.publish({
+          type: 'tombstone',
+          target,
+          deletedAt: new Date().toISOString(),
+          author
+        }, (err, res) => err ? reject(err) : resolve(res))
+      })
+      const tip = await getMsg(id)
+      if (tip.content?.type !== 'image') throw new Error('Image not found')
+      if (tip.content.author !== author) throw new Error('Not the author')
+      let currentId = id
+      while (currentId) {
+        const msg = await getMsg(currentId)
+        await publishTomb(currentId)
+        currentId = msg.content?.replaces || null
+      }
+      return { ok: true }
     },
     },
 
 
     async listAll(filter = 'all') {
     async listAll(filter = 'all') {
@@ -81,26 +92,23 @@ module.exports = ({ cooler }) => {
       const userId = ssbClient.id
       const userId = ssbClient.id
       const messages = await new Promise((res, rej) => {
       const messages = await new Promise((res, rej) => {
         pull(
         pull(
-          ssbClient.createLogStream(),
+          ssbClient.createLogStream({ limit: logLimit }),
           pull.collect((err, msgs) => err ? rej(err) : res(msgs))
           pull.collect((err, msgs) => err ? rej(err) : res(msgs))
         )
         )
       })
       })
-
       const tombstoned = new Set(
       const tombstoned = new Set(
         messages
         messages
           .filter(m => m.value.content?.type === 'tombstone')
           .filter(m => m.value.content?.type === 'tombstone')
           .map(m => m.value.content.target)
           .map(m => m.value.content.target)
       )
       )
-
       const replaces = new Map()
       const replaces = new Map()
       const latest = new Map()
       const latest = new Map()
-
       for (const m of messages) {
       for (const m of messages) {
         const k = m.key
         const k = m.key
         const c = m.value?.content
         const c = m.value?.content
         if (!c || c.type !== 'image') continue
         if (!c || c.type !== 'image') continue
-        if (tombstoned.has(k)) continue
         if (c.replaces) replaces.set(c.replaces, k)
         if (c.replaces) replaces.set(c.replaces, k)
+        if (tombstoned.has(k)) continue
         latest.set(k, {
         latest.set(k, {
           key: k,
           key: k,
           url: c.url,
           url: c.url,
@@ -115,13 +123,13 @@ module.exports = ({ cooler }) => {
           opinions_inhabitants: c.opinions_inhabitants || []
           opinions_inhabitants: c.opinions_inhabitants || []
         })
         })
       }
       }
-
       for (const oldId of replaces.keys()) {
       for (const oldId of replaces.keys()) {
         latest.delete(oldId)
         latest.delete(oldId)
       }
       }
-
+      for (const delId of tombstoned) {
+        latest.delete(delId)
+      }
       let images = Array.from(latest.values())
       let images = Array.from(latest.values())
-
       if (filter === 'mine') {
       if (filter === 'mine') {
         images = images.filter(img => img.author === userId)
         images = images.filter(img => img.author === userId)
       } else if (filter === 'recent') {
       } else if (filter === 'recent') {
@@ -138,7 +146,6 @@ module.exports = ({ cooler }) => {
       } else {
       } else {
         images = images.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
         images = images.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
       }
       }
-
       return images
       return images
     },
     },
 
 

+ 36 - 47
src/models/inhabitants_model.js

@@ -6,6 +6,8 @@ const { about, friend } = models({
   cooler: coolerInstance,
   cooler: coolerInstance,
   isPublic: require('../server/ssb_config').public,
   isPublic: require('../server/ssb_config').public,
 });
 });
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 
 module.exports = ({ cooler }) => {
 module.exports = ({ cooler }) => {
   let ssb;
   let ssb;
@@ -23,11 +25,10 @@ module.exports = ({ cooler }) => {
           timeoutPromise(5000) 
           timeoutPromise(5000) 
         ]).catch(() => '/assets/images/default-avatar.png'); 
         ]).catch(() => '/assets/images/default-avatar.png'); 
       };
       };
-
       if (filter === 'GALLERY') {
       if (filter === 'GALLERY') {
         const feedIds = await new Promise((res, rej) => {
         const feedIds = await new Promise((res, rej) => {
           pull(
           pull(
-            ssbClient.createLogStream(),
+            ssbClient.createLogStream({ limit: logLimit }),
             pull.filter(msg => {
             pull.filter(msg => {
               const c = msg.value?.content;
               const c = msg.value?.content;
               const a = msg.value?.author;
               const a = msg.value?.author;
@@ -43,7 +44,6 @@ module.exports = ({ cooler }) => {
         });
         });
 
 
         const uniqueFeedIds = Array.from(new Set(feedIds.map(r => r.value.author).filter(Boolean)));
         const uniqueFeedIds = Array.from(new Set(feedIds.map(r => r.value.author).filter(Boolean)));
-
         const users = await Promise.all(
         const users = await Promise.all(
           uniqueFeedIds.map(async (feedId) => {
           uniqueFeedIds.map(async (feedId) => {
             const name = await about.name(feedId);
             const name = await about.name(feedId);
@@ -56,14 +56,12 @@ module.exports = ({ cooler }) => {
             return { id: feedId, name, description, photo };
             return { id: feedId, name, description, photo };
           })
           })
         );
         );
-
         return users;
         return users;
       }
       }
-
       if (filter === 'all') {
       if (filter === 'all') {
         const feedIds = await new Promise((res, rej) => {
         const feedIds = await new Promise((res, rej) => {
           pull(
           pull(
-            ssbClient.createLogStream(),
+            ssbClient.createLogStream({ limit: logLimit }),
             pull.filter(msg => {
             pull.filter(msg => {
               const c = msg.value?.content;
               const c = msg.value?.content;
               const a = msg.value?.author;
               const a = msg.value?.author;
@@ -79,7 +77,6 @@ module.exports = ({ cooler }) => {
         });
         });
 
 
         const uniqueFeedIds = Array.from(new Set(feedIds.map(r => r.value.author).filter(Boolean)));
         const uniqueFeedIds = Array.from(new Set(feedIds.map(r => r.value.author).filter(Boolean)));
-
         const users = await Promise.all(
         const users = await Promise.all(
           uniqueFeedIds.map(async (feedId) => {
           uniqueFeedIds.map(async (feedId) => {
             const name = await about.name(feedId);
             const name = await about.name(feedId);
@@ -93,10 +90,8 @@ module.exports = ({ cooler }) => {
             return { id: feedId, name, description, photo };
             return { id: feedId, name, description, photo };
           })
           })
         );
         );
-
         const deduplicated = Array.from(new Map(users.filter(u => u && u.id).map(u => [u.id, u])).values());
         const deduplicated = Array.from(new Map(users.filter(u => u && u.id).map(u => [u.id, u])).values());
         let filtered = deduplicated;
         let filtered = deduplicated;
-
         if (search) {
         if (search) {
           const q = search.toLowerCase();
           const q = search.toLowerCase();
           filtered = filtered.filter(u =>
           filtered = filtered.filter(u =>
@@ -105,10 +100,8 @@ module.exports = ({ cooler }) => {
             u.id?.toLowerCase().includes(q)
             u.id?.toLowerCase().includes(q)
           );
           );
         }
         }
-
         return filtered;
         return filtered;
       }
       }
-
       if (filter === 'contacts') {
       if (filter === 'contacts') {
         const all = await this.listInhabitants({ filter: 'all' });
         const all = await this.listInhabitants({ filter: 'all' });
         const result = [];
         const result = [];
@@ -118,7 +111,6 @@ module.exports = ({ cooler }) => {
         }
         }
         return Array.from(new Map(result.map(u => [u.id, u])).values());
         return Array.from(new Map(result.map(u => [u.id, u])).values());
       }
       }
-
       if (filter === 'blocked') {
       if (filter === 'blocked') {
         const all = await this.listInhabitants({ filter: 'all' });
         const all = await this.listInhabitants({ filter: 'all' });
         const result = [];
         const result = [];
@@ -128,7 +120,6 @@ module.exports = ({ cooler }) => {
         }
         }
         return Array.from(new Map(result.map(u => [u.id, u])).values());
         return Array.from(new Map(result.map(u => [u.id, u])).values());
       }
       }
-
       if (filter === 'SUGGESTED') {
       if (filter === 'SUGGESTED') {
         const all = await this.listInhabitants({ filter: 'all' });
         const all = await this.listInhabitants({ filter: 'all' });
         const result = [];
         const result = [];
@@ -143,11 +134,10 @@ module.exports = ({ cooler }) => {
         return Array.from(new Map(result.map(u => [u.id, u])).values())
         return Array.from(new Map(result.map(u => [u.id, u])).values())
           .sort((a, b) => (b.mutualCount || 0) - (a.mutualCount || 0));
           .sort((a, b) => (b.mutualCount || 0) - (a.mutualCount || 0));
       }
       }
-
       if (filter === 'CVs' || filter === 'MATCHSKILLS') {
       if (filter === 'CVs' || filter === 'MATCHSKILLS') {
         const records = await new Promise((res, rej) => {
         const records = await new Promise((res, rej) => {
           pull(
           pull(
-            ssbClient.createLogStream(),
+            ssbClient.createLogStream({ limit: logLimit }),
             pull.filter(msg =>
             pull.filter(msg =>
               msg.value.content?.type === 'curriculum' &&
               msg.value.content?.type === 'curriculum' &&
               msg.value.content?.type !== 'tombstone'
               msg.value.content?.type !== 'tombstone'
@@ -180,7 +170,6 @@ module.exports = ({ cooler }) => {
           }
           }
           return cvs;
           return cvs;
         }
         }
-
         if (filter === 'MATCHSKILLS') {
         if (filter === 'MATCHSKILLS') {
           const cv = await this.getCVByUserId();
           const cv = await this.getCVByUserId();
           const userSkills = cv
           const userSkills = cv
@@ -196,12 +185,12 @@ module.exports = ({ cooler }) => {
             if (c.id === userId) return null;
             if (c.id === userId) return null;
             const common = c.skills.map(s => s.toLowerCase()).filter(s => userSkills.includes(s));
             const common = c.skills.map(s => s.toLowerCase()).filter(s => userSkills.includes(s));
             if (!common.length) return null;
             if (!common.length) return null;
-            return { ...c, commonSkills: common };
+            const matchScore = common.length / userSkills.length;
+            return { ...c, commonSkills: common, matchScore };
           }).filter(Boolean);
           }).filter(Boolean);
-          return matches.sort((a, b) => b.commonSkills.length - a.commonSkills.length);
+          return matches.sort((a, b) => b.matchScore - a.matchScore);
         }
         }
       }
       }
-
       return [];
       return [];
     },
     },
 
 
@@ -229,26 +218,10 @@ module.exports = ({ cooler }) => {
         createdAt: c.createdAt
         createdAt: c.createdAt
       };
       };
     },
     },
-
-    async getCVByUserId(id) {
-      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.content?.type === 'curriculum' &&
-            msg.value.content?.type !== 'tombstone'
-          ),
-          pull.collect((err, msgs) => err ? rej(err) : res(msgs))
-        );
-      });
-      return records.length ? records[records.length - 1].value.content : null;
-    },
-
-    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(
         pull(
           ssbClient.createUserStream({ id }),
           ssbClient.createUserStream({ id }),
           pull.filter(msg =>
           pull.filter(msg =>
@@ -262,26 +235,42 @@ module.exports = ({ cooler }) => {
       const latest = records.sort((a, b) => b.value.timestamp - a.value.timestamp)[0];
       const latest = records.sort((a, b) => b.value.timestamp - a.value.timestamp)[0];
       return latest.value.content;
       return latest.value.content;
     },
     },
-
+    
     async getFeedByUserId(id) {
     async getFeedByUserId(id) {
+      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);
+    },
+
+    async getCVByUserId(id) {
       const ssbClient = await openSsb();
       const ssbClient = await openSsb();
       const targetId = id || ssbClient.id;
       const targetId = id || ssbClient.id;
       const records = await new Promise((res, rej) => {
       const records = await new Promise((res, rej) => {
         pull(
         pull(
           ssbClient.createUserStream({ id: targetId }),
           ssbClient.createUserStream({ id: targetId }),
           pull.filter(msg =>
           pull.filter(msg =>
-            msg.value &&
-            msg.value.content &&
-            typeof msg.value.content.text === 'string' &&
+            msg.value.content?.type === 'curriculum' &&
             msg.value.content?.type !== 'tombstone'
             msg.value.content?.type !== 'tombstone'
           ),
           ),
           pull.collect((err, msgs) => err ? rej(err) : res(msgs))
           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);
+      return records.length ? records[records.length - 1].value.content : null;
     }
     }
   };
   };
 };
 };

+ 209 - 0
src/models/jobs_model.js

@@ -0,0 +1,209 @@
+const pull = require('../server/node_modules/pull-stream')
+const moment = require('../server/node_modules/moment')
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
+
+module.exports = ({ cooler }) => {
+  let ssb
+  const openSsb = async () => {
+    if (!ssb) ssb = await cooler.open()
+    return ssb
+  }
+
+  return {
+    type: 'job',
+
+    async createJob(jobData) {
+      const ssbClient = await openSsb()
+      let blobId = jobData.image
+      if (blobId && /\(([^)]+)\)/.test(blobId)) blobId = blobId.match(/\(([^)]+)\)/)[1]
+      const content = {
+        type: 'job',
+        ...jobData,
+        image: blobId,
+        author: ssbClient.id,
+        createdAt: new Date().toISOString(),
+        status: 'OPEN',
+        subscribers: []
+      }
+      return new Promise((res, rej) => ssbClient.publish(content, (e, m) => e ? rej(e) : res(m)))
+    },
+
+    async updateJob(id, jobData) {
+      const ssbClient = await openSsb()
+      const job = await this.getJobById(id)
+      if (job.author !== ssbClient.id) throw new Error('Unauthorized')
+      let blobId = jobData.image || job.image
+      if (blobId && /\(([^)]+)\)/.test(blobId)) blobId = blobId.match(/\(([^)]+)\)/)[1]
+      const tomb = { type: 'tombstone', target: job.id, deletedAt: new Date().toISOString(), author: ssbClient.id }
+      const updated = {
+        type: 'job',
+        ...job,
+        ...jobData,
+        image: blobId,
+        updatedAt: new Date().toISOString(),
+        replaces: job.id
+      }
+      await new Promise((res, rej) => ssbClient.publish(tomb, e => e ? rej(e) : res()))
+      return new Promise((res, rej) => ssbClient.publish(updated, (e, m) => e ? rej(e) : res(m)))
+    },
+
+    async updateJobStatus(id, status) {
+      return this.updateJob(id, { status })
+    },
+
+    async deleteJob(id) {
+      const ssbClient = await openSsb();
+      const latestId = await this.getJobTipId(id);
+      const job = await this.getJobById(latestId);
+      if (job.author !== ssbClient.id) throw new Error('Unauthorized');
+      const tomb = {
+        type: 'tombstone',
+        target: latestId,
+        deletedAt: new Date().toISOString(),
+        author: ssbClient.id
+      };
+      return new Promise((res, rej) =>
+        ssbClient.publish(tomb, (e, r) => e ? rej(e) : res(r))
+      );
+    },
+
+    async listJobs(filter) {
+      const ssbClient = await openSsb();
+      const currentUserId = ssbClient.id;
+      return new Promise((res, rej) => {
+      pull(
+      ssbClient.createLogStream({ limit: logLimit }),
+      pull.collect((e, msgs) => {
+        if (e) return rej(e);
+        const tomb = new Set();
+        const replaces = new Map();
+        const referencedAsReplaces = new Set();
+        const jobs = new Map();
+        msgs.forEach(m => {
+          const k = m.key;
+          const c = m.value.content;
+          if (!c) return;
+          if (c.type === 'tombstone' && c.target) { tomb.add(c.target); return; }
+          if (c.type !== 'job') return;
+          if (c.replaces) { replaces.set(c.replaces, k); referencedAsReplaces.add(c.replaces); }
+          jobs.set(k, { key: k, content: c });
+        });
+        const tipJobs = [];
+        for (const [id, job] of jobs.entries()) {
+          if (!referencedAsReplaces.has(id)) tipJobs.push(job);
+        }
+        const groups = {};
+        for (const job of tipJobs) {
+          const ancestor = job.content.replaces || job.key;
+          if (!groups[ancestor]) groups[ancestor] = [];
+          groups[ancestor].push(job);
+        }
+
+        const liveTipIds = new Set();
+        for (const groupJobs of Object.values(groups)) {
+          let best = groupJobs[0];
+          for (const job of groupJobs) {
+            if (
+              job.content.status === 'CLOSED' ||
+              (best.content.status !== 'CLOSED' &&
+               new Date(job.content.updatedAt || job.content.createdAt || 0) >
+               new Date(best.content.updatedAt || best.content.createdAt || 0))
+            ) {
+              best = job;
+            }
+          }
+          liveTipIds.add(best.key);
+        }
+        let list = Array.from(jobs.values())
+          .filter(j => liveTipIds.has(j.key) && !tomb.has(j.key))
+          .map(j => ({ id: j.key, ...j.content }));
+        const F = String(filter).toUpperCase();
+        if (F === 'MINE')           list = list.filter(j => j.author === currentUserId);
+        else if (F === 'REMOTE')    list = list.filter(j => (j.location||'').toUpperCase() === 'REMOTE');
+        else if (F === 'PRESENCIAL')list = list.filter(j => (j.location||'').toUpperCase() === 'PRESENCIAL');
+        else if (F === 'FREELANCER')list = list.filter(j => (j.job_type||'').toUpperCase() === 'FREELANCER');
+        else if (F === 'EMPLOYEE')  list = list.filter(j => (j.job_type||'').toUpperCase() === 'EMPLOYEE');
+        else if (F === 'OPEN')      list = list.filter(j => (j.status||'').toUpperCase() === 'OPEN');
+        else if (F === 'CLOSED')    list = list.filter(j => (j.status||'').toUpperCase() === 'CLOSED');
+        else if (F === 'RECENT')    list = list.filter(j => moment(j.createdAt).isAfter(moment().subtract(24, 'hours')));
+        if (F === 'TOP') list.sort((a, b) => parseFloat(b.salary||0) - parseFloat(a.salary||0));
+        else list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
+        res(list);
+        })
+        );
+      });
+    },
+
+    async getJobById(id) {
+      const ssbClient = await openSsb();
+      const all = await new Promise((r, j) => {
+        pull(
+            ssbClient.createLogStream({ limit: logLimit }),
+            pull.collect((e, m) => e ? j(e) : r(m))
+        )
+      });
+      const tomb = new Set();
+      const replaces = new Map();
+      all.forEach(m => {
+        const c = m.value.content;
+        if (!c) return;
+        if (c.type === 'tombstone' && c.target) {
+            tomb.add(c.target);
+        } else if (c.type === 'job' && c.replaces) {
+            replaces.set(c.replaces, m.key);
+        }
+      });
+      let key = id;
+      while (replaces.has(key)) key = replaces.get(key);
+      if (tomb.has(key)) throw new Error('Job not found');
+      const msg = await new Promise((r, j) => ssbClient.get(key, (e, m) => e ? j(e) : r(m)));
+      if (!msg) throw new Error('Job not found');
+      return { id: key, ...msg.content };
+    },
+    
+    async getJobTipId(id) {
+      const ssbClient = await openSsb();
+      const all = await new Promise((r, j) => {
+        pull(
+            ssbClient.createLogStream({ limit: logLimit }),
+            pull.collect((e, m) => e ? j(e) : r(m))
+        )
+    });
+      const tomb = new Set();
+      const replaces = new Map();
+      all.forEach(m => {
+        const c = m.value.content;
+        if (!c) return;
+        if (c.type === 'tombstone' && c.target) {
+            tomb.add(c.target);
+        } else if (c.type === 'job' && c.replaces) {
+            replaces.set(c.replaces, m.key);
+        }
+    });
+    let key = id;
+    while (replaces.has(key)) key = replaces.get(key);
+    if (tomb.has(key)) throw new Error('Job not found');
+      return key;
+    },
+
+    async subscribeToJob(id, userId) {
+      const latestId = await this.getJobTipId(id);
+      const job = await this.getJobById(latestId);
+      if (!job.subscribers) job.subscribers = [];
+      if (job.subscribers.includes(userId)) throw new Error('Already subscribed');
+      job.subscribers.push(userId);
+      return this.updateJob(latestId, { subscribers: job.subscribers });
+    },
+
+    async unsubscribeFromJob(id, userId) {
+      const latestId = await this.getJobTipId(id);
+      const job = await this.getJobById(latestId);
+      if (!job.subscribers) job.subscribers = [];
+      if (!job.subscribers.includes(userId)) throw new Error('Not subscribed');
+      job.subscribers = job.subscribers.filter(uid => uid !== userId);
+      return this.updateJob(latestId, { subscribers: job.subscribers });
+    }
+    
+  }
+}

+ 5 - 2
src/models/main_models.js

@@ -15,6 +15,9 @@ const os = require('os');
 
 
 const ssbRef = require("../server/node_modules/ssb-ref");
 const ssbRef = require("../server/node_modules/ssb-ref");
 
 
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
+
 const isEncrypted = (message) => typeof message.value.content === "string";
 const isEncrypted = (message) => typeof message.value.content === "string";
 const isNotEncrypted = (message) => isEncrypted(message) === false;
 const isNotEncrypted = (message) => isEncrypted(message) === false;
 
 
@@ -583,7 +586,7 @@ models.meta = {
     query,
     query,
     filter = null,
     filter = null,
   }) => {
   }) => {
-    const source = ssb.createLogStream({ reverse: true, limit: 100 });
+    const source = ssb.createLogStream({ reverse: true,  limit: logLimit });
 
 
     return new Promise((resolve, reject) => {
     return new Promise((resolve, reject) => {
       pull(
       pull(
@@ -1705,7 +1708,7 @@ const post = {
       const myFeedId = ssb.id;
       const myFeedId = ssb.id;
       const rawMessages = await new Promise((resolve, reject) => {
       const rawMessages = await new Promise((resolve, reject) => {
         pull(
         pull(
-          ssb.createLogStream({ reverse: true, limit: 1000 }),
+          ssb.createLogStream({ reverse: true, limit: logLimit }),
           pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs)))
           pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs)))
         );
         );
       });
       });

+ 305 - 259
src/models/market_model.js

@@ -1,5 +1,12 @@
 const pull = require('../server/node_modules/pull-stream');
 const pull = require('../server/node_modules/pull-stream');
 const moment = require('../server/node_modules/moment');
 const moment = require('../server/node_modules/moment');
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
+
+const N = s => String(s || '').toUpperCase().replace(/\s+/g, '_');
+const D = s => ({FOR_SALE:'FOR SALE',OPEN:'OPEN',RESERVED:'RESERVED',CLOSED:'CLOSED',SOLD:'SOLD'})[s] || (s ? s.replace(/_/g,' ') : s);
+const ORDER = ['FOR_SALE','OPEN','RESERVED','CLOSED','SOLD'];
+const OI = s => ORDER.indexOf(N(s));
 
 
 module.exports = ({ cooler }) => {
 module.exports = ({ cooler }) => {
   let ssb;
   let ssb;
@@ -8,61 +15,69 @@ module.exports = ({ cooler }) => {
   return {
   return {
     type: 'market',
     type: 'market',
 
 
-  async createItem(item_type, title, description, image, price, tagsRaw = [], item_status, deadline, includesShipping = false, stock = 0) {
-    const ssbClient = await openSsb();
-    const userId = ssbClient.id;
-    const formattedDeadline = deadline ? moment(deadline, moment.ISO_8601, true).toISOString() : null;
-    let blobId = null;
-    if (image) {
-      const match = image.match(/\(([^)]+)\)/);
-      blobId = match ? match[1] : image;
-    }
-    const tags = Array.isArray(tagsRaw) ? tagsRaw.filter(Boolean) : tagsRaw.split(',').map(t => t.trim()).filter(Boolean);
-    const itemContent = {
-      type: "market",
-      item_type,
-      title,
-      description,
-      image: blobId,
-      price: parseFloat(price).toFixed(6),
-      tags,
-      item_status,
-      status: 'FOR SALE',
-      deadline: formattedDeadline,
-      includesShipping,
-      stock,
-      createdAt: new Date().toISOString(),
-      updatedAt: new Date().toISOString(),
-      seller: userId,
-      auctions_poll: []
-    };
-    return new Promise((resolve, reject) => {
-      ssbClient.publish(itemContent, (err, res) => err ? reject(err) : resolve(res));
-    });
-  },
-
-   async updateItemById(itemId, updatedData) {
+    async createItem(item_type, title, description, image, price, tagsRaw = [], item_status, deadline, includesShipping = false, stock = 0) {
+      const ssbClient = await openSsb();
+      const formattedDeadline = deadline ? moment(deadline, moment.ISO_8601, true).toISOString() : null;
+      let blobId = null;
+      if (image) {
+        const match = image.match(/\(([^)]+)\)/);
+        blobId = match ? match[1] : image;
+      }
+      const tags = Array.isArray(tagsRaw) ? tagsRaw.filter(Boolean) : String(tagsRaw).split(',').map(t => t.trim()).filter(Boolean);
+      const itemContent = {
+        type: "market",
+        item_type,
+        title,
+        description,
+        image: blobId,
+        price: parseFloat(price).toFixed(6),
+        tags,
+        item_status,
+        status: 'FOR SALE',
+        deadline: formattedDeadline,
+        includesShipping,
+        stock,
+        createdAt: new Date().toISOString(),
+        updatedAt: new Date().toISOString(),
+        seller: ssbClient.id,
+        auctions_poll: []
+      };
+      return new Promise((resolve, reject) => {
+        ssbClient.publish(itemContent, (err, res) => err ? reject(err) : resolve(res));
+      });
+    },
+
+    async resolveCurrentId(itemId) {
+      const ssbClient = await openSsb();
+      const messages = await new Promise((resolve, reject) =>
+        pull(
+          ssbClient.createLogStream({ limit: logLimit }),
+          pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
+        )
+      );
+      const fwd = new Map();
+      for (const m of messages) {
+        const c = m.value?.content;
+        if (!c || c.type !== 'market') continue;
+        if (c.replaces) fwd.set(c.replaces, m.key);
+      }
+      let cur = itemId;
+      while (fwd.has(cur)) cur = fwd.get(cur);
+      return cur;
+    },
+
+    async updateItemById(itemId, updatedData) {
+      const tipId = await this.resolveCurrentId(itemId);
       const ssbClient = await openSsb();
       const ssbClient = await openSsb();
       const userId = ssbClient.id;
       const userId = ssbClient.id;
       return new Promise((resolve, reject) => {
       return new Promise((resolve, reject) => {
-        ssbClient.get(itemId, (err, item) => {
+        ssbClient.get(tipId, (err, item) => {
           if (err || !item?.content) return reject(new Error("Item not found"));
           if (err || !item?.content) return reject(new Error("Item not found"));
           if (item.content.seller !== userId) return reject(new Error("Not the seller"));
           if (item.content.seller !== userId) return reject(new Error("Not the seller"));
-          if (['SOLD', 'DISCARDED'].includes(item.content.status)) return reject(new Error("Cannot update this item"));
-          const updated = {
-            ...item.content,
-            ...updatedData,
-            tags: updatedData.tags || item.content.tags,
-            updatedAt: new Date().toISOString(),
-            replaces: itemId
-          };
-          const tombstone = {
-            type: 'tombstone',
-            target: itemId,
-            deletedAt: new Date().toISOString(),
-            author: userId
-          };
-          ssbClient.publish(tombstone, (err) => {
+          if (['SOLD','DISCARDED'].includes(D(N(item.content.status)))) return reject(new Error("Cannot update this item"));
+          const updated = { ...item.content, ...updatedData, tags: updatedData.tags || item.content.tags, updatedAt: new Date().toISOString(), replaces: tipId };
+          const tombstone = { type: 'tombstone', target: tipId, deletedAt: new Date().toISOString(), author: userId };
+          ssbClient.publish(tombstone, err => {
             if (err) return reject(err);
             if (err) return reject(err);
             ssbClient.publish(updated, (err2, res) => err2 ? reject(err2) : resolve(res));
             ssbClient.publish(updated, (err2, res) => err2 ? reject(err2) : resolve(res));
           });
           });
@@ -71,18 +86,14 @@ module.exports = ({ cooler }) => {
     },
     },
 
 
     async deleteItemById(itemId) {
     async deleteItemById(itemId) {
+      const tipId = await this.resolveCurrentId(itemId);
       const ssbClient = await openSsb();
       const ssbClient = await openSsb();
       const userId = ssbClient.id;
       const userId = ssbClient.id;
       return new Promise((resolve, reject) => {
       return new Promise((resolve, reject) => {
-        ssbClient.get(itemId, (err, item) => {
+        ssbClient.get(tipId, (err, item) => {
           if (err || !item?.content) return reject(new Error("Item not found"));
           if (err || !item?.content) return reject(new Error("Item not found"));
           if (item.content.seller !== userId) return reject(new Error("Not the seller"));
           if (item.content.seller !== userId) return reject(new Error("Not the seller"));
-          const tombstone = {
-            type: 'tombstone',
-            target: itemId,
-            deletedAt: new Date().toISOString(),
-            author: userId
-          };
+          const tombstone = { type: 'tombstone', target: tipId, deletedAt: new Date().toISOString(), author: userId };
           ssbClient.publish(tombstone, (err) => err ? reject(err) : resolve({ message: "Item deleted successfully" }));
           ssbClient.publish(tombstone, (err) => err ? reject(err) : resolve({ message: "Item deleted successfully" }));
         });
         });
       });
       });
@@ -91,107 +102,171 @@ module.exports = ({ cooler }) => {
     async listAllItems(filter = 'all') {
     async listAllItems(filter = 'all') {
       const ssbClient = await openSsb();
       const ssbClient = await openSsb();
       const userId = ssbClient.id;
       const userId = ssbClient.id;
-      return new Promise((resolve, reject) => {
+      const messages = await new Promise((resolve, reject) =>
         pull(
         pull(
-          ssbClient.createLogStream(),
-          pull.collect(async (err, results) => {
-            if (err) return reject(new Error("Error listing items: " + err.message));
-            const tombstoned = new Set();
-            const replaces = new Map();
-            const itemsById = new Map();
-            const now = moment();
-            for (const r of results) {
-              const k = r.key;
-              const c = r.value.content;
-              if (!c) continue;
-              if (c.type === 'tombstone' && c.target) {
-                tombstoned.add(c.target);
-                continue;
-              }
-              if (c.type === 'market') {
-                if (tombstoned.has(k)) continue;
-                if (c.replaces) replaces.set(c.replaces, k);
-                let status = c.status || 'FOR SALE';
-                if (c.deadline) {
-                  const deadline = moment(c.deadline);
-                  if (deadline.isValid() && deadline.isBefore(now) && status !== 'SOLD') {
-                    status = 'DISCARDED';
-                  }
-                }
-                if (c.stock === 0 && c.status === 'FOR SALE') continue;
-                itemsById.set(k, {
-                  id: k,
-                  title: c.title,
-                  description: c.description,
-                  image: c.image,
-                  price: c.price,
-                  tags: c.tags || [],
-                  item_status: c.item_status || 'NEW',
-                  status,
-                  createdAt: c.createdAt,
-                  updatedAt: c.updatedAt,
-                  seller: c.seller,
-                  includesShipping: c.includesShipping || false,
-                  stock: c.stock || 0,
-                  deadline: c.deadline,
-                  auctions_poll: c.auctions_poll || [],
-                  item_type: c.item_type
-                });
-              }
-            }
-            for (const replacedId of replaces.keys()) {
-              itemsById.delete(replacedId);
-            }
-            let filteredItems = Array.from(itemsById.values());
-            switch (filter) {
-              case 'mine':
-                filteredItems = filteredItems.filter(e => e.seller === userId);
-                break;
-              case 'exchange':
-                filteredItems = filteredItems.filter(e => e.item_type === 'exchange' && e.status === 'FOR SALE');
-                break;
-              case 'auctions':
-                filteredItems = filteredItems.filter(e => e.item_type === 'auction' && e.status === 'FOR SALE');
-                break;
-              case 'new':
-                filteredItems = filteredItems.filter(e => e.item_status === 'NEW' && e.status === 'FOR SALE');
-                break;
-              case 'used':
-                filteredItems = filteredItems.filter(e => e.item_status === 'USED' && e.status === 'FOR SALE');
-                break;
-              case 'broken':
-                filteredItems = filteredItems.filter(e => e.item_status === 'BROKEN' && e.status === 'FOR SALE');
-                break;
-              case 'for sale':
-                filteredItems = filteredItems.filter(e => e.status === 'FOR SALE');
-                break;
-              case 'sold':
-                filteredItems = filteredItems.filter(e => e.status === 'SOLD');
-                break;
-              case 'discarded':
-                filteredItems = filteredItems.filter(e => e.status === 'DISCARDED');
-                break;
-              default:
-                break;
-            }
-            filteredItems = filteredItems.filter(item => !(item.status === 'FOR SALE' && item.stock === 0));
-            filteredItems = filteredItems.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
-            resolve(filteredItems);
-          })
-        );
-      });
+          ssbClient.createLogStream({ limit: logLimit }),
+          pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
+        )
+      );
+
+      const tomb = new Set();
+      const nodes = new Map();
+      const parent = new Map();
+      const child = new Map();
+
+      for (const m of messages) {
+        const k = m.key;
+        const c = m.value?.content;
+        if (!c) continue;
+        if (c.type === 'tombstone' && c.target) { tomb.add(c.target); continue; }
+        if (c.type !== 'market') continue;
+        nodes.set(k, { key: k, ts: m.value.timestamp, c });
+        if (c.replaces) { parent.set(k, c.replaces); child.set(c.replaces, k); }
+      }
+
+      const rootOf = id => { let cur = id; while (parent.has(cur)) cur = parent.get(cur); return cur; };
+      const groups = new Map();
+      for (const id of nodes.keys()) {
+        const r = rootOf(id);
+        if (!groups.has(r)) groups.set(r, new Set());
+        groups.get(r).add(id);
+      }
+
+      const items = [];
+      for (const [root, ids] of groups.entries()) {
+        let tip = Array.from(ids).find(id => !child.has(id)) || Array.from(ids).reduce((a,b)=> nodes.get(a).ts>nodes.get(b).ts?a:b);
+        if (tomb.has(tip)) continue;
+
+        let best = nodes.get(tip);
+        let bestS = N(best.c.status || 'FOR_SALE');
+        for (const id of ids) {
+          const s = N(nodes.get(id).c.status);
+          if (OI(s) > OI(bestS)) { best = nodes.get(id); bestS = s; }
+        }
+
+        const c = best.c;
+        let status = D(bestS);
+        if (c.deadline) {
+          const dl = moment(c.deadline);
+          if (dl.isValid() && dl.isBefore(moment()) && status !== 'SOLD') status = 'DISCARDED';
+        }
+        if (status === 'FOR SALE' && (c.stock || 0) === 0) continue;
+
+        items.push({
+          id: tip,
+          title: c.title,
+          description: c.description,
+          image: c.image,
+          price: c.price,
+          tags: c.tags || [],
+          item_type: c.item_type,
+          item_status: c.item_status || 'NEW',
+          status,
+          createdAt: c.createdAt || best.ts,
+          updatedAt: c.updatedAt,
+          seller: c.seller,
+          includesShipping: !!c.includesShipping,
+          stock: c.stock || 0,
+          deadline: c.deadline || null,
+          auctions_poll: c.auctions_poll || []
+        });
+      }
+
+      let list = items;
+      switch (filter) {
+        case 'mine':       list = list.filter(i => i.seller === userId); break;
+        case 'exchange':   list = list.filter(i => i.item_type === 'exchange' && i.status === 'FOR SALE'); break;
+        case 'auctions':   list = list.filter(i => i.item_type === 'auction'  && i.status === 'FOR SALE'); break;
+        case 'new':        list = list.filter(i => i.item_status === 'NEW'    && i.status === 'FOR SALE'); break;
+        case 'used':       list = list.filter(i => i.item_status === 'USED'   && i.status === 'FOR SALE'); break;
+        case 'broken':     list = list.filter(i => i.item_status === 'BROKEN' && i.status === 'FOR SALE'); break;
+        case 'for sale':   list = list.filter(i => i.status === 'FOR SALE'); break;
+        case 'sold':       list = list.filter(i => i.status === 'SOLD'); break;
+        case 'discarded':  list = list.filter(i => i.status === 'DISCARDED'); break;
+        case 'recent':
+          const oneDayAgo = moment().subtract(1, 'days');
+          list = list.filter(i => i.status === 'FOR SALE' && moment(i.createdAt).isAfter(oneDayAgo));
+          break;
+      }
+
+      return list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
+    },
+
+    async getItemById(itemId) {
+      const ssbClient = await openSsb();
+      const messages = await new Promise((resolve, reject) =>
+        pull(
+          ssbClient.createLogStream({ limit: logLimit }),
+          pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
+        )
+      );
+
+      const nodes = new Map();
+      const parent = new Map();
+      const child = new Map();
+      for (const m of messages) {
+        const k = m.key;
+        const c = m.value?.content;
+        if (!c || c.type !== 'market') continue;
+        nodes.set(k, { key: k, ts: m.value.timestamp, c });
+        if (c.replaces) { parent.set(k, c.replaces); child.set(c.replaces, k); }
+      }
+
+      let tip = itemId;
+      while (child.has(tip)) tip = child.get(tip);
+
+      const ids = new Set();
+      let cur = tip;
+      ids.add(cur);
+      while (parent.has(cur)) { cur = parent.get(cur); ids.add(cur); }
+
+      let best = nodes.get(tip) || (await new Promise(resolve => ssbClient.get(tip, (e, m) => resolve(m ? { key: tip, ts: m.timestamp, c: m.content } : null))));
+      if (!best) return null;
+      let bestS = N(best.c.status || 'FOR_SALE');
+      for (const id of ids) {
+        const n = nodes.get(id);
+        if (!n) continue;
+        const s = N(n.c.status);
+        if (OI(s) > OI(bestS)) { best = n; bestS = s; }
+      }
+
+      const c = best.c;
+      let status = D(bestS);
+      if (c.deadline) {
+        const dl = moment(c.deadline);
+        if (dl.isValid() && dl.isBefore(moment()) && status !== 'SOLD') status = 'DISCARDED';
+      }
+
+      return {
+        id: tip,
+        title: c.title,
+        description: c.description,
+        image: c.image,
+        price: c.price,
+        tags: c.tags || [],
+        item_type: c.item_type,
+        item_status: c.item_status,
+        status,
+        createdAt: c.createdAt || best.ts,
+        updatedAt: c.updatedAt,
+        seller: c.seller,
+        includesShipping: c.includesShipping,
+        stock: c.stock,
+        deadline: c.deadline,
+        auctions_poll: c.auctions_poll || []
+      };
     },
     },
 
 
     async checkAuctionItemsStatus(items) {
     async checkAuctionItemsStatus(items) {
       const now = new Date().toISOString();
       const now = new Date().toISOString();
       for (let item of items) {
       for (let item of items) {
         if ((item.item_type === 'auction' || item.item_type === 'exchange') && item.deadline && now > item.deadline) {
         if ((item.item_type === 'auction' || item.item_type === 'exchange') && item.deadline && now > item.deadline) {
-          if (['SOLD', 'DISCARDED'].includes(item.status)) continue;
+          if (['SOLD','DISCARDED'].includes(D(N(item.status)))) continue;
           let status = item.status;
           let status = item.status;
           if (item.item_type === 'auction') {
           if (item.item_type === 'auction') {
-            const highestBid = item.auctions_poll.reduce((prev, curr) => {
-              const [_, bidAmount] = curr.split(':');
-              return parseFloat(bidAmount) > prev ? parseFloat(bidAmount) : prev;
+            const highestBid = (item.auctions_poll || []).reduce((prev, curr) => {
+              const parts = String(curr).split(':'); const bidAmount = parseFloat(parts[1] || 0);
+              return bidAmount > prev ? bidAmount : prev;
             }, 0);
             }, 0);
             status = highestBid > 0 ? 'SOLD' : 'DISCARDED';
             status = highestBid > 0 ? 'SOLD' : 'DISCARDED';
           } else if (item.item_type === 'exchange') {
           } else if (item.item_type === 'exchange') {
@@ -202,30 +277,65 @@ module.exports = ({ cooler }) => {
       }
       }
     },
     },
 
 
-  async setItemAsSold(itemId) {
+    async setItemAsSold(itemId) {
+      const tipId = await this.resolveCurrentId(itemId);
       const ssbClient = await openSsb();
       const ssbClient = await openSsb();
       const userId = ssbClient.id;
       const userId = ssbClient.id;
       return new Promise((resolve, reject) => {
       return new Promise((resolve, reject) => {
-        ssbClient.get(itemId, (err, item) => {
+        ssbClient.get(tipId, (err, item) => {
           if (err || !item?.content) return reject(new Error("Item not found"));
           if (err || !item?.content) return reject(new Error("Item not found"));
-          if (['SOLD', 'DISCARDED'].includes(item.content.status)) return reject(new Error("Already sold/discarded"));
+          if (['SOLD','DISCARDED'].includes(String(item.content.status).toUpperCase().replace(/\s+/g,'_')))
+            return reject(new Error("Already sold/discarded"));
           if (item.content.stock <= 0) return reject(new Error("Out of stock"));
           if (item.content.stock <= 0) return reject(new Error("Out of stock"));
 
 
-          const updated = {
+          const soldMsg = {
             ...item.content,
             ...item.content,
             stock: 0,
             stock: 0,
             status: 'SOLD',
             status: 'SOLD',
             updatedAt: new Date().toISOString(),
             updatedAt: new Date().toISOString(),
-            replaces: itemId
+            replaces: tipId
           };
           };
+          const tomb1 = { type: 'tombstone', target: tipId, deletedAt: new Date().toISOString(), author: userId };
 
 
-          const tombstone = {
-            type: 'tombstone',
-            target: itemId,
-            deletedAt: new Date().toISOString(),
-            author: userId
-          };
+          ssbClient.publish(tomb1, err => {
+            if (err) return reject(err);
+            ssbClient.publish(soldMsg, (err2, soldRes) => {
+              if (err2) return reject(err2);
+
+              const touchMsg = {
+                ...soldMsg,
+                updatedAt: new Date().toISOString(),
+                replaces: soldRes.key
+              };
+              const tomb2 = { type: 'tombstone', target: soldRes.key, deletedAt: new Date().toISOString(), author: userId };
 
 
+              ssbClient.publish(tomb2, err3 => {
+                if (err3) return reject(err3);
+                ssbClient.publish(touchMsg, (err4, finalRes) => err4 ? reject(err4) : resolve(finalRes));
+              });
+            });
+          });
+        });
+      });
+    },
+
+    async addBidToAuction(itemId, userId, bidAmount) {
+      const tipId = await this.resolveCurrentId(itemId);
+      const ssbClient = await openSsb();
+      return new Promise((resolve, reject) => {
+        ssbClient.get(tipId, (err, item) => {
+          if (err || !item?.content) return reject(new Error("Item not found"));
+          if (item.content.item_type !== 'auction') return reject(new Error("Not an auction"));
+          if (item.content.seller === userId) return reject(new Error("Cannot bid on your own item"));
+          if (parseFloat(bidAmount) <= parseFloat(item.content.price)) return reject(new Error("Bid too low"));
+          const highestBid = (item.content.auctions_poll || []).reduce((prev, curr) => {
+            const parts = String(curr).split(':'); const bid = parseFloat(parts[1] || 0);
+            return Math.max(prev, bid);
+          }, 0);
+          if (parseFloat(bidAmount) <= highestBid) return reject(new Error("Bid not highest"));
+          const bid = `${userId}:${bidAmount}:${new Date().toISOString()}`;
+          const updated = { ...item.content, auctions_poll: [...(item.content.auctions_poll || []), bid], stock: item.content.stock - 1, updatedAt: new Date().toISOString(), replaces: tipId };
+          const tombstone = { type: 'tombstone', target: tipId, deletedAt: new Date().toISOString(), author: userId };
           ssbClient.publish(tombstone, (err) => {
           ssbClient.publish(tombstone, (err) => {
             if (err) return reject(err);
             if (err) return reject(err);
             ssbClient.publish(updated, (err2, res) => err2 ? reject(err2) : resolve(res));
             ssbClient.publish(updated, (err2, res) => err2 ? reject(err2) : resolve(res));
@@ -234,113 +344,49 @@ module.exports = ({ cooler }) => {
       });
       });
     },
     },
 
 
-    async getItemById(itemId) {
+    async decrementStock(itemId) {
+      const tipId = await this.resolveCurrentId(itemId);
       const ssbClient = await openSsb();
       const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+
       return new Promise((resolve, reject) => {
       return new Promise((resolve, reject) => {
-        ssbClient.get(itemId, async (err, item) => {
+        ssbClient.get(tipId, (err, item) => {
           if (err || !item?.content) return reject(new Error("Item not found"));
           if (err || !item?.content) return reject(new Error("Item not found"));
-          const c = item.content;
-          let status = c.status || 'FOR SALE';
-          if (c.deadline) {
-            const deadlineMoment = moment(c.deadline);
-            if (deadlineMoment.isValid() && deadlineMoment.isBefore(moment()) && status !== 'SOLD') {
-              status = 'DISCARDED';
-              const tombstone = {
-                type: 'tombstone',
-                target: itemId,
-                deletedAt: new Date().toISOString(),
-                author: c.seller
-              };
-              const updated = { ...c, status, updatedAt: new Date().toISOString() };
-              await ssbClient.publish(tombstone);
-              await ssbClient.publish(updated);
-            }
+
+          const curStatus = String(item.content.status).toUpperCase().replace(/\s+/g,'_');
+          if (['SOLD','DISCARDED'].includes(curStatus)) {
+            return resolve({ ok: true, noop: true });
           }
           }
-          resolve({
-            id: itemId,
-            title: c.title,
-            description: c.description,
-            price: c.price,
-            stock: c.stock,
-            status,
-            item_status: c.item_status,
-            seller: c.seller,
-            createdAt: c.createdAt,
-            updatedAt: c.updatedAt,
-            image: c.image || null,
-            tags: c.tags || [],
-            includesShipping: c.includesShipping,
-            deadline: c.deadline,
-            auctions_poll: c.auctions_poll || [],
-            item_type: c.item_type
+
+          const current = Number(item.content.stock) || 0;
+          if (current <= 0) {
+            return resolve({ ok: true, noop: true });
+          }
+
+          const newStock = current - 1;
+          const updated = {
+            ...item.content,
+            stock: newStock,
+            status: newStock === 0 ? 'SOLD' : item.content.status,
+            updatedAt: new Date().toISOString(),
+            replaces: tipId
+          };
+
+          const tombstone = {
+            type: 'tombstone',
+            target: tipId,
+            deletedAt: new Date().toISOString(),
+            author: userId
+          };
+
+          ssbClient.publish(tombstone, (e1) => {
+            if (e1) return reject(e1);
+            ssbClient.publish(updated, (e2, res) => e2 ? reject(e2) : resolve(res));
           });
           });
         });
         });
       });
       });
-    },
+    }
     
     
-  async addBidToAuction(itemId, userId, bidAmount) {
-    const ssbClient = await openSsb();
-    return new Promise((resolve, reject) => {
-    ssbClient.get(itemId, (err, item) => {
-      if (err || !item?.content) return reject(new Error("Item not found"));
-      if (item.content.item_type !== 'auction') return reject(new Error("Not an auction"));
-      if (item.content.seller === userId) return reject(new Error("Cannot bid on your own item"));
-      if (parseFloat(bidAmount) <= parseFloat(item.content.price)) return reject(new Error("Bid too low"));
-      const highestBid = item.content.auctions_poll.reduce((prev, curr) => {
-        const [_, bid] = curr.split(':');
-        return Math.max(prev, parseFloat(bid));
-      }, 0);
-      if (parseFloat(bidAmount) <= highestBid) return reject(new Error("Bid not highest"));
-      const bid = `${userId}:${bidAmount}:${new Date().toISOString()}`;
-      const updated = {
-        ...item.content,
-        auctions_poll: [...(item.content.auctions_poll || []), bid],
-        stock: item.content.stock - 1,
-        updatedAt: new Date().toISOString(),
-        replaces: itemId
-      };
-      const tombstone = {
-        type: 'tombstone',
-        target: itemId,
-        deletedAt: new Date().toISOString(),
-        author: userId
-      };
-      ssbClient.publish(tombstone, (err) => {
-        if (err) return reject(err);
-        ssbClient.publish(updated, (err2, res) => err2 ? reject(err2) : resolve(res));
-      });
-     });
-    });
-  },
-  
-  async decrementStock(itemId) {
-    const ssbClient = await openSsb();
-    const userId = ssbClient.id;
-    return new Promise((resolve, reject) => {
-      ssbClient.get(itemId, (err, item) => {
-        if (err || !item?.content) return reject(new Error("Item not found"));
-        if (item.content.stock <= 0) return reject(new Error("No stock left"));
-        const updated = {
-          ...item.content,
-          stock: item.content.stock - 1,
-          updatedAt: new Date().toISOString(),
-          replaces: itemId
-        };
-
-        const tombstone = {
-          type: 'tombstone',
-          target: itemId,
-          deletedAt: new Date().toISOString(),
-          author: userId
-        };
-        ssbClient.publish(tombstone, (err) => {
-          if (err) return reject(err);
-          ssbClient.publish(updated, (err2, res) => err2 ? reject(err2) : resolve(res));
-        });
-      });
-    });
-  }
-  
   };
   };
 };
 };
 
 

+ 45 - 1
src/models/opinions_model.js

@@ -1,4 +1,6 @@
 const pull = require('../server/node_modules/pull-stream');
 const pull = require('../server/node_modules/pull-stream');
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 
 module.exports = ({ cooler }) => {
 module.exports = ({ cooler }) => {
   let ssb;
   let ssb;
@@ -71,7 +73,7 @@ module.exports = ({ cooler }) => {
     const userId = ssbClient.id;
     const userId = ssbClient.id;
     const messages = await new Promise((res, rej) => {
     const messages = await new Promise((res, rej) => {
       pull(
       pull(
-        ssbClient.createLogStream(),
+        ssbClient.createLogStream({ limit: logLimit }),
         pull.collect((err, msgs) => err ? rej(err) : res(msgs))
         pull.collect((err, msgs) => err ? rej(err) : res(msgs))
       );
       );
     });
     });
@@ -122,6 +124,48 @@ module.exports = ({ cooler }) => {
       })
       })
     );
     );
     filtered = filtered.filter(Boolean);
     filtered = filtered.filter(Boolean);
+    const signatureOf = (m) => {
+    const c = m.value?.content || {};
+    switch (c.type) {
+      case 'document':
+      case 'image':
+      case 'audio':
+      case 'video':
+        return `${c.type}::${(c.url || '').trim()}`;
+      case 'bookmark': {
+        const u = (c.url || c.bookmark || '').trim().toLowerCase();
+        return `bookmark::${u}`;
+      }
+      case 'feed': {
+        const t = (c.text || '').replace(/\s+/g, ' ').trim();
+        return `feed::${t}`;
+      }
+      case 'votes': {
+        const q = (c.question || '').replace(/\s+/g, ' ').trim();
+        return `votes::${q}`;
+      }
+      case 'transfer': {
+        const concept = (c.concept || '').trim();
+        const amount = c.amount || '';
+        const from = c.from || '';
+        const to = c.to || '';
+        const deadline = c.deadline || '';
+        return `transfer::${concept}|${amount}|${from}|${to}|${deadline}`;
+      }
+      default:
+        return `key::${m.key}`;
+    }
+  };
+
+    const bySig = new Map();
+    for (const m of filtered) {
+      const sig = signatureOf(m);
+      const prev = bySig.get(sig);
+      if (!prev || (m.value?.timestamp || 0) > (prev.value?.timestamp || 0)) {
+        bySig.set(sig, m);
+      }
+    }
+    filtered = Array.from(bySig.values());
 
 
     if (filter === 'MINE') {
     if (filter === 'MINE') {
       filtered = filtered.filter(m => m.value.author === userId);
       filtered = filtered.filter(m => m.value.author === userId);

+ 4 - 2
src/models/pixelia_model.js

@@ -1,4 +1,6 @@
 const pull = require('../server/node_modules/pull-stream');
 const pull = require('../server/node_modules/pull-stream');
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 
 module.exports = ({ cooler }) => {
 module.exports = ({ cooler }) => {
   let ssb;
   let ssb;
@@ -12,7 +14,7 @@ module.exports = ({ cooler }) => {
     const ssbClient = await openSsb();
     const ssbClient = await openSsb();
     const messages = await new Promise((res, rej) => {
     const messages = await new Promise((res, rej) => {
       pull(
       pull(
-        ssbClient.createLogStream(),
+        ssbClient.createLogStream({ limit: logLimit }),
         pull.collect((err, msgs) => err ? rej(err) : res(msgs))
         pull.collect((err, msgs) => err ? rej(err) : res(msgs))
       );
       );
     });
     });
@@ -89,7 +91,7 @@ module.exports = ({ cooler }) => {
     const ssbClient = await openSsb();
     const ssbClient = await openSsb();
     const messages = await new Promise((res, rej) => {
     const messages = await new Promise((res, rej) => {
       pull(
       pull(
-        ssbClient.createLogStream(),
+        ssbClient.createLogStream({ limit: logLimit }),
         pull.collect((err, msgs) => err ? rej(err) : res(msgs))
         pull.collect((err, msgs) => err ? rej(err) : res(msgs))
       );
       );
     });
     });

+ 4 - 1
src/models/reports_model.js

@@ -1,5 +1,7 @@
 const pull = require('../server/node_modules/pull-stream');
 const pull = require('../server/node_modules/pull-stream');
 const moment = require('../server/node_modules/moment');
 const moment = require('../server/node_modules/moment');
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 
 module.exports = ({ cooler }) => {
 module.exports = ({ cooler }) => {
   let ssb;
   let ssb;
@@ -98,7 +100,8 @@ module.exports = ({ cooler }) => {
     async listAll() {
     async listAll() {
       const ssb = await openSsb();
       const ssb = await openSsb();
       return new Promise((resolve, reject) => {
       return new Promise((resolve, reject) => {
-        pull(ssb.createLogStream(), pull.collect((err, results) => {
+        pull(ssb.createLogStream({ limit: logLimit }), 
+          pull.collect((err, results) => {
           if (err) return reject(err);
           if (err) return reject(err);
           const tombstoned = new Set();
           const tombstoned = new Set();
           const replaced = new Map();
           const replaced = new Map();

+ 3 - 1
src/models/search_model.js

@@ -1,5 +1,7 @@
 const pull = require('../server/node_modules/pull-stream');
 const pull = require('../server/node_modules/pull-stream');
 const moment = require('../server/node_modules/moment');
 const moment = require('../server/node_modules/moment');
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 
 module.exports = ({ cooler }) => {
 module.exports = ({ cooler }) => {
   let ssb;
   let ssb;
@@ -59,7 +61,7 @@ module.exports = ({ cooler }) => {
 
 
     const messages = await new Promise((res, rej) => {
     const messages = await new Promise((res, rej) => {
       pull(
       pull(
-        ssbClient.createLogStream(),
+        ssbClient.createLogStream({ limit: logLimit }),
         pull.collect((err, msgs) => err ? rej(err) : res(msgs))
         pull.collect((err, msgs) => err ? rej(err) : res(msgs))
       );
       );
     });
     });

+ 53 - 29
src/models/stats_model.js

@@ -1,17 +1,16 @@
 const pull = require('../server/node_modules/pull-stream');
 const pull = require('../server/node_modules/pull-stream');
 const os = require('os');
 const os = require('os');
 const fs = require('fs');
 const fs = require('fs');
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 
 module.exports = ({ cooler }) => {
 module.exports = ({ cooler }) => {
   let ssb;
   let ssb;
-  const openSsb = async () => {
-    if (!ssb) ssb = await cooler.open();
-    return ssb;
-  };
+  const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
 
 
   const types = [
   const types = [
-    'bookmark', 'event', 'task', 'votes', 'report', 'feed',
-    'image', 'audio', 'video', 'document', 'transfer', 'post', 'tribe', 'market'
+    'bookmark','event','task','votes','report','feed',
+    'image','audio','video','document','transfer','post','tribe','market','forum','job'
   ];
   ];
 
 
   const getFolderSize = (folderPath) => {
   const getFolderSize = (folderPath) => {
@@ -40,50 +39,75 @@ module.exports = ({ cooler }) => {
 
 
     const messages = await new Promise((res, rej) => {
     const messages = await new Promise((res, rej) => {
       pull(
       pull(
-        ssbClient.createLogStream(),
+        ssbClient.createLogStream({ limit: logLimit }),
         pull.collect((err, msgs) => err ? rej(err) : res(msgs))
         pull.collect((err, msgs) => err ? rej(err) : res(msgs))
       );
       );
     });
     });
 
 
     const allMsgs = messages.filter(m => m.value?.content);
     const allMsgs = messages.filter(m => m.value?.content);
-    const tombstoned = new Set(allMsgs.filter(m => m.value.content.type === 'tombstone' && m.value.content.target).map(m => m.value.content.target));
-    const replacesMap = new Map();
-    const userMsgs = filter === 'MINE' ? allMsgs.filter(m => m.value.author === userId) : allMsgs;
+    const tombTargets = new Set(
+      allMsgs
+        .filter(m => m.value.content.type === 'tombstone' && m.value.content.target)
+        .map(m => m.value.content.target)
+    );
 
 
-    const latestByType = {};
-    const opinions = {};
-    const content = {};
+    const scopedMsgs = filter === 'MINE' ? allMsgs.filter(m => m.value.author === userId) : allMsgs;
 
 
+    const byType = {};
+    const parentOf = {};
     for (const t of types) {
     for (const t of types) {
-      latestByType[t] = new Map();
-      opinions[t] = 0;
-      content[t] = 0;
+      byType[t] = new Map();
+      parentOf[t] = new Map();
     }
     }
 
 
-    for (const m of userMsgs) {
+    for (const m of scopedMsgs) {
       const k = m.key;
       const k = m.key;
       const c = m.value.content;
       const c = m.value.content;
       const t = c.type;
       const t = c.type;
       if (!types.includes(t)) continue;
       if (!types.includes(t)) continue;
-      if (tombstoned.has(k)) continue;
-      if (c.replaces) replacesMap.set(c.replaces, k);
-      latestByType[t].set(k, { msg: m, content: c });
+      byType[t].set(k, { key: k, ts: m.value.timestamp, content: c });
+      if (c.replaces) parentOf[t].set(k, c.replaces);
     }
     }
 
 
-    for (const replacedId of replacesMap.keys()) {
-      for (const t of types) {
-        latestByType[t].delete(replacedId);
+    const findRoot = (t, id) => {
+      let cur = id;
+      const pMap = parentOf[t];
+      while (pMap.has(cur)) cur = pMap.get(cur);
+      return cur;
+    };
+
+    const tipOf = {};
+    for (const t of types) {
+      tipOf[t] = new Map();
+      const pMap = parentOf[t];
+      const fwd = new Map();
+      for (const [child, parent] of pMap.entries()) {
+        fwd.set(parent, child);
+      }
+      const allMap = byType[t];
+      const roots = new Set(Array.from(allMap.keys()).map(id => findRoot(t, id)));
+      for (const root of roots) {
+        let tip = root;
+        while (fwd.has(tip)) tip = fwd.get(tip);
+        if (tombTargets.has(tip)) continue;
+        const node = allMap.get(tip) || allMap.get(root);
+        if (node) tipOf[t].set(root, node);
       }
       }
     }
     }
 
 
+    const content = {};
+    const opinions = {};
     for (const t of types) {
     for (const t of types) {
-      const values = Array.from(latestByType[t].values());
-      content[t] = values.length;
-      opinions[t] = values.filter(e => (e.content.opinions_inhabitants || []).length > 0).length;
+      let vals = Array.from(tipOf[t].values()).map(v => v.content);
+      if (t === 'forum') {
+        vals = vals.filter(c => !(c.root && tombTargets.has(c.root)));
+      }
+      content[t] = vals.length;
+      opinions[t] = vals.filter(e => Array.isArray(e.opinions_inhabitants) && e.opinions_inhabitants.length > 0).length;
     }
     }
 
 
-    const tribeContents = Array.from(latestByType['tribe'].values()).map(e => e.content);
-    const memberTribes = tribeContents
+    const tribeVals = Array.from(tipOf['tribe'].values()).map(v => v.content);
+    const memberTribes = tribeVals
       .filter(c => Array.isArray(c.members) && c.members.includes(userId))
       .filter(c => Array.isArray(c.members) && c.members.includes(userId))
       .map(c => c.name || c.title || c.id);
       .map(c => c.name || c.title || c.id);
 
 
@@ -103,7 +127,7 @@ module.exports = ({ cooler }) => {
       content,
       content,
       opinions,
       opinions,
       memberTribes,
       memberTribes,
-      userTombstoneCount: userMsgs.filter(m => m.value.content.type === 'tombstone').length,
+      userTombstoneCount: scopedMsgs.filter(m => m.value.content.type === 'tombstone').length,
       networkTombstoneCount: allMsgs.filter(m => m.value.content.type === 'tombstone').length,
       networkTombstoneCount: allMsgs.filter(m => m.value.content.type === 'tombstone').length,
       folderSize: formatSize(folderSize),
       folderSize: formatSize(folderSize),
       statsBlockchainSize: formatSize(flumeSize),
       statsBlockchainSize: formatSize(flumeSize),

+ 3 - 1
src/models/tags_model.js

@@ -1,5 +1,7 @@
 const pull = require('../server/node_modules/pull-stream');
 const pull = require('../server/node_modules/pull-stream');
 const moment = require('../server/node_modules/moment');
 const moment = require('../server/node_modules/moment');
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 
 module.exports = ({ cooler }) => {
 module.exports = ({ cooler }) => {
   let ssb;
   let ssb;
@@ -13,7 +15,7 @@ module.exports = ({ cooler }) => {
       const ssbClient = await openSsb();
       const ssbClient = await openSsb();
       return new Promise((resolve, reject) => {
       return new Promise((resolve, reject) => {
         pull(
         pull(
-          ssbClient.createLogStream(),
+          ssbClient.createLogStream({ limit: logLimit }),
           pull.filter(msg => {
           pull.filter(msg => {
             const c = msg.value.content;
             const c = msg.value.content;
             return c && Array.isArray(c.tags) && c.tags.length && c.type !== 'tombstone'; 
             return c && Array.isArray(c.tags) && c.tags.length && c.type !== 'tombstone'; 

+ 56 - 6
src/models/tasks_model.js

@@ -1,5 +1,7 @@
 const pull = require('../server/node_modules/pull-stream');
 const pull = require('../server/node_modules/pull-stream');
 const moment = require('../server/node_modules/moment');
 const moment = require('../server/node_modules/moment');
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 
 module.exports = ({ cooler }) => {
 module.exports = ({ cooler }) => {
   let ssb;
   let ssb;
@@ -46,11 +48,57 @@ module.exports = ({ cooler }) => {
     async updateTaskById(taskId, updatedData) {
     async updateTaskById(taskId, updatedData) {
       const ssb = await openSsb();
       const ssb = await openSsb();
       const userId = ssb.id;
       const userId = ssb.id;
-      const task = await new Promise((res, rej) => ssb.get(taskId, (err, task) => err ? rej(new Error('Task not found')) : res(task)));
-      if (task.content.status === 'CLOSED') throw new Error('Cannot edit a closed task');
-      const updated = { ...task.content, ...updatedData, updatedAt: new Date().toISOString(), replaces: taskId };
-      const tombstone = { type: 'tombstone', target: taskId, deletedAt: new Date().toISOString(), author: userId };
-      await new Promise((res, rej) => ssb.publish(tombstone, err => err ? rej(err) : res()));
+      const old = await new Promise((res, rej) =>
+        ssb.get(taskId, (err, msg) => err || !msg ? rej(new Error('Task not found')) : res(msg))
+      );
+      const c = old.content;
+      if (c.type !== 'task') throw new Error('Invalid type');
+      if (c.author !== userId) throw new Error('Not the author');
+      if (c.status === 'CLOSED') throw new Error('Cannot edit a closed task');
+      let newStart = c.startTime;
+      if (updatedData.startTime != null && updatedData.startTime !== '') {
+        const m = moment(updatedData.startTime);
+        if (!m.isValid()) throw new Error('Invalid startTime');
+        newStart = m.toISOString();
+      }
+      let newEnd = c.endTime;
+      if (updatedData.endTime != null && updatedData.endTime !== '') {
+        const m = moment(updatedData.endTime);
+        if (!m.isValid()) throw new Error('Invalid endTime');
+        newEnd = m.toISOString();
+      }
+      if (moment(newEnd).isBefore(moment(newStart))) {
+        throw new Error('Invalid time range');
+      }
+      let newTags = c.tags || [];
+      if (updatedData.tags !== undefined) {
+        if (Array.isArray(updatedData.tags)) {
+          newTags = updatedData.tags.filter(Boolean);
+        } else if (typeof updatedData.tags === 'string') {
+          newTags = updatedData.tags.split(',').map(t => t.trim()).filter(Boolean);
+        } else {
+          newTags = [];
+        }
+      }
+      let newVisibility = c.isPublic;
+      if (updatedData.isPublic !== undefined) {
+        const v = String(updatedData.isPublic).toUpperCase();
+        newVisibility = (v === 'PUBLIC' || v === 'PRIVATE') ? v : c.isPublic;
+      }
+      const updated = {
+        ...c,
+        title: updatedData.title ?? c.title,
+        description: updatedData.description ?? c.description,
+        startTime: newStart,
+        endTime: newEnd,
+        priority: updatedData.priority ?? c.priority,
+        location: updatedData.location ?? c.location,
+        tags: newTags,
+        isPublic: newVisibility,
+        status: updatedData.status ?? c.status,
+        updatedAt: new Date().toISOString(),
+        replaces: taskId
+      };
       return new Promise((res, rej) => ssb.publish(updated, (err, result) => err ? rej(err) : res(result)));
       return new Promise((res, rej) => ssb.publish(updated, (err, result) => err ? rej(err) : res(result)));
     },
     },
 
 
@@ -87,7 +135,8 @@ module.exports = ({ cooler }) => {
       const ssb = await openSsb();
       const ssb = await openSsb();
       const now = moment();
       const now = moment();
       return new Promise((resolve, reject) => {
       return new Promise((resolve, reject) => {
-        pull(ssb.createLogStream(), pull.collect((err, results) => {
+        pull(ssb.createLogStream({ limit: logLimit }), 
+        pull.collect((err, results) => {
           if (err) return reject(err);
           if (err) return reject(err);
           const tombstoned = new Set();
           const tombstoned = new Set();
           const replaced = new Map();
           const replaced = new Map();
@@ -114,3 +163,4 @@ module.exports = ({ cooler }) => {
   };
   };
 };
 };
 
 
+

+ 3 - 1
src/models/transfers_model.js

@@ -1,5 +1,7 @@
 const pull = require('../server/node_modules/pull-stream');
 const pull = require('../server/node_modules/pull-stream');
 const moment = require('../server/node_modules/moment');
 const moment = require('../server/node_modules/moment');
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 
 module.exports = ({ cooler }) => {
 module.exports = ({ cooler }) => {
   let ssb;
   let ssb;
@@ -116,7 +118,7 @@ module.exports = ({ cooler }) => {
 	  const ssb = await openSsb();
 	  const ssb = await openSsb();
 	  return new Promise((resolve, reject) => {
 	  return new Promise((resolve, reject) => {
 	    pull(
 	    pull(
-	      ssb.createLogStream(),
+	      ssb.createLogStream({ limit: logLimit }),
 	      pull.collect(async (err, results) => {
 	      pull.collect(async (err, results) => {
 		if (err) return reject(err);
 		if (err) return reject(err);
 		const tombstoned = new Set();
 		const tombstoned = new Set();

+ 32 - 1
src/models/trending_model.js

@@ -1,4 +1,6 @@
 const pull = require('../server/node_modules/pull-stream');
 const pull = require('../server/node_modules/pull-stream');
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 
 module.exports = ({ cooler }) => {
 module.exports = ({ cooler }) => {
   let ssb;
   let ssb;
@@ -30,7 +32,7 @@ module.exports = ({ cooler }) => {
     const userId = ssbClient.id;
     const userId = ssbClient.id;
     const messages = await new Promise((res, rej) => {
     const messages = await new Promise((res, rej) => {
       pull(
       pull(
-        ssbClient.createLogStream(),
+        ssbClient.createLogStream({ limit: logLimit }),
         pull.collect((err, xs) => err ? rej(err) : res(xs))
         pull.collect((err, xs) => err ? rej(err) : res(xs))
       );
       );
     });
     });
@@ -71,6 +73,35 @@ module.exports = ({ cooler }) => {
       })
       })
     );
     );
     items = items.filter(Boolean);
     items = items.filter(Boolean);
+    const signatureOf = (m) => {
+    const c = m.value?.content || {};
+    switch (c.type) {
+      case 'document':
+      case 'image':
+      case 'audio':
+      case 'video':
+        return `${c.type}::${(c.url || '').trim()}`;
+      case 'bookmark':
+        return `bookmark::${(c.url || '').trim().toLowerCase()}`;
+      case 'feed':
+        return `feed::${(c.text || '').replace(/\s+/g, ' ').trim()}`;
+      case 'votes':
+       return `votes::${(c.question || '').replace(/\s+/g, ' ').trim()}`;
+      case 'transfer':
+        return `transfer::${(c.concept || '')}|${c.amount || ''}|${c.from || ''}|${c.to || ''}|${c.deadline || ''}`;
+      default:
+        return `key::${m.key}`;
+    }
+    };
+    const bySig = new Map();
+    for (const m of items) {
+      const sig = signatureOf(m);
+      const prev = bySig.get(sig);
+      if (!prev || (m.value?.timestamp || 0) > (prev.value?.timestamp || 0)) {
+        bySig.set(sig, m);
+      }
+    }
+    items = Array.from(bySig.values());
 
 
     if (filter === 'MINE') {
     if (filter === 'MINE') {
       items = items.filter(m => m.value.author === userId);
       items = items.filter(m => m.value.author === userId);

+ 4 - 2
src/models/tribes_model.js

@@ -1,4 +1,6 @@
 const pull = require('../server/node_modules/pull-stream');
 const pull = require('../server/node_modules/pull-stream');
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 
 module.exports = ({ cooler }) => {
 module.exports = ({ cooler }) => {
   let ssb;
   let ssb;
@@ -233,7 +235,7 @@ module.exports = ({ cooler }) => {
     async getTribeById(tribeId) {
     async getTribeById(tribeId) {
       const ssb = await openSsb();
       const ssb = await openSsb();
       return new Promise((res, rej) => pull(
       return new Promise((res, rej) => pull(
-        ssb.createLogStream(),
+        ssb.createLogStream({ limit: logLimit }),
         pull.collect((err, msgs) => {
         pull.collect((err, msgs) => {
           if (err) return rej(err);
           if (err) return rej(err);
           const tombstoned = new Set();
           const tombstoned = new Set();
@@ -278,7 +280,7 @@ module.exports = ({ cooler }) => {
     async listAll() {
     async listAll() {
       const ssb = await openSsb();
       const ssb = await openSsb();
       return new Promise((res, rej) => pull(
       return new Promise((res, rej) => pull(
-        ssb.createLogStream(),
+        ssb.createLogStream({ limit: logLimit }),
         pull.collect((err, msgs) => {
         pull.collect((err, msgs) => {
           if (err) return rej(err);
           if (err) return rej(err);
           const tombstoned = new Set();
           const tombstoned = new Set();

+ 8 - 9
src/models/videos_model.js

@@ -1,4 +1,6 @@
 const pull = require('../server/node_modules/pull-stream');
 const pull = require('../server/node_modules/pull-stream');
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 
 module.exports = ({ cooler }) => {
 module.exports = ({ cooler }) => {
   let ssb;
   let ssb;
@@ -87,7 +89,7 @@ module.exports = ({ cooler }) => {
       const userId = ssbClient.id;
       const userId = ssbClient.id;
       const messages = await new Promise((res, rej) => {
       const messages = await new Promise((res, rej) => {
         pull(
         pull(
-          ssbClient.createLogStream(),
+          ssbClient.createLogStream({ limit: logLimit }),
           pull.collect((err, msgs) => err ? rej(err) : res(msgs))
           pull.collect((err, msgs) => err ? rej(err) : res(msgs))
         );
         );
       });
       });
@@ -103,13 +105,12 @@ module.exports = ({ cooler }) => {
           continue;
           continue;
         }
         }
         if (c.type !== 'video') continue;
         if (c.type !== 'video') continue;
-        if (tombstoned.has(k)) continue;
         if (c.replaces) replaces.set(c.replaces, k);
         if (c.replaces) replaces.set(c.replaces, k);
         videos.set(k, {
         videos.set(k, {
           key: k,
           key: k,
           url: c.url,
           url: c.url,
           createdAt: c.createdAt,
           createdAt: c.createdAt,
-          updatedAt: c.updatedAt || null,
+         updatedAt: c.updatedAt || null,
           tags: c.tags || [],
           tags: c.tags || [],
           author: c.author,
           author: c.author,
           title: c.title || '',
           title: c.title || '',
@@ -118,9 +119,8 @@ module.exports = ({ cooler }) => {
           opinions_inhabitants: c.opinions_inhabitants || []
           opinions_inhabitants: c.opinions_inhabitants || []
         });
         });
       }
       }
-      for (const replaced of replaces.keys()) {
-        videos.delete(replaced);
-      }
+      for (const oldId of replaces.keys()) videos.delete(oldId);
+      for (const delId of tombstoned.values()) videos.delete(delId);
       let out = Array.from(videos.values());
       let out = Array.from(videos.values());
       if (filter === 'mine') {
       if (filter === 'mine') {
         out = out.filter(v => v.author === userId);
         out = out.filter(v => v.author === userId);
@@ -129,9 +129,8 @@ module.exports = ({ cooler }) => {
         out = out.filter(v => new Date(v.createdAt).getTime() >= now - 86400000);
         out = out.filter(v => new Date(v.createdAt).getTime() >= now - 86400000);
       } else if (filter === 'top') {
       } else if (filter === 'top') {
         out = out.sort((a, b) => {
         out = out.sort((a, b) => {
-          const sumA = Object.values(a.opinions).reduce((s, v) => s + v, 0);
-          const sumB = Object.values(b.opinions).reduce((s, v) => s + v, 0);
-          return sumB - sumA;
+          const sum = o => Object.values(o || {}).reduce((s, n) => s + (n || 0), 0);
+          return sum(b.opinions) - sum(a.opinions);
         });
         });
       } else {
       } else {
         out = out.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
         out = out.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));

+ 52 - 1
src/models/votes_model.js

@@ -1,5 +1,7 @@
 const pull = require('../server/node_modules/pull-stream');
 const pull = require('../server/node_modules/pull-stream');
 const moment = require('../server/node_modules/moment');
 const moment = require('../server/node_modules/moment');
+const { getConfig } = require('../configs/config-manager.js');
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 
 module.exports = ({ cooler }) => {
 module.exports = ({ cooler }) => {
   let ssb;
   let ssb;
@@ -38,6 +40,54 @@ module.exports = ({ cooler }) => {
       const tombstone = { type: 'tombstone', target: id, deletedAt: new Date().toISOString(), author: userId };
       const tombstone = { type: 'tombstone', target: id, deletedAt: new Date().toISOString(), author: userId };
       return new Promise((res, rej) => ssb.publish(tombstone, (err, result) => err ? rej(err) : res(result)));
       return new Promise((res, rej) => ssb.publish(tombstone, (err, result) => err ? rej(err) : res(result)));
     },
     },
+    
+    async updateVoteById(id, { question, deadline, options, tags }) {
+      const ssb = await openSsb();
+      const userId = ssb.id;
+      const oldMsg = await new Promise((res, rej) =>
+        ssb.get(id, (err, msg) => err || !msg ? rej(new Error('Vote not found')) : res(msg))
+      );
+      const c = oldMsg.content;
+      if (c.type !== 'votes') throw new Error('Invalid type');
+      if (c.createdBy !== userId) throw new Error('Not the author');
+      let newDeadline = c.deadline;
+      if (deadline != null && deadline !== '') {
+        const parsed = moment(deadline, moment.ISO_8601, true);
+        if (!parsed.isValid() || parsed.isBefore(moment())) throw new Error('Invalid deadline');
+        newDeadline = parsed.toISOString();
+      }
+      let newOptions = c.options;
+      let newVotesMap = c.votes;
+      let newTotalVotes = c.totalVotes;
+      const optionsCambiaron = Array.isArray(options) && (
+        options.length !== c.options.length ||
+        options.some((o, i) => o !== c.options[i])
+      );
+      if (optionsCambiaron) {
+        if (c.totalVotes > 0) {
+          throw new Error('Cannot change options after voting has started');
+        }
+        newOptions = options;
+        newVotesMap = newOptions.reduce((acc, opt) => (acc[opt] = 0, acc), {});
+        newTotalVotes = 0;
+      }
+      const newTags =
+        Array.isArray(tags) ? tags.filter(Boolean)
+        : typeof tags === 'string' ? tags.split(',').map(t => t.trim()).filter(Boolean)
+        : c.tags || [];
+      const updated = {
+        ...c,
+        replaces: id,
+        question: question ?? c.question,
+        deadline: newDeadline,
+        options: newOptions,
+        votes: newVotesMap,
+        totalVotes: newTotalVotes,
+        tags: newTags,
+        updatedAt: new Date().toISOString()
+      };
+      return new Promise((res, rej) => ssb.publish(updated, (err, result) => err ? rej(err) : res(result)));
+    },
 
 
     async voteOnVote(id, choice) {
     async voteOnVote(id, choice) {
       const ssb = await openSsb();
       const ssb = await openSsb();
@@ -68,7 +118,8 @@ module.exports = ({ cooler }) => {
       const userId = ssb.id;
       const userId = ssb.id;
       const now = moment();
       const now = moment();
       return new Promise((resolve, reject) => {
       return new Promise((resolve, reject) => {
-        pull(ssb.createLogStream(), pull.collect((err, results) => {
+        pull(ssb.createLogStream({ limit: logLimit }), 
+        pull.collect((err, results) => {
           if (err) return reject(err);
           if (err) return reject(err);
           const tombstoned = new Set();
           const tombstoned = new Set();
           const replaced = new Map();
           const replaced = new Map();

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

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

+ 1 - 1
src/server/package.json

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

+ 76 - 30
src/views/activity_view.js

@@ -7,7 +7,7 @@ function capitalize(str) {
   return typeof str === 'string' && str.length ? str[0].toUpperCase() + str.slice(1) : '';
   return typeof str === 'string' && str.length ? str[0].toUpperCase() + str.slice(1) : '';
 }
 }
 
 
-function renderActionCards(actions) {
+function renderActionCards(actions, userId) {
   const validActions = actions
   const validActions = actions
     .filter(action => {
     .filter(action => {
       const content = action.value?.content || action.content;
       const content = action.value?.content || action.content;
@@ -363,23 +363,24 @@ function renderActionCards(actions) {
     }
     }
 
 
     if (type === 'market') {
     if (type === 'market') {
-      const { item_type, title, price, status, deadline, stock, image, auctions_poll } = content;
+      const { item_type, title, price, status, deadline, stock, image, auctions_poll, seller } = content;
+      const isSeller = seller && userId && seller === userId;
       cardBody.push(
       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.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.marketItemType + ':'), span({ class: 'card-value' }, item_type.toUpperCase())),
           div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemStatus + ": " ), span({ class: 'card-value' }, status.toUpperCase())),
           div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemStatus + ": " ), span({ class: 'card-value' }, status.toUpperCase())),
           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.deadline + ':'), span({ class: 'card-value' }, deadline ? new Date(deadline).toLocaleString() : "")),
+          div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemStock + ':'), span({ class: 'card-value' }, stock)),
           br,
           br,
           image
           image
             ? img({ src: `/blob/${encodeURIComponent(image)}` })
             ? img({ src: `/blob/${encodeURIComponent(image)}` })
             : img({ src: '/assets/images/default-market.png', alt: title }),
             : img({ src: '/assets/images/default-market.png', alt: title }),
-          div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemStock + ':'), span({ class: 'card-value' }, stock)),
           br,
           br,
           div({ class: "market-card price" },
           div({ class: "market-card price" },
             p(`${i18n.marketItemPrice}: ${price} ECO`)
             p(`${i18n.marketItemPrice}: ${price} ECO`)
           ),
           ),
-          item_type === 'auction' && status !== 'SOLD' && status !== 'DISCARDED'
+            item_type === 'auction' && status !== 'SOLD' && status !== 'DISCARDED' && !isSeller
             ? div({ class: "auction-info" },
             ? div({ class: "auction-info" },
                 auctions_poll && auctions_poll.length > 0
                 auctions_poll && auctions_poll.length > 0
                   ? [
                   ? [
@@ -390,8 +391,8 @@ function renderActionCards(actions) {
                           th(i18n.marketAuctionUser),
                           th(i18n.marketAuctionUser),
                           th(i18n.marketAuctionBidAmount)
                           th(i18n.marketAuctionBidAmount)
                         ),
                         ),
-                        ...auctions_poll.map(bid => {
-                          const [userId, bidAmount, bidTime] = bid.split(':');
+                            ...auctions_poll.map(bid => {
+                            const [bidderId, bidAmount, bidTime] = bid.split(':');
                           return tr(
                           return tr(
                             td(moment(bidTime).format('YYYY-MM-DD HH:mm:ss')),
                             td(moment(bidTime).format('YYYY-MM-DD HH:mm:ss')),
                             td(a({ href: `/author/${encodeURIComponent(userId)}` }, userId)),
                             td(a({ href: `/author/${encodeURIComponent(userId)}` }, userId)),
@@ -407,7 +408,7 @@ function renderActionCards(actions) {
                   button({ class: "buy-btn", type: "submit" }, i18n.marketPlaceBidButton)
                   button({ class: "buy-btn", type: "submit" }, i18n.marketPlaceBidButton)
                 )
                 )
               ) : "",
               ) : "",
-          item_type === 'exchange' && status !== 'SOLD' && status !== 'DISCARDED'
+            item_type === 'exchange' && status !== 'SOLD' && status !== 'DISCARDED' && !isSeller
             ? form({ method: "POST", action: `/market/buy/${encodeURIComponent(action.id)}` },
             ? form({ method: "POST", action: `/market/buy/${encodeURIComponent(action.id)}` },
                 button({ class: "buy-btn", type: "submit" }, i18n.marketActionsBuy)
                 button({ class: "buy-btn", type: "submit" }, i18n.marketActionsBuy)
               ) : ""
               ) : ""
@@ -426,13 +427,55 @@ function renderActionCards(actions) {
         )
         )
       );
       );
     }
     }
+    
+    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()
+                )
+            ),
+        )
+      );
+    }
 
 
 return div({ class: 'card card-rpg' },
 return div({ class: 'card card-rpg' },
   div({ class: 'card-header' },
   div({ class: 'card-header' },
     h2({ class: 'card-label' }, `[${typeLabel}]`),
     h2({ class: 'card-label' }, `[${typeLabel}]`),
-    type !== 'feed' ? form({ method: "GET", action: getViewDetailsAction(type, action) },
-      button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
-    ) : ''
+	type !== 'feed' && (!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),
   div({ class: 'card-body' }, ...cardBody),
   p({ class: 'card-footer' },
   p({ class: 'card-footer' },
@@ -445,30 +488,32 @@ return div({ class: 'card card-rpg' },
 }
 }
 
 
 function getViewDetailsAction(type, action) {
 function getViewDetailsAction(type, action) {
+  const id = encodeURIComponent(action.tipId || action.id);
   switch (type) {
   switch (type) {
-    case 'votes': return `/votes/${encodeURIComponent(action.id)}`;
-    case 'transfer': return `/transfers/${encodeURIComponent(action.id)}`;
+    case 'votes': return `/votes/${id}`;
+    case 'transfer': return `/transfers/${id}`;
     case 'pixelia': return `/pixelia`;
     case 'pixelia': return `/pixelia`;
-    case 'tribe': return `/tribe/${encodeURIComponent(action.id)}`;
+    case 'tribe': return `/tribe/${id}`;
     case 'curriculum': return `/inhabitant/${encodeURIComponent(action.author)}`;
     case 'curriculum': return `/inhabitant/${encodeURIComponent(action.author)}`;
-    case 'image': return `/images/${encodeURIComponent(action.id)}`;
-    case 'audio': return `/audios/${encodeURIComponent(action.id)}`;
-    case 'video': return `/videos/${encodeURIComponent(action.id)}`;
-    case 'forum': 
-      return `/forum/${encodeURIComponent(action.content?.key || action.id)}`;
-    case 'document': return `/documents/${encodeURIComponent(action.id)}`;
-    case 'bookmark': return `/bookmarks/${encodeURIComponent(action.id)}`;
-    case 'event': return `/events/${encodeURIComponent(action.id)}`;
-    case 'task': return `/tasks/${encodeURIComponent(action.id)}`;    
+    case 'image': return `/images/${id}`;
+    case 'audio': return `/audios/${id}`;
+    case 'video': return `/videos/${id}`;
+    case 'forum':
+      return `/forum/${encodeURIComponent(action.content?.key || action.tipId || action.id)}`;
+    case 'document': return `/documents/${id}`;
+    case 'bookmark': return `/bookmarks/${id}`;
+    case 'event': return `/events/${id}`;
+    case 'task': return `/tasks/${id}`;
     case 'about': return `/author/${encodeURIComponent(action.author)}`;
     case 'about': return `/author/${encodeURIComponent(action.author)}`;
-    case 'post': return `/thread/${encodeURIComponent(action.id)}#${encodeURIComponent(action.id)}`;
+    case 'post': return `/thread/${id}#${id}`;
     case 'vote': return `/thread/${encodeURIComponent(action.content.vote.link)}#${encodeURIComponent(action.content.vote.link)}`;
     case 'vote': return `/thread/${encodeURIComponent(action.content.vote.link)}#${encodeURIComponent(action.content.vote.link)}`;
     case 'contact': return `/inhabitants`;
     case 'contact': return `/inhabitants`;
     case 'pub': return `/invites`;
     case 'pub': return `/invites`;
-    case 'market': return `/market/${encodeURIComponent(action.id)}`;
-    case 'report': return `/reports/${encodeURIComponent(action.id)}`;
-    }
-   }
+    case 'market': return `/market/${id}`;
+    case 'job': return `/jobs/${id}`;
+    case 'report': return `/reports/${id}`;
+  }
+}
 
 
 exports.activityView = (actions, filter, userId) => {
 exports.activityView = (actions, filter, userId) => {
   const title = filter === 'mine' ? i18n.yourActivity : i18n.globalActivity;
   const title = filter === 'mine' ? i18n.yourActivity : i18n.globalActivity;
@@ -486,6 +531,7 @@ exports.activityView = (actions, filter, userId) => {
     { type: 'about', label: i18n.typeAbout },
     { type: 'about', label: i18n.typeAbout },
     { type: 'curriculum', label: i18n.typeCurriculum },
     { type: 'curriculum', label: i18n.typeCurriculum },
     { type: 'market', label: i18n.typeMarket },
     { type: 'market', label: i18n.typeMarket },
+    { type: 'job', label: i18n.typeJob },
     { type: 'transfer', label: i18n.typeTransfer },
     { type: 'transfer', label: i18n.typeTransfer },
     { type: 'feed', label: i18n.typeFeed },
     { type: 'feed', label: i18n.typeFeed },
     { type: 'post', label: i18n.typePost },
     { type: 'post', label: i18n.typePost },
@@ -551,7 +597,7 @@ exports.activityView = (actions, filter, userId) => {
             div({
             div({
               style: 'display: flex; flex-direction: column; gap: 8px;'
               style: 'display: flex; flex-direction: column; gap: 8px;'
             },
             },
-              activityTypes.slice(11, 15).map(({ type, label }) =>
+              activityTypes.slice(11, 16).map(({ type, label }) =>
                 form({ method: 'GET', action: '/activity' },
                 form({ method: 'GET', action: '/activity' },
                   input({ type: 'hidden', name: 'filter', value: type }),
                   input({ type: 'hidden', name: 'filter', value: type }),
                   button({ type: 'submit', class: filter === type ? 'filter-btn active' : 'filter-btn' }, label)
                   button({ type: 'submit', class: filter === type ? 'filter-btn active' : 'filter-btn' }, label)
@@ -561,7 +607,7 @@ exports.activityView = (actions, filter, userId) => {
             div({
             div({
               style: 'display: flex; flex-direction: column; gap: 8px;'
               style: 'display: flex; flex-direction: column; gap: 8px;'
             },
             },
-              activityTypes.slice(15, 21).map(({ type, label }) =>
+              activityTypes.slice(16, 22).map(({ type, label }) =>
                 form({ method: 'GET', action: '/activity' },
                 form({ method: 'GET', action: '/activity' },
                   input({ type: 'hidden', name: 'filter', value: type }),
                   input({ type: 'hidden', name: 'filter', value: type }),
                   button({ type: 'submit', class: filter === type ? 'filter-btn active' : 'filter-btn' }, label)
                   button({ type: 'submit', class: filter === type ? 'filter-btn active' : 'filter-btn' }, label)
@@ -570,7 +616,7 @@ exports.activityView = (actions, filter, userId) => {
             )
             )
           )
           )
         ),
         ),
-      section({ class: 'feed-container' }, renderActionCards(filteredActions))
+       section({ class: 'feed-container' }, renderActionCards(filteredActions, userId))
     )
     )
   );
   );
   const hasDocument = actions.some(a => a && a.type === 'document');
   const hasDocument = actions.some(a => a && a.type === 'document');

+ 93 - 64
src/views/agenda_view.js

@@ -3,37 +3,44 @@ const { template, i18n } = require('./main_views');
 const moment = require('../server/node_modules/moment');
 const moment = require('../server/node_modules/moment');
 const { config } = require('../server/SSB_server.js');
 const { config } = require('../server/SSB_server.js');
 
 
-userId = config.keys.id;
+const userId = config.keys.id;
 
 
 const renderCardField = (labelText, value) =>
 const renderCardField = (labelText, value) =>
   div({ class: 'card-field' },
   div({ class: 'card-field' },
     span({ class: 'card-label' }, labelText),
     span({ class: 'card-label' }, labelText),
-    span({ class: 'card-value' }, value)
+    span(
+      { class: 'card-value' },
+      ...(Array.isArray(value) ? value : [value ?? ''])
+    )
   );
   );
-  
+
 function getViewDetailsAction(item) {
 function getViewDetailsAction(item) {
   switch (item.type) {
   switch (item.type) {
     case 'transfer': return `/transfers/${encodeURIComponent(item.id)}`;
     case 'transfer': return `/transfers/${encodeURIComponent(item.id)}`;
     case 'tribe': return `/tribe/${encodeURIComponent(item.id)}`;
     case 'tribe': return `/tribe/${encodeURIComponent(item.id)}`;
     case 'event': return `/events/${encodeURIComponent(item.id)}`;
     case 'event': return `/events/${encodeURIComponent(item.id)}`;
-    case 'task': return `/tasks/${encodeURIComponent(item.id)}`;    
+    case 'task': return `/tasks/${encodeURIComponent(item.id)}`;
     case 'market': return `/market/${encodeURIComponent(item.id)}`;
     case 'market': return `/market/${encodeURIComponent(item.id)}`;
     case 'report': return `/reports/${encodeURIComponent(item.id)}`;
     case 'report': return `/reports/${encodeURIComponent(item.id)}`;
-    }
-   }
+    case 'job': return `/jobs/${encodeURIComponent(item.id)}`;
+    default: return `/messages/${encodeURIComponent(item.id)}`;
+  }
+}
 
 
 const renderAgendaItem = (item, userId, filter) => {
 const renderAgendaItem = (item, userId, filter) => {
   const fmt = d => moment(d).format('YYYY/MM/DD HH:mm:ss');
   const fmt = d => moment(d).format('YYYY/MM/DD HH:mm:ss');
-  const author = item.seller || item.organizer || item.from || item.author;
+  const author = item.seller || item.organizer || item.from || item.author || '';
 
 
   const commonFields = [
   const commonFields = [
     p({ class: 'card-footer' },
     p({ class: 'card-footer' },
-      span({ class: 'date-link' }, `${moment(item.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed} `),
-      a({ href: `/author/${encodeURIComponent(author)}`, class: 'user-link' }, `${author}`)
+      span({ class: 'date-link' }, `${item.createdAt ? moment(item.createdAt).format('YYYY/MM/DD HH:mm:ss') : ''} ${i18n.performed} `),
+      author ? a({ href: `/author/${encodeURIComponent(author)}`, class: 'user-link' }, `${author}`) : ''
     )
     )
   ];
   ];
+
   let details = [];
   let details = [];
   let actionButton = null;
   let actionButton = null;
+
   if (filter === 'discarded') {
   if (filter === 'discarded') {
     actionButton = form({ method: 'POST', action: `/agenda/restore/${encodeURIComponent(item.id)}` },
     actionButton = form({ method: 'POST', action: `/agenda/restore/${encodeURIComponent(item.id)}` },
       button({ type: 'submit', class: 'restore-btn' }, i18n.agendaRestoreButton)
       button({ type: 'submit', class: 'restore-btn' }, i18n.agendaRestoreButton)
@@ -43,66 +50,56 @@ const renderAgendaItem = (item, userId, filter) => {
       button({ type: 'submit', class: 'discard-btn' }, i18n.agendaDiscardButton)
       button({ type: 'submit', class: 'discard-btn' }, i18n.agendaDiscardButton)
     );
     );
   }
   }
+
   if (item.type === 'market') {
   if (item.type === 'market') {
-  details = [
-    renderCardField(i18n.marketItemType + ":", item.item_type.toUpperCase()),
-    renderCardField(i18n.marketItemStatus + ":", item.status),
-    renderCardField(i18n.marketItemStock + ":", item.stock), 
-    renderCardField(i18n.marketItemPrice + ":", `${item.price} ECO`),
-    renderCardField(i18n.marketItemIncludesShipping + ":", item.includesShipping ? i18n.agendaYes : i18n.agendaNo),
-    renderCardField(i18n.deadline + ":", new Date(item.deadline).toLocaleString()),
-   ];
-    if (item.item_type === 'auction') {
-      const bids = item.auctions_poll.map(bid => parseFloat(bid.split(':')[1]))
+    details = [
+      renderCardField(i18n.marketItemType + ":", String(item.item_type || '').toUpperCase()),
+      renderCardField(i18n.marketItemStatus + ":", item.status),
+      renderCardField(i18n.marketItemStock + ":", item.stock),
+      renderCardField(i18n.marketItemPrice + ":", `${item.price} ECO`),
+      renderCardField(i18n.marketItemIncludesShipping + ":", item.includesShipping ? i18n.agendaYes : i18n.agendaNo),
+      renderCardField(i18n.deadline + ":", item.deadline ? new Date(item.deadline).toLocaleString() : '')
+    ];
+    if (String(item.item_type || '').toLowerCase() === 'auction') {
+      const bids = Array.isArray(item.auctions_poll) ? item.auctions_poll.map(bid => parseFloat(String(bid).split(':')[1])).filter(n => !isNaN(n)) : [];
       const maxBid = bids.length ? Math.max(...bids) : 0;
       const maxBid = bids.length ? Math.max(...bids) : 0;
-          details.push(
-      renderCardField(i18n.marketItemHighestBid + ":", `${maxBid} ECO`));
-    };
-    const seller = p(a({ class: "user-link", href: `/author/${encodeURIComponent(item.seller)}` }, item.seller))
-      details.push(
-      br,
-      div({ class: 'members-list' }, i18n.marketItemSeller + ': ', seller)
-    );
+      details.push(renderCardField(i18n.marketItemHighestBid + ":", `${maxBid} ECO`));
+    }
+    const seller = author ? p(a({ class: "user-link", href: `/author/${encodeURIComponent(author)}` }, author)) : '';
+    details.push(br(), div({ class: 'members-list' }, i18n.marketItemSeller + ': ', seller));
   }
   }
 
 
   if (item.type === 'tribe') {
   if (item.type === 'tribe') {
     details = [
     details = [
       renderCardField(i18n.agendaAnonymousLabel + ":", item.isAnonymous ? i18n.agendaYes : i18n.agendaNo),
       renderCardField(i18n.agendaAnonymousLabel + ":", item.isAnonymous ? i18n.agendaYes : i18n.agendaNo),
-      renderCardField(i18n.agendaInviteModeLabel + ":", item.inviteMode.toUpperCase() || i18n.noInviteMode),
+      renderCardField(i18n.agendaInviteModeLabel + ":", (item.inviteMode ? String(item.inviteMode).toUpperCase() : i18n.noInviteMode)),
       renderCardField(i18n.agendaLARPLabel + ":", item.isLARP ? i18n.agendaYes : i18n.agendaNo),
       renderCardField(i18n.agendaLARPLabel + ":", item.isLARP ? i18n.agendaYes : i18n.agendaNo),
       renderCardField(i18n.agendaLocationLabel + ":", item.location || i18n.noLocation),
       renderCardField(i18n.agendaLocationLabel + ":", item.location || i18n.noLocation),
-      renderCardField(i18n.agendaMembersCount + ":", item.members.length || 0),
-      br(),
+      renderCardField(i18n.agendaMembersCount + ":", Array.isArray(item.members) ? item.members.length : 0),
+      br()
     ];
     ];
-    const membersList = item.members.map(member =>
-      p(a({ class: "user-link", href: `/author/${encodeURIComponent(member)}` }, member))
-    );
-    details.push(
-      div({ class: 'members-list' }, `${i18n.agendaMembersLabel}:`, membersList)
-    );
+    const membersList = Array.isArray(item.members) ? item.members.map(member => p(a({ class: "user-link", href: `/author/${encodeURIComponent(member)}` }, member))) : [];
+    details.push(div({ class: 'members-list' }, `${i18n.agendaMembersLabel}:`, membersList));
   }
   }
 
 
   if (item.type === 'report') {
   if (item.type === 'report') {
     details = [
     details = [
       renderCardField(i18n.agendareportStatus + ":", item.status || i18n.noStatus),
       renderCardField(i18n.agendareportStatus + ":", item.status || i18n.noStatus),
       renderCardField(i18n.agendareportCategory + ":", item.category || i18n.noCategory),
       renderCardField(i18n.agendareportCategory + ":", item.category || i18n.noCategory),
-      renderCardField(i18n.agendareportSeverity + ":", item.severity.toUpperCase() || i18n.noSeverity),
+      renderCardField(i18n.agendareportSeverity + ":", (item.severity ? String(item.severity).toUpperCase() : i18n.noSeverity))
     ];
     ];
   }
   }
 
 
   if (item.type === 'event') {
   if (item.type === 'event') {
     details = [
     details = [
-      renderCardField(i18n.eventDateLabel + ":", fmt(item.date)),
-      renderCardField(i18n.eventLocationLabel + ":", item.location),
+      renderCardField(i18n.eventDateLabel + ":", item.date ? fmt(item.date) : ''),
+      renderCardField(i18n.eventLocationLabel + ":", item.location || ''),
       renderCardField(i18n.eventPriceLabel + ":", `${item.price} ECO`),
       renderCardField(i18n.eventPriceLabel + ":", `${item.price} ECO`),
       renderCardField(
       renderCardField(
-  	i18n.eventUrlLabel + ":",
- 	 item.url
-   	 ? p(a({href: item.url, target: "_blank" }, item.url))
-         : p(i8n.noUrl)
-         ),
+        i18n.eventUrlLabel + ":",
+        item.url ? p(a({ href: item.url, target: "_blank" }, item.url)) : p(i18n.noUrl)
+      )
     ];
     ];
-
     actionButton = actionButton || form({ method: 'POST', action: `/events/attend/${encodeURIComponent(item.id)}` },
     actionButton = actionButton || form({ method: 'POST', action: `/events/attend/${encodeURIComponent(item.id)}` },
       button({ type: 'submit', class: 'assign-btn' }, `${i18n.eventAttendButton}`)
       button({ type: 'submit', class: 'assign-btn' }, `${i18n.eventAttendButton}`)
     );
     );
@@ -112,16 +109,13 @@ const renderAgendaItem = (item, userId, filter) => {
     details = [
     details = [
       renderCardField(i18n.taskStatus + ":", item.status),
       renderCardField(i18n.taskStatus + ":", item.status),
       renderCardField(i18n.taskPriorityLabel + ":", item.priority),
       renderCardField(i18n.taskPriorityLabel + ":", item.priority),
-      renderCardField(i18n.taskStartTimeLabel + ":",  new Date(item.startTime).toLocaleString()),
-      renderCardField(i18n.taskEndTimeLabel + ":", new Date(item.endTime).toLocaleString()),
-      renderCardField(i18n.taskLocationLabel + ":", item.location),
+      renderCardField(i18n.taskStartTimeLabel + ":", item.startTime ? new Date(item.startTime).toLocaleString() : ''),
+      renderCardField(i18n.taskEndTimeLabel + ":", item.endTime ? new Date(item.endTime).toLocaleString() : ''),
+      renderCardField(i18n.taskLocationLabel + ":", item.location || '')
     ];
     ];
-
     const assigned = Array.isArray(item.assignees) && item.assignees.includes(userId);
     const assigned = Array.isArray(item.assignees) && item.assignees.includes(userId);
     actionButton = actionButton || form({ method: 'POST', action: `/tasks/assign/${encodeURIComponent(item.id)}` },
     actionButton = actionButton || form({ method: 'POST', action: `/tasks/assign/${encodeURIComponent(item.id)}` },
-      button({ type: 'submit', class: 'assign-btn' },
-        assigned ? i18n.taskUnassignButton : i18n.taskAssignButton
-      )
+      button({ type: 'submit', class: 'assign-btn' }, assigned ? i18n.taskUnassignButton : i18n.taskAssignButton)
     );
     );
   }
   }
 
 
@@ -129,31 +123,64 @@ const renderAgendaItem = (item, userId, filter) => {
     details = [
     details = [
       renderCardField(i18n.agendaTransferConcept + ":", item.concept),
       renderCardField(i18n.agendaTransferConcept + ":", item.concept),
       renderCardField(i18n.agendaTransferAmount + ":", item.amount),
       renderCardField(i18n.agendaTransferAmount + ":", item.amount),
-      renderCardField(i18n.agendaTransferDeadline + ":", fmt(item.deadline)),
-      br,
+      renderCardField(i18n.agendaTransferDeadline + ":", item.deadline ? fmt(item.deadline) : ''),
+      br()
     ];
     ];
-    const membersList = p(a({ class: "user-link", href: `/author/${encodeURIComponent(item.to)}` }, item.to))
-    details.push(
-      div({ class: 'members-list' }, i18n.to + ': ', membersList)
-    );
+    const membersList = item.to ? p(a({ class: "user-link", href: `/author/${encodeURIComponent(item.to)}` }, item.to)) : '';
+    details.push(div({ class: 'members-list' }, i18n.to + ': ', membersList));
+  }
+
+  if (item.type === 'job') {
+    const subs = Array.isArray(item.subscribers)
+      ? item.subscribers
+      : (typeof item.subscribers === 'string'
+          ? item.subscribers.split(',').map(s => s.trim()).filter(Boolean)
+          : (item.subscribers && typeof item.subscribers.length === 'number'
+              ? Array.from(item.subscribers)
+              : []));
+
+    const subsInterleaved = subs
+      .map((id, i) => [i > 0 ? ', ' : '', a({ class: 'user-link', href: `/author/${encodeURIComponent(id)}` }, id)])
+      .flat();
+
+    details = [
+      renderCardField(i18n.jobStatus + ":", item.status),
+      renderCardField(i18n.jobLocation + ":", (item.location || '').toUpperCase()),
+      renderCardField(i18n.jobType + ":", (item.job_type || '').toUpperCase()),
+      renderCardField(i18n.jobSalary + ":", `${item.salary} ECO`),
+      renderCardField(i18n.jobVacants + ":", item.vacants),
+      renderCardField(i18n.jobLanguages + ":", (item.languages || '').toUpperCase()),
+      br(),
+      div(
+        { class: 'members-list' },
+        i18n.jobSubscribers + ': ',br(),br(),
+        ...(subs.length ? subsInterleaved : [i18n.noSubscribers.toUpperCase()])
+      ),
+    ];
+
+    const subscribed = subs.includes(userId);
+    if (!subscribed && String(item.status).toUpperCase() !== 'CLOSED' && item.author !== userId) {
+      actionButton = form({ method: 'GET', action: `/jobs/subscribe/${encodeURIComponent(item.id)}` },
+        button({ type: 'submit', class: 'subscribe-btn' }, i18n.jobSubscribeButton)
+      );
+    }
   }
   }
 
 
   return div({ class: 'agenda-item card' },
   return div({ class: 'agenda-item card' },
-    h2(`[${item.type.toUpperCase()}] ${item.title || item.name || item.concept}`),
+    h2(`[${String(item.type || '').toUpperCase()}] ${item.title || item.name || item.concept || ''}`),
     form({ method: "GET", action: getViewDetailsAction(item) },
     form({ method: "GET", action: getViewDetailsAction(item) },
       button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
       button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
-    ),    
+    ),
     actionButton,
     actionButton,
-    br,
+    br(),
     ...details,
     ...details,
-    br,
-    ...commonFields,
+    br(),
+    ...commonFields
   );
   );
 };
 };
 
 
 exports.agendaView = async (data, filter) => {
 exports.agendaView = async (data, filter) => {
   const { items, counts } = data;
   const { items, counts } = data;
-
   return template(
   return template(
     i18n.agendaTitle,
     i18n.agendaTitle,
     section(
     section(
@@ -181,6 +208,8 @@ exports.agendaView = async (data, filter) => {
             `${i18n.agendaFilterMarket} (${counts.market})`),
             `${i18n.agendaFilterMarket} (${counts.market})`),
           button({ type: 'submit', name: 'filter', value: 'transfers', class: filter === 'transfers' ? 'filter-btn active' : 'filter-btn' },
           button({ type: 'submit', name: 'filter', value: 'transfers', class: filter === 'transfers' ? 'filter-btn active' : 'filter-btn' },
             `${i18n.agendaFilterTransfers} (${counts.transfers})`),
             `${i18n.agendaFilterTransfers} (${counts.transfers})`),
+          button({ type: 'submit', name: 'filter', value: 'jobs', class: filter === 'jobs' ? 'filter-btn active' : 'filter-btn' },
+            `${i18n.agendaFilterJobs} (${counts.jobs})`),
           button({ type: 'submit', name: 'filter', value: 'discarded', class: filter === 'discarded' ? 'filter-btn active' : 'filter-btn' },
           button({ type: 'submit', name: 'filter', value: 'discarded', class: filter === 'discarded' ? 'filter-btn active' : 'filter-btn' },
             `DISCARDED (${counts.discarded})`)
             `DISCARDED (${counts.discarded})`)
         )
         )

+ 164 - 191
src/views/blockchain_view.js

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

+ 1 - 1
src/views/event_view.js

@@ -94,7 +94,7 @@ exports.eventView = async (events, filter, eventId) => {
 
 
   let filtered
   let filtered
   if (filter === 'all') {
   if (filter === 'all') {
-    filtered = list.filter(e => e.isPublic === "public")
+    filtered = list.filter(e => String(e.isPublic).toLowerCase() === 'public')
   } else if (filter === 'mine') {
   } else if (filter === 'mine') {
     filtered = list.filter(e => e.organizer === userId)
     filtered = list.filter(e => e.organizer === userId)
   } else if (filter === 'today') {
   } else if (filter === 'today') {

+ 102 - 109
src/views/inhabitants_view.js

@@ -1,4 +1,4 @@
-const { div, h2, p, section, button, form, img, a, textarea, input, br } = require("../server/node_modules/hyperaxe");
+const { div, h2, p, section, button, form, img, a, textarea, input, br, span } = require("../server/node_modules/hyperaxe");
 const { template, i18n } = require('./main_views');
 const { template, i18n } = require('./main_views');
 const { renderUrl } = require('../backend/renderUrl');
 const { renderUrl } = require('../backend/renderUrl');
 
 
@@ -12,8 +12,8 @@ function resolvePhoto(photoField, size = 256) {
   return '/assets/images/default-avatar.png';
   return '/assets/images/default-avatar.png';
 }
 }
 
 
-const generateFilterButtons = (filters, currentFilter) => {
-  return filters.map(mode => 
+const generateFilterButtons = (filters, currentFilter) =>
+  filters.map(mode =>
     form({ method: 'GET', action: '/inhabitants' },
     form({ method: 'GET', action: '/inhabitants' },
       input({ type: 'hidden', name: 'filter', value: mode }),
       input({ type: 'hidden', name: 'filter', value: mode }),
       button({
       button({
@@ -22,71 +22,77 @@ const generateFilterButtons = (filters, currentFilter) => {
       }, i18n[mode + 'Button'] || i18n[mode + 'SectionTitle'] || mode)
       }, i18n[mode + 'Button'] || i18n[mode + 'SectionTitle'] || mode)
     )
     )
   );
   );
-};
 
 
-const renderInhabitantCard = (user, filter) => {
+const renderInhabitantCard = (user, filter, currentUserId) => {
+  const isMe = user.id === currentUserId;
   return div({ class: 'inhabitant-card' },
   return div({ class: 'inhabitant-card' },
-  img({
-    class: 'inhabitant-photo',
-    src: resolvePhoto(user.photo),
-  }),
+    img({ class: 'inhabitant-photo', src: resolvePhoto(user.photo) }),
     div({ class: 'inhabitant-details' },
     div({ class: 'inhabitant-details' },
       h2(user.name),
       h2(user.name),
-        user.description ? p(...renderUrl(user.description)) : null,
+      user.description ? p(...renderUrl(user.description)) : null,
       filter === 'MATCHSKILLS' && user.commonSkills?.length
       filter === 'MATCHSKILLS' && user.commonSkills?.length
-        ? p(`${i18n.commonSkills}: ${user.commonSkills.join(', ')}`) : null,
+        ? div({ class: 'matchskills' },
+            p(`${i18n.commonSkills}: ${user.commonSkills.join(', ')}`),
+            p(`${i18n.matchScore}: ${Math.round(user.matchScore * 100)}%`)
+          )
+        : null,
       filter === 'SUGGESTED' && user.mutualCount
       filter === 'SUGGESTED' && user.mutualCount
         ? p(`${i18n.mutualFollowers}: ${user.mutualCount}`) : null,
         ? p(`${i18n.mutualFollowers}: ${user.mutualCount}`) : null,
       filter === 'blocked' && user.isBlocked
       filter === 'blocked' && user.isBlocked
         ? p(i18n.blockedLabel) : null,
         ? p(i18n.blockedLabel) : null,
       p(a({ class: 'user-link', href: `/author/${encodeURIComponent(user.id)}` }, user.id)),
       p(a({ class: 'user-link', href: `/author/${encodeURIComponent(user.id)}` }, user.id)),
-      ['CVs', 'MATCHSKILLS', 'SUGGESTED'].includes(filter)
-        ? a({ href: `/inhabitant/${encodeURIComponent(user.id)}`, class: 'view-profile-btn' }, i18n.inhabitantviewDetails)
-        : null
+
+      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')
+            ? form(
+                { method: 'GET', action: `/inhabitant/${encodeURIComponent(user.id)}` },
+                button({ type: 'submit', class: 'btn' }, i18n.inhabitantviewDetails)
+              )
+            : null,
+        !isMe
+          ? form(
+              { method: 'GET', action: '/pm' },
+              input({ type: 'hidden', name: 'recipients', value: user.id }),
+              button({ type: 'submit', class: 'btn' }, i18n.pmCreateButton)
+            )
+          : null
+      )
     )
     )
   );
   );
 };
 };
 
 
-const renderGalleryInhabitants = (inhabitants) => {
-  return div({ class: "gallery", style: 'display:grid; grid-template-columns: repeat(3, 1fr); gap:16px;' },
+const renderGalleryInhabitants = inhabitants =>
+  div(
+    { class: "gallery", style: 'display:grid; grid-template-columns: repeat(3, 1fr); gap:16px;' },
     inhabitants.length
     inhabitants.length
-      ? inhabitants.map(u => {
-          const photo = resolvePhoto(u.photo);
-          return a({ href: `#inhabitant-${encodeURIComponent(u.id)}`, class: "gallery-item" },
-            img({
-              src: photo,
-              alt: u.name || "Anonymous",
-              class: "gallery-image"
-            })
-          );
-        })
+      ? inhabitants.map(u =>
+          a({ href: `#inhabitant-${encodeURIComponent(u.id)}`, class: "gallery-item" },
+            img({ src: resolvePhoto(u.photo), alt: u.name || "Anonymous", class: "gallery-image" })
+          )
+        )
       : p(i18n.noInhabitantsFound)
       : p(i18n.noInhabitantsFound)
   );
   );
-};
 
 
-const renderLightbox = (inhabitants) => {
-  return inhabitants.map(u => {
-  const photoUrl = resolvePhoto(u.photo);
-    return div(
+const renderLightbox = inhabitants =>
+  inhabitants.map(u =>
+    div(
       { id: `inhabitant-${encodeURIComponent(u.id)}`, class: "lightbox" },
       { id: `inhabitant-${encodeURIComponent(u.id)}`, class: "lightbox" },
       a({ href: "#", class: "lightbox-close" }, "×"),
       a({ href: "#", class: "lightbox-close" }, "×"),
-      img({ 
-        src: photoUrl, 
-        class: "lightbox-image", 
-        alt: u.name || "Anonymous" 
-      })
-    );
-  });
-};
+      img({ src: resolvePhoto(u.photo), class: "lightbox-image", alt: u.name || "Anonymous" })
+    )
+  );
 
 
-exports.inhabitantsView = (inhabitants, filter, query) => {
-  const title = filter === 'contacts'    ? i18n.yourContacts              :
-                filter === 'CVs'         ? i18n.allCVs                    :
-                filter === 'MATCHSKILLS' ? i18n.matchSkills               :
-                filter === 'SUGGESTED'   ? i18n.suggestedSectionTitle     :
-                filter === 'blocked'     ? i18n.blockedSectionTitle       :
-                filter === 'GALLERY'     ? i18n.gallerySectionTitle       :
-                                            i18n.allInhabitants;
+exports.inhabitantsView = (inhabitants, filter, query, currentUserId) => {
+  const title = filter === 'contacts'    ? i18n.yourContacts
+               : filter === 'CVs'         ? i18n.allCVs
+               : filter === 'MATCHSKILLS' ? i18n.matchSkills
+               : filter === 'SUGGESTED'   ? i18n.suggestedSectionTitle
+               : filter === 'blocked'     ? i18n.blockedSectionTitle
+               : filter === 'GALLERY'     ? i18n.gallerySectionTitle
+                                          : i18n.allInhabitants;
 
 
   const showCVFilters = filter === 'CVs' || filter === 'MATCHSKILLS';
   const showCVFilters = filter === 'CVs' || filter === 'MATCHSKILLS';
   const filters = ['all', 'contacts', 'SUGGESTED', 'blocked', 'CVs', 'MATCHSKILLS', 'GALLERY'];
   const filters = ['all', 'contacts', 'SUGGESTED', 'blocked', 'CVs', 'MATCHSKILLS', 'GALLERY'];
@@ -109,39 +115,23 @@ exports.inhabitantsView = (inhabitants, filter, query) => {
           }),
           }),
           showCVFilters
           showCVFilters
             ? [
             ? [
-                input({
-                  type: 'text',
-                  name: 'location',
-                  placeholder: i18n.filterLocation,
-                  value: query.location || ''
-                }),
-                input({
-                  type: 'text',
-                  name: 'language',
-                  placeholder: i18n.filterLanguage,
-                  value: query.language || ''
-                }),
-                input({
-                  type: 'text',
-                  name: 'skills',
-                  placeholder: i18n.filterSkills,
-                  value: query.skills || ''
-                })
+                input({ type: 'text', name: 'location', placeholder: i18n.filterLocation, value: query.location || '' }),
+                input({ type: 'text', name: 'language', placeholder: i18n.filterLanguage, value: query.language || '' }),
+                input({ type: 'text', name: 'skills', placeholder: i18n.filterSkills, value: query.skills || '' })
               ]
               ]
             : null,
             : null,
           br(),
           br(),
           button({ type: 'submit' }, i18n.applyFilters)
           button({ type: 'submit' }, i18n.applyFilters)
         )
         )
       ),
       ),
-      div({ class: 'inhabitant-action', style: 'margin-top: 1em;' },
-        generateFilterButtons(filters, filter)
+      div({ class: 'inhabitant-action', style: 'margin-top:1em;' },
+        ...generateFilterButtons(filters, filter)
       ),
       ),
-      
-      filter === 'GALLERY' 
+      filter === 'GALLERY'
         ? renderGalleryInhabitants(inhabitants)
         ? renderGalleryInhabitants(inhabitants)
         : div({ class: 'inhabitants-list' },
         : div({ class: 'inhabitants-list' },
-            inhabitants && inhabitants.length > 0
-              ? inhabitants.map(user => renderInhabitantCard(user, filter))
+            inhabitants.length
+              ? inhabitants.map(user => renderInhabitantCard(user, filter, currentUserId))
               : p({ class: 'no-results' }, i18n.noInhabitantsFound)
               : p({ class: 'no-results' }, i18n.noInhabitantsFound)
           ),
           ),
       ...renderLightbox(inhabitants)
       ...renderLightbox(inhabitants)
@@ -149,38 +139,37 @@ exports.inhabitantsView = (inhabitants, filter, query) => {
   );
   );
 };
 };
 
 
-exports.inhabitantsProfileView = ({ about = {}, cv = {}, feed = [] }) => {
-  const profile = cv || about || {};
-  const id = cv?.author || about?.about || 'unknown';
-  const name = cv?.name || about?.name || 'Unnamed';
-  const description = cv?.description || about?.description || '';
-  const image = resolvePhoto(cv?.photo) || '/assets/images/default-oasis.jpg';
-  const location = cv?.location || '';
-  const languages = typeof cv?.languages === 'string'
+exports.inhabitantsProfileView = ({ about = {}, cv = {}, feed = [] }, currentUserId) => {
+  const profile = Object.keys(cv).length ? cv : about;
+  const id = cv.author || about.about || 'unknown';
+  const name = cv.name || about.name || 'Unnamed';
+  const description = cv.description || about.description || '';
+  const image = resolvePhoto(cv.photo) || '/assets/images/default-oasis.jpg';
+  const location = cv.location || '';
+  const languages = typeof cv.languages === 'string'
     ? cv.languages.split(',').map(x => x.trim()).filter(Boolean)
     ? cv.languages.split(',').map(x => x.trim()).filter(Boolean)
-    : Array.isArray(cv?.languages) ? cv.languages : [];
+    : Array.isArray(cv.languages) ? cv.languages : [];
   const skills = [
   const skills = [
-    ...(cv?.personalSkills || []),
-    ...(cv?.oasisSkills || []),
-    ...(cv?.educationalSkills || []),
-    ...(cv?.professionalSkills || [])
+    ...(cv.personalSkills || []),
+    ...(cv.oasisSkills || []),
+    ...(cv.educationalSkills || []),
+    ...(cv.professionalSkills || [])
   ];
   ];
-  const status = cv?.status || '';
-  const preferences = cv?.preferences || '';
-  const createdAt = cv?.createdAt ? new Date(cv.createdAt).toLocaleString() : '';
+  const status = cv.status || '';
+  const preferences = cv.preferences || '';
+  const createdAt = cv.createdAt ? new Date(cv.createdAt).toLocaleString() : '';
+  const isMe = id === currentUserId;
   const title = i18n.inhabitantProfileTitle || i18n.inhabitantviewDetails;
   const title = i18n.inhabitantProfileTitle || i18n.inhabitantviewDetails;
 
 
-  const header = div({ class: 'tags-header' },
-    h2(title),
-    p(i18n.discoverPeople)
-  );
-
   return template(
   return template(
     name,
     name,
     section(
     section(
-      header,
+      div({ class: 'tags-header' },
+        h2(title),
+        p(i18n.discoverPeople)
+      ),
       div({ class: 'mode-buttons', style: 'display:flex; gap:8px; margin-top:16px;' },
       div({ class: 'mode-buttons', style: 'display:flex; gap:8px; margin-top:16px;' },
-        generateFilterButtons(['all', 'contacts', 'SUGGESTED', 'blocked', 'CVs', 'MATCHSKILLS', 'GALLERY'], 'all')
+        ...generateFilterButtons(['all', 'contacts', 'SUGGESTED', 'blocked', 'CVs', 'MATCHSKILLS', 'GALLERY'], 'all')
       ),
       ),
       div({ class: 'inhabitant-card', style: 'margin-top:32px;' },
       div({ class: 'inhabitant-card', style: 'margin-top:32px;' },
         img({ class: 'inhabitant-photo', src: image, alt: name }),
         img({ class: 'inhabitant-photo', src: image, alt: name }),
@@ -193,21 +182,25 @@ exports.inhabitantsProfileView = ({ about = {}, cv = {}, feed = [] }) => {
           skills.length ? p(`${i18n.skillsLabel}: ${skills.join(', ')}`) : null,
           skills.length ? p(`${i18n.skillsLabel}: ${skills.join(', ')}`) : null,
           status ? p(`${i18n.statusLabel || 'Status'}: ${status}`) : null,
           status ? p(`${i18n.statusLabel || 'Status'}: ${status}`) : null,
           preferences ? p(`${i18n.preferencesLabel || 'Preferences'}: ${preferences}`) : null,
           preferences ? p(`${i18n.preferencesLabel || 'Preferences'}: ${preferences}`) : null,
-          createdAt ? p(`${i18n.createdAtLabel || 'Created at'}: ${createdAt}`) : null
+          createdAt ? p(`${i18n.createdAtLabel || 'Created at'}: ${createdAt}`) : null,
+          !isMe
+            ? form(
+                { method: 'GET', action: '/pm' },
+                input({ type: 'hidden', name: 'recipients', value: id }),
+                button({ type: 'submit', class: 'btn', style: 'margin-top:1em;' }, i18n.pmCreateButton)
+              )
+            : null
         )
         )
       ),
       ),
-	feed && feed.length
-	  ? section({ class: 'profile-feed' },
-	      h2(i18n.latestInteractions),
-	      feed.map(m => {
-		const contentText = m.value.content.text || '';
-		const cleanText = contentText.replace(/<br\s*\/?>/g, '');	
-		return div({ class: 'post' },
-		  p(...renderUrl(cleanText)) 
-		);
-	      })
-	    )
-          : null
-      )
-    );
+      feed.length
+        ? section({ class: 'profile-feed' },
+            h2(i18n.latestInteractions),
+            ...feed.map(m => {
+              const text = (m.value.content.text || '').replace(/<br\s*\/?>/g, '');
+              return div({ class: 'post' }, p(...renderUrl(text)));
+            })
+          )
+        : null
+    )
+  );
 };
 };

+ 315 - 0
src/views/jobs_view.js

@@ -0,0 +1,315 @@
+const { form, button, div, h2, p, section, input, label, textarea, br, a, span, select, option, img } = require("../server/node_modules/hyperaxe");
+const { template, i18n } = require('./main_views');
+const moment = require("../server/node_modules/moment");
+const { config } = require('../server/SSB_server.js');
+const { renderUrl } = require('../backend/renderUrl');
+
+const userId = config.keys.id;
+
+const FILTERS = [
+  { key: 'ALL',        i18n: 'jobsFilterAll',        title: 'jobsAllTitle' },
+  { key: 'MINE',       i18n: 'jobsFilterMine',       title: 'jobsMineTitle' },
+  { key: 'REMOTE',     i18n: 'jobsFilterRemote',     title: 'jobsRemoteTitle' },
+  { key: 'PRESENCIAL', i18n: 'jobsFilterPresencial', title: 'jobsPresencialTitle' },
+  { key: 'FREELANCER', i18n: 'jobsFilterFreelancer', title: 'jobsFreelancerTitle' },
+  { key: 'EMPLOYEE',   i18n: 'jobsFilterEmployee',   title: 'jobsEmployeeTitle' },
+  { key: 'OPEN',       i18n: 'jobsFilterOpen',       title: 'jobsOpenTitle' },
+  { key: 'CLOSED',     i18n: 'jobsFilterClosed',     title: 'jobsClosedTitle' },
+  { key: 'RECENT',     i18n: 'jobsFilterRecent',     title: 'jobsRecentTitle' },
+  { key: 'CV',         i18n: 'jobsCV',               title: 'jobsCVTitle' },
+  { key: 'TOP',        i18n: 'jobsFilterTop',        title: 'jobsTopTitle' }
+];
+
+function resolvePhoto(photoField, size = 256) {
+  if (typeof photoField === 'string' && photoField.startsWith('/image/')) return photoField;
+  if (/^&[A-Za-z0-9+/=]+\.sha256$/.test(photoField)) return `/image/${size}/${encodeURIComponent(photoField)}`;
+  return '/assets/images/default-avatar.png';
+}
+
+const renderCardField = (labelText, value) =>
+  div({ class: 'card-field' },
+    span({ class: 'card-label' }, labelText),
+    span({ class: 'card-value' }, value)
+  );
+
+const renderSubscribers = (subs = []) =>
+  div({ class: 'card-field' },
+    span({ class: 'card-label' }, i18n.jobSubscribers + ':'),
+    span({ class: 'card-value' }, subs && subs.length > 0 ? `${subs.length}` : i18n.noSubscribers.toUpperCase())
+  );
+
+const renderJobList = (jobs, filter) =>
+  jobs.length > 0
+    ? jobs.map(job => {
+        const isMineFilter = String(filter).toUpperCase() === 'MINE';
+        const isAuthor = job.author === userId;
+        const isOpen = String(job.status).toUpperCase() === 'OPEN';
+
+        return div({ class: "job-card" },
+          isMineFilter && isAuthor
+            ? (
+                isOpen
+                  ? div({ class: "job-actions" },
+                      form({ method: "GET", action: `/jobs/edit/${encodeURIComponent(job.id)}` },
+                        button({ class: "update-btn", type: "submit" }, i18n.jobsUpdateButton)
+                      ),
+                      form({ method: "POST", action: `/jobs/delete/${encodeURIComponent(job.id)}` },
+                        button({ class: "delete-btn", type: "submit" }, i18n.jobsDeleteButton)
+                      ),
+                      form({ method: "POST", action: `/jobs/status/${encodeURIComponent(job.id)}` },
+                        button({
+                          class: "status-btn", type: "submit",
+                          name: "status", value: "CLOSED"
+                        }, i18n.jobSetClosed)
+                      )
+                    )
+                  : div({ class: "job-actions" },
+                      form({ method: "POST", action: `/jobs/delete/${encodeURIComponent(job.id)}` },
+                        button({ class: "delete-btn", type: "submit" }, i18n.jobsDeleteButton)
+                      )
+                    )
+              )
+            : null,
+
+          !isMineFilter && !isAuthor && isOpen
+            ? (
+                Array.isArray(job.subscribers) && job.subscribers.includes(userId)
+                  ? form({ method: "POST", action: `/jobs/unsubscribe/${encodeURIComponent(job.id)}` },
+                      button({ type: "submit", class: "unsubscribe-btn" }, i18n.jobUnsubscribeButton)
+                    )
+                  : form({ method: "POST", action: `/jobs/subscribe/${encodeURIComponent(job.id)}` },
+                      button({ type: "submit", class: "subscribe-btn" }, i18n.jobSubscribeButton)
+                    )
+              )
+            : null,
+
+          form({ method: "GET", action: `/jobs/${encodeURIComponent(job.id)}` },
+            button({ type: "submit", class: "filter-btn" }, i18n.viewDetailsButton)
+          ),
+          br(),
+          h2(job.title),
+          job.image
+            ? div({ class: 'activity-image-preview' }, img({ src: `/blob/${encodeURIComponent(job.image)}` }))
+            : null,
+          renderCardField(i18n.jobDescription + ':', ''),
+          p(...renderUrl(job.description)),
+          renderSubscribers(job.subscribers),
+          renderCardField(
+            i18n.jobStatus + ':',
+            i18n['jobStatus' + (String(job.status || '').toUpperCase())] || (String(job.status || '').toUpperCase())
+          ),
+          renderCardField(i18n.jobLanguages + ':', (job.languages || '').toUpperCase()),
+          renderCardField(
+            i18n.jobType + ':',
+            i18n['jobType' + (String(job.job_type || '').toUpperCase())] || (String(job.job_type || '').toUpperCase())
+          ),
+          renderCardField(i18n.jobLocation + ':', (job.location || '').toUpperCase()),
+          renderCardField(
+            i18n.jobTime + ':',
+            i18n['jobTime' + (String(job.job_time || '').toUpperCase())] || (String(job.job_time || '').toUpperCase())
+          ),
+          renderCardField(i18n.jobVacants + ':', job.vacants),
+          renderCardField(i18n.jobRequirements + ':', ''),
+          p(...renderUrl(job.requirements)),
+          renderCardField(i18n.jobTasks + ':', ''),
+          p(...renderUrl(job.tasks)),
+          renderCardField(i18n.jobSalary + ':', ''),
+          br(),
+          div({ class: 'card-label' }, h2(`${job.salary} ECO`)),
+          br(),
+          div({ class: 'card-footer' },
+            span({ class: 'date-link' }, `${moment(job.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed} `),
+            a({ href: `/author/${encodeURIComponent(job.author)}`, class: 'user-link' }, job.author)
+          )
+        );
+      })
+    : p(i18n.noJobsFound);
+
+const renderJobForm = (job = {}, mode = 'create') => {
+  const isEdit = mode === 'edit';
+  return div({ class: "div-center job-form" },
+    form({
+        action: isEdit ? `/jobs/update/${encodeURIComponent(job.id)}` : "/jobs/create",
+        method: "POST",
+        enctype: "multipart/form-data"
+      },
+      label(i18n.jobType), br(),
+      select({ name: "job_type", required: true },
+        option({ value: "freelancer", selected: job.job_type === 'freelancer' }, i18n.jobTypeFreelance),
+        option({ value: "employee",  selected: job.job_type === 'employee'  }, i18n.jobTypeSalary)
+      ), br(), br(),
+      label(i18n.jobTitle), br(),
+      input({ type: "text", name: "title", required: true, placeholder: i18n.jobTitlePlaceholder, value: job.title || "" }), br(), br(),
+      label(i18n.jobImage), br(),
+      input({ type: "file", name: "image", accept: "image/*" }), br(),
+      job.image ? img({ src: `/blob/${encodeURIComponent(job.image)}`, class: 'existing-image' }) : null,
+      br(),
+      label(i18n.jobDescription), br(),
+      textarea({ name: "description", rows: "6", required: true, placeholder: i18n.jobDescriptionPlaceholder }, job.description || ""), br(), br(),
+      label(i18n.jobRequirements), br(),
+      textarea({ name: "requirements", rows: "6", placeholder: i18n.jobRequirementsPlaceholder }, job.requirements || ""), br(), br(),
+      label(i18n.jobLanguages), br(),
+      input({ type: "text", name: "languages", placeholder: i18n.jobLanguagesPlaceholder, value: job.languages || "" }), br(), br(),
+      label(i18n.jobTime), br(),
+      select({ name: "job_time", required: true },
+        option({ value: "partial",  selected: job.job_time === 'partial'  }, i18n.jobTimePartial),
+        option({ value: "complete", selected: job.job_time === 'complete' }, i18n.jobTimeComplete)
+      ), br(), br(),
+      label(i18n.jobTasks), br(),
+      textarea({ name: "tasks", rows: "6", placeholder: i18n.jobTasksPlaceholder }, job.tasks || ""), br(), br(),
+      label(i18n.jobLocation), br(),
+      select({ name: "location", required: true },
+        option({ value: "remote",     selected: job.location === 'remote'     }, i18n.jobLocationRemote),
+        option({ value: "presencial", selected: job.location === 'presencial' }, i18n.jobLocationPresencial)
+      ), br(), br(),
+      label(i18n.jobVacants), br(),
+      input({ type: "number", name: "vacants", min: "1", placeholder: i18n.jobVacantsPlaceholder, value: job.vacants || 1, required: true }), br(), br(),
+      label(i18n.jobSalary), br(),
+      input({ type: "number", name: "salary", step: "0.01", placeholder: i18n.jobSalaryPlaceholder, value: job.salary || "" }), br(), br(),
+      button({ type: "submit" }, isEdit ? i18n.jobsUpdateButton : i18n.createJobButton)
+    )
+  );
+};
+
+const renderCVList = (inhabitants) =>
+  div({ class: "cv-list" },
+    inhabitants && inhabitants.length > 0
+      ? inhabitants.map(user => {
+          const isMe = user.id === userId;
+          return div({ class: 'inhabitant-card' },
+            img({ class: 'inhabitant-photo', src: resolvePhoto(user.photo) }),
+            div({ class: 'inhabitant-details' },
+              h2(user.name),
+              user.description ? p(...renderUrl(user.description)) : null,
+              p(a({ class: 'user-link', href: `/author/${encodeURIComponent(user.id)}` }, user.id)),
+              div(
+                { class: 'cv-actions', style: 'display:flex; flex-direction:column; gap:8px; margin-top:12px;' },
+                form(
+                  { method: 'GET', action: `/inhabitant/${encodeURIComponent(user.id)}` },
+                  button({ type: 'submit', class: 'btn' }, i18n.inhabitantviewDetails)
+                ),
+                !isMe
+                  ? form(
+                      { method: 'GET', action: '/pm' },
+                      input({ type: 'hidden', name: 'recipients', value: user.id }),
+                      button({ type: 'submit', class: 'btn' }, i18n.pmCreateButton)
+                    )
+                  : null
+              )
+            )
+          )
+        })
+      : p({ class: 'no-results' }, i18n.noInhabitantsFound)
+  );
+
+exports.jobsView = async (jobsOrCVs, filter = "ALL", cvQuery = {}) => {
+  const filterObj = FILTERS.find(f => f.key === filter) || FILTERS[0];
+  const sectionTitle = i18n[filterObj.title] || i18n.jobsTitle;
+
+  return template(
+    i18n.jobsTitle,
+    section(
+      div({ class: "tags-header" }, h2(sectionTitle), p(i18n.jobsDescription)),
+      div({ class: "filters" },
+        form({ method: "GET", action: "/jobs", style: "display:flex;gap:12px;" },
+          FILTERS.map(f =>
+            button({ type: "submit", name: "filter", value: f.key, class: filter === f.key ? "filter-btn active" : "filter-btn" }, i18n[f.i18n])
+          ).concat(button({ type: "submit", name: "filter", value: "CREATE", class: "create-button" }, i18n.jobsCreateJob))
+        )
+      ),
+      filter === 'CV'
+        ? section(
+            form({ method: "GET", action: "/jobs" },
+              input({ type: "hidden", name: "filter", value: "CV" }),
+              input({ type: "text", name: "location", placeholder: i18n.filterLocation, value: cvQuery.location || "" }),
+              input({ type: "text", name: "language", placeholder: i18n.filterLanguage, value: cvQuery.language || "" }),
+              input({ type: "text", name: "skills", placeholder: i18n.filterSkills, value: cvQuery.skills || "" }),
+              br(), button({ type: "submit" }, i18n.applyFilters)
+            ),
+            br(),
+            renderCVList(jobsOrCVs)
+          )
+        : filter === 'CREATE' || filter === 'EDIT'
+          ? (() => {
+              const jobToEdit = filter === 'EDIT' ? jobsOrCVs[0] : {};
+              return renderJobForm(jobToEdit, filter === 'EDIT' ? 'edit' : 'create');
+            })()
+          : div({ class: "jobs-list" }, renderJobList(jobsOrCVs, filter))
+    )
+  );
+};
+
+exports.singleJobsView = async (job, filter = "ALL") => {
+  const isAuthor = job.author === userId;
+  const isOpen = String(job.status).toUpperCase() === 'OPEN';
+
+  return template(
+    i18n.jobsTitle,
+    section(
+      div({ class: "tags-header" }, h2(i18n.jobsTitle), p(i18n.jobsDescription)),
+      div({ class: "filters" },
+        form({ method: "GET", action: "/jobs", style: "display:flex;gap:12px;" },
+          FILTERS.map(f =>
+            button({ type: "submit", name: "filter", value: f.key, class: filter === f.key ? "filter-btn active" : "filter-btn" }, i18n[f.i18n])
+          ).concat(button({ type: "submit", name: "filter", value: "CREATE", class: "create-button" }, i18n.jobsCreateJob))
+        )
+      ),
+      div({ class: "job-card" },
+        isAuthor
+          ? (
+              isOpen
+                ? div({ class: "job-actions" },
+                    form({ method: "GET", action: `/jobs/edit/${encodeURIComponent(job.id)}` },
+                      button({ class: "update-btn", type: "submit" }, i18n.jobsUpdateButton)
+                    ),
+                    form({ method: "POST", action: `/jobs/delete/${encodeURIComponent(job.id)}` },
+                      button({ class: "delete-btn", type: "submit" }, i18n.jobsDeleteButton)
+                    ),
+                    form({ method: "POST", action: `/jobs/status/${encodeURIComponent(job.id)}` },
+                      button({ class: "status-btn", type: "submit", name: "status", value: "CLOSED" }, i18n.jobSetClosed)
+                    )
+                  )
+                : div({ class: "job-actions" },
+                    form({ method: "POST", action: `/jobs/delete/${encodeURIComponent(job.id)}` },
+                      button({ class: "delete-btn", type: "submit" }, i18n.jobsDeleteButton)
+                    )
+                  )
+            )
+          : null,
+        h2(job.title),
+        job.image ? div({ class: 'activity-image-preview' }, img({ src: `/blob/${encodeURIComponent(job.image)}` })) : null,
+        renderCardField(i18n.jobDescription + ':', ''), p(...renderUrl(job.description)),
+        renderSubscribers(job.subscribers),
+        renderCardField(i18n.jobStatus + ':', i18n['jobStatus' + (String(job.status || '').toUpperCase())] || (String(job.status || '').toUpperCase())),
+        renderCardField(i18n.jobLanguages + ':', (job.languages || '').toUpperCase()),
+        renderCardField(i18n.jobType + ':', i18n['jobType' + (String(job.job_type || '').toUpperCase())] || (String(job.job_type || '').toUpperCase())),
+        renderCardField(i18n.jobLocation + ':', (job.location || '').toUpperCase()),
+        renderCardField(i18n.jobTime + ':', i18n['jobTime' + (String(job.job_time || '').toUpperCase())] || (String(job.job_time || '').toUpperCase())),
+        renderCardField(i18n.jobVacants + ':', job.vacants),
+        renderCardField(i18n.jobRequirements + ':', ''), p(...renderUrl(job.requirements)),
+        renderCardField(i18n.jobTasks + ':', ''), p(...renderUrl(job.tasks)),
+        renderCardField(i18n.jobSalary + ':', ''), br(),
+        div({ class: 'card-label' }, h2(`${job.salary} ECO`)), br(),
+        (isOpen && !isAuthor)
+          ? (
+              Array.isArray(job.subscribers) && job.subscribers.includes(userId)
+                ? div({ class: "subscribe-actions" },
+                    form({ method: "POST", action: `/jobs/unsubscribe/${encodeURIComponent(job.id)}` },
+                      button({ class: "filter-btn", type: "submit" }, i18n.jobUnsubscribeButton.toUpperCase())
+                    )
+                  )
+                : div({ class: "subscribe-actions" },
+                    form({ method: "POST", action: `/jobs/subscribe/${encodeURIComponent(job.id)}` },
+                      button({ class: "filter-btn", type: "submit" }, i18n.jobSubscribeButton.toUpperCase())
+                    )
+                  )
+            )
+          : null,
+        div({ class: 'card-footer' },
+          span({ class: 'date-link' }, `${moment(job.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed} `),
+          a({ href: `/author/${encodeURIComponent(job.author)}`, class: 'user-link' }, job.author)
+        )
+      )
+    )
+  );
+};

+ 136 - 45
src/views/main_views.js

@@ -242,6 +242,15 @@ const renderMarketLink = () => {
     : '';
     : '';
 };
 };
 
 
+const renderJobsLink = () => {
+  const jobsMod = getConfig().modules.jobsMod === 'on';
+  return jobsMod 
+    ? [
+      navLink({ href: "/jobs", emoji: "ꗒ", text: i18n.jobsTitle }),
+      ]
+    : '';
+};
+
 const renderTribesLink = () => {
 const renderTribesLink = () => {
   const tribesMod = getConfig().modules.tribesMod === 'on';
   const tribesMod = getConfig().modules.tribesMod === 'on';
   return tribesMod 
   return tribesMod 
@@ -453,6 +462,7 @@ const template = (titlePrefix, ...elements) => {
               renderFeedLink(),
               renderFeedLink(),
               renderPixeliaLink(),
               renderPixeliaLink(),
               renderMarketLink(),
               renderMarketLink(),
+              renderJobsLink(),
               renderTransfersLink(),
               renderTransfersLink(),
               renderBookmarksLink(),
               renderBookmarksLink(),
               renderImagesLink(),
               renderImagesLink(),
@@ -940,8 +950,17 @@ const prefix = section(
     relationship.me
     relationship.me
       ? a({ href: `/profile/edit`, class: "btn" }, nbsp, i18n.editProfile)
       ? a({ href: `/profile/edit`, class: "btn" }, nbsp, i18n.editProfile)
       : null,
       : null,
-    a({ href: `/likes/${encodeURIComponent(feedId)}`, class: "btn" }, i18n.viewLikes)
-    )
+    a({ href: `/likes/${encodeURIComponent(feedId)}`, class: "btn" }, i18n.viewLikes),
+     !relationship.me
+        ? a(
+            { 
+              href: `/pm?recipients=${encodeURIComponent(feedId)}`, 
+              class: "btn" 
+            },
+            i18n.pmCreateButton
+          )
+        : null
+      )
    )
    )
   );
   );
 
 
@@ -1186,19 +1205,58 @@ exports.mentionsView = ({ messages, myFeedId }) => {
   );
   );
 };
 };
 
 
-exports.privateView = async (input, filter) => {
-  const messages = Array.isArray(input) ? input : input.messages;
+exports.privateView = async (messagesInput, filter) => {
+  const messages = Array.isArray(messagesInput) ? messagesInput : messagesInput.messages;
   const userId = await getUserId();
   const userId = await getUserId();
-  const counts = {
-    inbox: messages.filter(m => m.value.content.to?.includes(userId)).length,
-    sent: messages.filter(m => m.value.content.from === userId).length
-  };
-
   const filtered =
   const filtered =
     filter === 'sent' ? messages.filter(m => m.value.content.from === userId) :
     filter === 'sent' ? messages.filter(m => m.value.content.from === userId) :
     filter === 'inbox' ? messages.filter(m => m.value.content.to?.includes(userId)) :
     filter === 'inbox' ? messages.filter(m => m.value.content.to?.includes(userId)) :
     messages;
     messages;
 
 
+  function header({ sentAt, from, toLinks, botIcon = '', botLabel = '' }) {
+    return div({ class: 'pm-header' },
+      span({ class: 'date-link' }, `${moment(sentAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed}`),
+      botIcon || botLabel ? span({ class: 'pm-from' }, `${botIcon} ${botLabel}`) : null,
+      !botIcon && !botLabel
+        ? [
+            span({ class: 'pm-from' },
+              'From: ', a({ href: `/author/${encodeURIComponent(from)}`, class: 'user-link' }, from)
+            ),
+            span({ class: 'pm-to' },
+              'To: ', toLinks
+            )
+          ] : null
+    );
+  }
+
+  function actions({ key, replyId }) {
+    return div({ class: 'pm-actions' },
+      form({ method: 'POST', action: `/inbox/delete/${encodeURIComponent(key)}`, class: 'delete-message-form', style: 'display:inline-block;margin-right:8px;' },
+        button({ type: 'submit', class: 'delete-btn' }, i18n.privateDelete)
+      ),
+      form({ method: 'GET', action: '/pm', style: 'display:inline-block;' },
+        input({ type: 'hidden', name: 'recipients', value: replyId }),
+        button({ type: 'submit', class: 'reply-btn' }, i18n.pmCreateButton || 'Write a PM')
+      )
+    );
+  }
+  
+  function clickableLinks(str) {
+    return str
+      .replace(/(@[a-zA-Z0-9/+._=-]+\.ed25519)/g,
+        (match, userId) =>
+          `<a class="user-link" href="/author/${encodeURIComponent(userId)}">${match}</a>`
+      )
+      .replace(/\/jobs\/([%a-zA-Z0-9/+._=-]+\.sha256)/g,
+        (match, jobId) =>
+          `<a class="job-link" href="/jobs/${jobId}">${match}</a>`
+      )
+      .replace(/\/market\/([%a-zA-Z0-9/+._=-]+\.sha256)/g,
+        (match, itemId) =>
+          `<a class="market-link" href="/market/${itemId}">${match}</a>`
+      );
+  }
+
   return template(
   return template(
     i18n.private,
     i18n.private,
     section(
     section(
@@ -1206,55 +1264,88 @@ exports.privateView = async (input, filter) => {
         h2(i18n.private),
         h2(i18n.private),
         p(i18n.privateDescription)
         p(i18n.privateDescription)
       ),
       ),
-	div({ class: 'filters' },
-	  form({ method: 'GET', action: '/inbox' }, [
-	    button({
-	      type: 'submit',
-	      name: 'filter',
-	      value: 'inbox',
-	      class: filter === 'inbox' ? 'filter-btn active' : 'filter-btn'
-	    }, i18n.privateInbox),
-	    button({
-	      type: 'submit',
-	      name: 'filter',
-	      value: 'sent',
-	      class: filter === 'sent' ? 'filter-btn active' : 'filter-btn'
-	    }, i18n.privateSent),
-	    button({
-	      type: 'submit',
-	      name: 'filter',
-	      value: 'create',
-	      class: 'create-button',
-	      formaction: '/pm',
-	      formmethod: 'GET'
-	    }, i18n.pmCreateButton)
-	  ])
-	),
+      div({ class: 'filters' },
+        form({ method: 'GET', action: '/inbox' }, [
+          button({
+            type: 'submit',
+            name: 'filter',
+            value: 'inbox',
+            class: filter === 'inbox' ? 'filter-btn active' : 'filter-btn'
+          }, i18n.privateInbox),
+          button({
+            type: 'submit',
+            name: 'filter',
+            value: 'sent',
+            class: filter === 'sent' ? 'filter-btn active' : 'filter-btn'
+          }, i18n.privateSent),
+          button({
+            type: 'submit',
+            name: 'filter',
+            value: 'create',
+            class: 'create-button',
+            formaction: '/pm',
+            formmethod: 'GET'
+          }, i18n.pmCreateButton)
+        ])
+      ),
       div({ class: 'message-list' },
       div({ class: 'message-list' },
         filtered.length
         filtered.length
           ? filtered.map(msg => {
           ? filtered.map(msg => {
               const content = msg?.value?.content;
               const content = msg?.value?.content;
               const author = msg?.value?.author;
               const author = msg?.value?.author;
-              if (!content || !author) {
+              if (!content || !author)
                 return div({ class: 'malformed-message' }, 'Invalid message');
                 return div({ class: 'malformed-message' }, 'Invalid message');
-              }
               const subject = content.subject || '(no subject)';
               const subject = content.subject || '(no subject)';
               const text = content.text || '';
               const text = content.text || '';
-              const sentAt = new Date(content.sentAt || msg.timestamp).toLocaleString();
+              const sentAt = new Date(content.sentAt || msg.timestamp);
               const from = content.from;
               const from = content.from;
               const toLinks = (content.to || []).map(addr =>
               const toLinks = (content.to || []).map(addr =>
                 a({ class: 'user-link', href: `/author/${encodeURIComponent(addr)}` }, addr)
                 a({ class: 'user-link', href: `/author/${encodeURIComponent(addr)}` }, addr)
               );
               );
-              return div({ class: 'message-item' },
-                p({ class: 'card-footer' },
-                span({ class: 'date-link' }, `${sentAt} ${i18n.performed} `),
-                 a({ href: `/author/${encodeURIComponent(from)}`, class: 'user-link' }, `${from}`)
-                ),
+              let jobMatch = text.match(/has subscribed to your job offer "([^"]+)"/);
+              let jobLinkMatch = text.match(/\/jobs\/([%a-zA-Z0-9/+._-]+\.sha256)/);
+              if (jobMatch && jobLinkMatch) {
+                const jobTitle = jobMatch[1];
+                const jobId = jobLinkMatch[1];
+                return div({ class: 'pm-card job-sub-notification' },
+                  header({ sentAt, from, toLinks, botIcon: '🟡', botLabel: '42-JobsBOT' }),
+                  h2({ class: 'pm-title', style: 'color:#ffe082;' }, 'New subscription to your job offer'),
+                  p(
+                    'Inhabitant with OASIS ID: ',
+                    a({ class: 'user-link', href: `/author/${encodeURIComponent(from)}` }, from),
+                    ' has subscribed to your job offer ',
+                    a({ class: "job-link", href: `/jobs/${encodeURIComponent(decodeURIComponent(jobId))}` }, `"${jobTitle}"`)
+                  ),
+                  actions({ key: msg.key, replyId: from })
+                );
+              }
+              let saleMatch = subject.match(/item "([^"]+)" has been sold/);
+              let buyerMatch = text.match(/OASIS ID: ([\w=/+.-]+)/);
+              let priceMatch = text.match(/for: \$([\d.]+)/);
+              let marketIdMatch = text.match(/\/market\/([%a-zA-Z0-9/+._-]+\.sha256)/);
+              if (saleMatch && buyerMatch && priceMatch && marketIdMatch) {
+                const itemTitle = saleMatch[1];
+                const buyerId = buyerMatch[1];
+                const price = priceMatch[1];
+                const marketId = marketIdMatch[1];
+                return div({ class: 'pm-card market-sold-notification' },
+                  header({ sentAt, from, toLinks, botIcon: '💰', botLabel: '42-MarketBOT' }),
+                  h2({ class: 'pm-title', style: 'color:#80cbc4;' }, 'Item Sold'),
+                  p(
+                    'Your item ',
+                    a({ class: 'market-link', href: `/market/${encodeURIComponent(decodeURIComponent(marketId))}` }, `"${itemTitle}"`),
+                    ' has been sold to ',
+                    a({ class: 'user-link', href: `/author/${encodeURIComponent(buyerId)}` }, buyerId),
+                    ` for $${price}.`
+                  ),
+                  actions({ key: msg.key, replyId: buyerId })
+                );
+              }
+              return div({ class: 'pm-card normal-pm' },
+                header({ sentAt, from, toLinks }),
                 h2(subject),
                 h2(subject),
                 p({ class: 'message-text' }, ...renderUrl(text)),
                 p({ class: 'message-text' }, ...renderUrl(text)),
-                form({ method: 'POST', action: `/inbox/delete/${encodeURIComponent(msg.key)}`, class: 'delete-message-form' },
-                  button({ type: 'submit', class: 'delete-btn' }, i18n.privateDelete)
-                )
+                actions({ key: msg.key, replyId: from })
               );
               );
             })
             })
           : p({ class: 'empty' }, i18n.noPrivateMessages)
           : p({ class: 'empty' }, i18n.noPrivateMessages)
@@ -1464,7 +1555,7 @@ const generatePreview = ({ previewData, contentWarning, action }) => {
       form(
       form(
         { action, method: "post" },
         { action, method: "post" },
         [
         [
-          input({ type: "hidden", name: "text", value: renderedText }), // Pass the formatted text
+          input({ type: "hidden", name: "text", value: renderedText }),
           input({ type: "hidden", name: "contentWarning", value: contentWarning || "" }),
           input({ type: "hidden", name: "contentWarning", value: contentWarning || "" }),
           input({ type: "hidden", name: "mentions", value: JSON.stringify(mentions) }),
           input({ type: "hidden", name: "mentions", value: JSON.stringify(mentions) }),
           button({ type: "submit" }, i18n.publish)
           button({ type: "submit" }, i18n.publish)

+ 307 - 349
src/views/market_view.js

@@ -13,363 +13,321 @@ const renderCardField = (labelText, value) =>
   );
   );
 
 
 exports.marketView = async (items, filter, itemToEdit = null) => {
 exports.marketView = async (items, filter, itemToEdit = null) => {
-  const list = Array.isArray(items) ? items : [];
-  let title = i18n.marketAllSectionTitle;
+    const list = Array.isArray(items) ? items : [];
+    let title = i18n.marketAllSectionTitle;
 
 
-  switch (filter) {
-    case 'mine':
-      title = i18n.marketMineSectionTitle;
-      break;
-    case 'create':
-      title = i18n.marketCreateSectionTitle;
-      break;
-    case 'edit':
-      title = i18n.marketUpdateSectionTitle;
-      break;
-  }
+    switch (filter) {
+        case 'mine':
+            title = i18n.marketMineSectionTitle;
+            break;
+        case 'create':
+            title = i18n.marketCreateSectionTitle;
+            break;
+        case 'edit':
+            title = i18n.marketUpdateSectionTitle;
+            break;
+    }
 
 
-  let filtered = [];
+    let filtered = [];
+    switch (filter) {
+        case 'all':         filtered = list; break;
+        case 'mine':        filtered = list.filter(e => e.seller === userId); break;
+        case 'exchange':    filtered = list.filter(e => e.item_type === 'exchange' && e.status === 'FOR SALE'); break;
+        case 'auctions':    filtered = list.filter(e => e.item_type === 'auction' && e.status === 'FOR SALE'); break;
+        case 'new':         filtered = list.filter(e => e.item_status === 'NEW' && e.status === 'FOR SALE'); break;
+        case 'used':        filtered = list.filter(e => e.item_status === 'USED' && e.status === 'FOR SALE'); break;
+        case 'broken':      filtered = list.filter(e => e.item_status === 'BROKEN' && e.status === 'FOR SALE'); break;
+        case 'for sale':    filtered = list.filter(e => e.status === 'FOR SALE'); break;
+        case 'sold':        filtered = list.filter(e => e.status === 'SOLD'); break;
+        case 'discarded':   filtered = list.filter(e => e.status === 'DISCARDED'); break;
+        case 'recent':
+            const oneDayAgo = moment().subtract(1, 'days').toISOString();
+            filtered = list.filter(e => e.status === 'FOR SALE' && e.createdAt >= oneDayAgo);
+            break;
+        default: break;
+    }
+    filtered = filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
 
 
-  switch (filter) {
-    case 'all':
-      filtered = list;
-      break;
-    case 'mine':
-      filtered = list.filter(e => e.seller === userId);
-      break;
-    case 'exchange':
-      filtered = list.filter(e => e.item_type === 'exchange' && e.status === 'FOR SALE');
-      break;
-    case 'auctions':
-      filtered = list.filter(e => e.item_type === 'auction' && e.status === 'FOR SALE');
-      break;
-    case 'new':
-      filtered = list.filter(e => e.item_status === 'NEW' && e.status === 'FOR SALE');
-      break;
-    case 'used':
-      filtered = list.filter(e => e.item_status === 'USED' && e.status === 'FOR SALE');
-      break;
-    case 'broken':
-      filtered = list.filter(e => e.item_status === 'BROKEN' && e.status === 'FOR SALE');
-      break;
-    case 'for sale':
-      filtered = list.filter(e => e.status === 'FOR SALE');
-      break;
-    case 'sold':
-      filtered = list.filter(e => e.status === 'SOLD');
-      break;
-    case 'discarded':
-      filtered = list.filter(e => e.status === 'DISCARDED');
-      break;
-    case 'recent':
-      const oneDayAgo = moment().subtract(1, 'days').toISOString();
-      filtered = list.filter(e => e.status === 'FOR SALE' && e.createdAt >= oneDayAgo);
-      break;
-    default:
-      break;
-  }
-
-  filtered = filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
-
-  return template(
-    title,
-    section(
-      div({ class: "tags-header" },
-        h2(i18n.marketTitle),
-        p(i18n.marketDescription)
-      ),
-      div({ class: "filters" },
-        form({ method: "GET", action: "/market" },
-          button({ type:"submit", name:"filter", value:"all", class:filter === 'all' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterAll),
-          button({ type:"submit", name:"filter", value:"mine", class:filter === 'mine' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterMine),
-          button({ type:"submit", name:"filter", value:"exchange", class:filter === 'exchange' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterItems),
-          button({ type:"submit", name:"filter", value:"auctions", class:filter === 'auctions' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterAuctions),
-          button({ type:"submit", name:"filter", value:"new", class:filter === 'new' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterNew),
-          button({ type:"submit", name:"filter", value:"used", class:filter === 'used' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterUsed),
-          button({ type:"submit", name:"filter", value:"broken", class:filter === 'broken' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterBroken),
-          button({ type:"submit", name:"filter", value:"for sale", class:filter === 'for sale' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterForSale),
-          button({ type:"submit", name:"filter", value:"sold", class:filter === 'sold' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterSold),
-          button({ type:"submit", name:"filter", value:"discarded", class:filter === 'discarded' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterDiscarded),
-          button({ type:"submit", name:"filter", value:"recent", class:filter === 'recent' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterRecent),
-          button({ type:"submit", name:"filter", value:"create", class:"create-button" }, i18n.marketCreateButton)
-        )
-      )
-    ),
-    section(
-      (filter === 'create' || filter === 'edit') ? (
-        div({ class: "market-form" },
-          form({
-            action: filter === 'edit' ? `/market/update/${encodeURIComponent(itemToEdit.id)}` : "/market/create",
-            method: "POST",
-            enctype: "multipart/form-data"
-          },
-            label(i18n.marketItemType), br(),
-            select({ name: "item_type", id: "item_type", required: true },
-              option({ value: "auction", selected: itemToEdit?.item_type === 'auction' ? true : false }, "Auction"),
-              option({ value: "exchange", selected: itemToEdit?.item_type === 'exchange' ? true : false }, "Exchange")
-            ), br(), br(),
-            
-            label(i18n.marketItemTitle), br(),
-            input({ type: "text", name: "title", id: "title", value: itemToEdit?.title || '', required: true }), br(), br(),
-            
-            label(i18n.marketItemDescription), br(),
-            textarea({ name: "description", id: "description", placeholder: i18n.marketItemDescriptionPlaceholder, rows:"6", innerHTML: itemToEdit?.description || '', required: true }), br(), br(),
-            
-            label(i18n.marketCreateFormImageLabel), br(),
-            input({ type: "file", name: "image", id: "image", accept: "image/*" }), br(), br(),
-            
-            label(i18n.marketItemStatus), br(),
-            select({ name: "item_status", id: "item_status" },
-              option({ value: "BROKEN", selected: itemToEdit?.item_status === 'BROKEN' ? true : false }, "BROKEN"),
-              option({ value: "USED", selected: itemToEdit?.item_status === 'USED' ? true : false }, "USED"),
-              option({ value: "NEW", selected: itemToEdit?.item_status === 'NEW' ? true : false }, "NEW")
-            ), br(), br(),
-            
-            label(i18n.marketItemStock), br(),
-	    input({ 
-	      type: "number", 
-	      name: "stock", 
-	      id: "stock", 
-	      value: itemToEdit?.stock || 1, 
-	      required: true, 
-	      min: "1", 
-	      step: "1" 
-	    }), br(), br(),
-            
-            label(i18n.marketItemPrice), br(),
-            input({ type: "number", name: "price", id: "price", value: itemToEdit?.price || '', required: true, step: "0.000001", min: "0.000001" }), br(), br(),
-            
-            label(i18n.marketItemTags), br(),
-            input({ type: "text", name: "tags", id: "tags", placeholder: i18n.marketItemTagsPlaceholder, value: itemToEdit?.tags?.join(', ') || '' }), br(), br(),
-            
-            label(i18n.marketItemDeadline), br(),
-            input({
-              type: "datetime-local",
-              name: "deadline",
-              id: "deadline",
-              required: true,
-              min: moment().format("YYYY-MM-DDTHH:mm"),
-              value: itemToEdit?.deadline ? moment(itemToEdit.deadline).format("YYYY-MM-DDTHH:mm") : ''
-            }), br(), br(),
-            
-            label(i18n.marketItemIncludesShipping), br(),
-            input({ type: "checkbox", name: "includesShipping", id: "includesShipping", checked: itemToEdit?.includesShipping }), br(), br(),
-
-            button({ type: "submit" }, filter === 'edit' ? i18n.marketUpdateButton : i18n.marketCreateButton)
-          )
+    return template(
+        title,
+        section(
+            div({ class: "tags-header" },
+                h2(i18n.marketTitle),
+                p(i18n.marketDescription)
+            ),
+            div({ class: "filters" },
+                form({ method: "GET", action: "/market" },
+                    button({ type: "submit", name: "filter", value: "all", class: filter === 'all' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterAll),
+                    button({ type: "submit", name: "filter", value: "mine", class: filter === 'mine' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterMine),
+                    button({ type: "submit", name: "filter", value: "exchange", class: filter === 'exchange' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterItems),
+                    button({ type: "submit", name: "filter", value: "auctions", class: filter === 'auctions' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterAuctions),
+                    button({ type: "submit", name: "filter", value: "new", class: filter === 'new' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterNew),
+                    button({ type: "submit", name: "filter", value: "used", class: filter === 'used' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterUsed),
+                    button({ type: "submit", name: "filter", value: "broken", class: filter === 'broken' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterBroken),
+                    button({ type: "submit", name: "filter", value: "for sale", class: filter === 'for sale' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterForSale),
+                    button({ type: "submit", name: "filter", value: "sold", class: filter === 'sold' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterSold),
+                    button({ type: "submit", name: "filter", value: "discarded", class: filter === 'discarded' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterDiscarded),
+                    button({ type: "submit", name: "filter", value: "recent", class: filter === 'recent' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterRecent),
+                    button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.marketCreateButton)
+                )
+            )
+        ),
+        section(
+            (filter === 'create' || filter === 'edit') ? (
+                div({ class: "market-form" },
+                    form({
+                        action: filter === 'edit' ? `/market/update/${encodeURIComponent(itemToEdit.id)}` : "/market/create",
+                        method: "POST",
+                        enctype: "multipart/form-data"
+                    },
+                        label(i18n.marketItemType), br(),
+                        select({ name: "item_type", id: "item_type", required: true },
+                            option({ value: "auction", selected: itemToEdit?.item_type === 'auction' ? true : false }, "Auction"),
+                            option({ value: "exchange", selected: itemToEdit?.item_type === 'exchange' ? true : false }, "Exchange")
+                        ), br(), br(),
+                        label(i18n.marketItemTitle), br(),
+                        input({ type: "text", name: "title", id: "title", value: itemToEdit?.title || '', required: true }), br(), br(),
+                        label(i18n.marketItemDescription), br(),
+                        textarea({ name: "description", id: "description", placeholder: i18n.marketItemDescriptionPlaceholder, rows: "6", innerHTML: itemToEdit?.description || '', required: true }), br(), br(),
+                        label(i18n.marketCreateFormImageLabel), br(),
+                        input({ type: "file", name: "image", id: "image", accept: "image/*" }), br(), br(),
+                        label(i18n.marketItemStatus), br(),
+                        select({ name: "item_status", id: "item_status" },
+                            option({ value: "BROKEN", selected: itemToEdit?.item_status === 'BROKEN' ? true : false }, "BROKEN"),
+                            option({ value: "USED", selected: itemToEdit?.item_status === 'USED' ? true : false }, "USED"),
+                            option({ value: "NEW", selected: itemToEdit?.item_status === 'NEW' ? true : false }, "NEW")
+                        ), br(), br(),
+                        label(i18n.marketItemStock), br(),
+                        input({
+                            type: "number",
+                            name: "stock",
+                            id: "stock",
+                            value: itemToEdit?.stock || 1,
+                            required: true,
+                            min: "1",
+                            step: "1"
+                        }), br(), br(),
+                        label(i18n.marketItemPrice), br(),
+                        input({ type: "number", name: "price", id: "price", value: itemToEdit?.price || '', required: true, step: "0.000001", min: "0.000001" }), br(), br(),
+                        label(i18n.marketItemTags), br(),
+                        input({ type: "text", name: "tags", id: "tags", placeholder: i18n.marketItemTagsPlaceholder, value: itemToEdit?.tags?.join(', ') || '' }), br(), br(),
+                        label(i18n.marketItemDeadline), br(),
+                        input({
+                            type: "datetime-local",
+                            name: "deadline",
+                            id: "deadline",
+                            required: true,
+                            min: moment().format("YYYY-MM-DDTHH:mm"),
+                            value: itemToEdit?.deadline ? moment(itemToEdit.deadline).format("YYYY-MM-DDTHH:mm") : ''
+                        }), br(), br(),
+                        label(i18n.marketItemIncludesShipping), br(),
+                        input({ type: "checkbox", name: "includesShipping", id: "includesShipping", checked: itemToEdit?.includesShipping }), br(), br(),
+                        button({ type: "submit" }, filter === 'edit' ? i18n.marketUpdateButton : i18n.marketCreateButton)
+                    )
+                )
+            ) : (
+                div({ class: "market-grid" },
+                    filtered.length > 0
+                        ? filtered.map((item, index) =>
+                            div({ class: "market-item" },
+                                div({ class: "market-card left-col" },
+                                    form({ method: "GET", action: `/market/${encodeURIComponent(item.id)}` },
+                                        button({ class: "filter-btn", type: "submit" }, i18n.viewDetails)
+                                    ),
+                                    h2({ class: "market-card type" }, `${i18n.marketItemType}: ${item.item_type.toUpperCase()}`),
+                                    h2(item.title),
+                                    renderCardField(`${i18n.marketItemStatus}:`, item.status),
+                                    item.deadline ? renderCardField(`${i18n.marketItemAvailable}:`, moment(item.deadline).format('YYYY/MM/DD HH:mm:ss')) : null,
+                                    br, br,
+                                    div({ class: "market-card image" },
+                                        item.image
+                                            ? img({ src: `/blob/${encodeURIComponent(item.image)}` })
+                                            : img({ src: '/assets/images/default-market.png', alt: item.title })
+                                    ),
+                                    p(...renderUrl(item.description)),
+                                    item.tags && item.tags.filter(Boolean).length
+                                        ? div({ class: 'card-tags' }, item.tags.filter(Boolean).map(tag =>
+                                            a({ class: "tag-link", href: `/search?query=%23${encodeURIComponent(tag)}` },
+                                                `#${tag}`)
+                                        ))
+                                        : null,
+                                ),
+                                div({ class: "market-card right-col" },
+                                    renderCardField(`${i18n.marketItemStock}:`, item.stock > 0 ? item.stock : i18n.marketOutOfStock),
+                                    div({ class: "market-card price" },
+                                        renderCardField(`${i18n.marketItemPrice}:`, `${item.price} ECO`),
+                                    ),
+                                    renderCardField(`${i18n.marketItemCondition}:`, item.item_status),
+                                    renderCardField(`${i18n.marketItemIncludesShipping}:`, item.includesShipping ? i18n.YESLabel : i18n.NOLabel),
+                                    renderCardField(`${i18n.marketItemSeller}:`),
+                                    div({ class: "market-card image" },
+                                        div({ class: 'card-field' },
+                                            a({ class: 'user-link', href: `/author/${encodeURIComponent(item.seller)}` }, item.seller)
+                                        )),
+                                    item.item_type === 'auction' && item.auctions_poll.length > 0
+                                        ? div({ class: "auction-info" },
+                                            p({ class: "auction-bid-text" }, i18n.marketAuctionBids),
+                                            table({ class: 'auction-bid-table' },
+                                                tr(
+                                                    th(i18n.marketAuctionBidTime),
+                                                    th(i18n.marketAuctionUser),
+                                                    th(i18n.marketAuctionBidAmount)
+                                                ),
+                                                item.auctions_poll.map(bid => {
+                                                    const [userId, bidAmount, bidTime] = bid.split(':');
+                                                    return tr(
+                                                        td(moment(bidTime).format('YYYY-MM-DD HH:mm:ss')),
+                                                        td(a({ href: `/author/${encodeURIComponent(userId)}` }, userId)),
+                                                        td(`${parseFloat(bidAmount).toFixed(6)} ECO`)
+                                                    );
+                                                })
+                                            )
+                                        )
+                                        : null,
+                                    div({ class: "market-card buttons" },
+                                        (item.seller === userId) ? [
+                                            form({ method: "POST", action: `/market/delete/${encodeURIComponent(item.id)}` },
+                                                button({ class: "delete-btn", type: "submit" }, i18n.marketActionsDelete)
+                                            ),
+                                            (item.status !== 'SOLD' && item.status !== 'DISCARDED' && item.auctions_poll.length === 0)
+                                                ? form({ method: "GET", action: `/market/edit/${encodeURIComponent(item.id)}` },
+                                                    button({ class: "update-btn", type: "submit" }, i18n.marketActionsUpdate)
+                                                )
+                                                : null,
+                                            (item.status === 'FOR SALE')
+                                                ? form({ method: "POST", action: `/market/sold/${encodeURIComponent(item.id)}` },
+                                                    button({ class: "sold-btn", type: "submit" }, i18n.marketActionsSold)
+                                                )
+                                                : null
+                                        ] : [
+                                            (item.status !== 'SOLD' && item.status !== 'DISCARDED' && item.item_type === 'auction')
+                                                ? form({ method: "POST", action: `/market/bid/${encodeURIComponent(item.id)}` },
+                                                    input({ type: "number", name: "bidAmount", step: "0.000001", min: "0.000001", placeholder: i18n.marketYourBid, required: true }),
+                                                    br,
+                                                    button({ class: "buy-btn", type: "submit" }, i18n.marketPlaceBidButton)
+                                                )
+                                                : null,
+                                            (item.status === 'FOR SALE' && item.item_type !== 'auction' && item.seller !== userId)
+                                                ? form({ method: "POST", action: `/market/buy/${encodeURIComponent(item.id)}` },
+                                                    input({ type: "hidden", name: "buyerId", value: userId }),
+                                                    button({ class: "buy-btn", type: "submit" }, i18n.marketActionsBuy)
+                                                )
+                                                : null
+                                        ]
+                                    )
+                                )
+                            )
+                        )
+                        : p(i18n.marketNoItems)
+                )
+            )
         )
         )
-      ) : (
-	div({ class: "market-grid" },
-	  filtered.length > 0
-	    ? filtered.map((item, index) =>     
-	      div({ class: "market-item" }, 
-		div({ class: "market-card left-col" },
-		  form({ method: "GET", action: `/market/${encodeURIComponent(item.id)}` },
-		      button({ class: "filter-btn", type: "submit" }, i18n.viewDetails)
-		  ),
-		  h2({ class: "market-card type" }, `${i18n.marketItemType}: ${item.item_type.toUpperCase()}`),
-		  h2(item.title),
-		  renderCardField(`${i18n.marketItemStatus}:`, item.status),
-		  item.deadline ? renderCardField(`${i18n.marketItemAvailable}:`, moment(item.deadline).format('YYYY/MM/DD HH:mm:ss')) : null,
-		  br,br,
-		  div({ class: "market-card image" },
-		    item.image
-		      ? img({ src: `/blob/${encodeURIComponent(item.image)}` })
-		      : img({ src: '/assets/images/default-market.png', alt: item.title })
-		  ),
-		  p(...renderUrl(item.description)),
-		  item.tags && item.tags.filter(Boolean).length
-		    ? div({ class: 'card-tags' }, item.tags.filter(Boolean).map(tag =>
-		        a({ class: "tag-link", href: `/search?query=%23${encodeURIComponent(tag)}` },
-		          `#${tag}`)
-		      ))
-		    : null,
-		),
-		div({ class: "market-card right-col" },
-		  renderCardField(`${i18n.marketItemStock}:`, item.stock > 0 ? item.stock : i18n.marketOutOfStock),
-		  div({ class: "market-card price" },
-		    renderCardField(`${i18n.marketItemPrice}:`, `${item.price} ECO`),
-		  ),
-		  renderCardField(`${i18n.marketItemCondition}:`, item.item_status),
-		  renderCardField(`${i18n.marketItemIncludesShipping}:`, item.includesShipping ? i18n.YESLabel : i18n.NOLabel),
-		  renderCardField(`${i18n.marketItemSeller}:`),
-		  div({ class: "market-card image" }, 
-		  div({ class: 'card-field' },
-		    a({ class: 'user-link', href: `/author/${encodeURIComponent(item.seller)}` }, item.seller)
-		  )),
-		  item.item_type === 'auction' && item.auctions_poll.length > 0
-		  ? div({ class: "auction-info" },
-		      p({ class: "auction-bid-text" }, i18n.marketAuctionBids),
-		      table({ class: 'auction-bid-table' },
-		        tr(
-		          th(i18n.marketAuctionBidTime),
-		          th(i18n.marketAuctionUser),
-		          th(i18n.marketAuctionBidAmount)
-		        ),
-		        item.auctions_poll.map(bid => {
-		          const [userId, bidAmount, bidTime] = bid.split(':');
-		          return tr(
-		            td(moment(bidTime).format('YYYY-MM-DD HH:mm:ss')),
-		            td(a({ href: `/author/${encodeURIComponent(userId)}` }, userId)),
-		            td(`${parseFloat(bidAmount).toFixed(6)} ECO`)
-		          );
-		        })
-		      )
-		    )
-		  : null,
-		  div({ class: "market-card buttons" },
-		    (filter === 'mine') ? [
-		      form({ method: "POST", action: `/market/delete/${encodeURIComponent(item.id)}` },
-		        button({ class: "delete-btn", type: "submit" }, i18n.marketActionsDelete)
-		      ),
-		      (item.status !== 'SOLD' && item.status !== 'DISCARDED' && item.auctions_poll.length === 0) 
-		        ? form({ method: "GET", action: `/market/edit/${encodeURIComponent(item.id)}` },
-		        button({ class: "update-btn", type: "submit" }, i18n.marketActionsUpdate)
-		      )
-		        : null,
-		      (item.status === 'FOR SALE') 
-		        ? form({ method: "POST", action: `/market/sold/${encodeURIComponent(item.id)}` },
-		        button({ class: "sold-btn", type: "submit" }, i18n.marketActionsSold)
-		      )
-		        : null
-		    ] : [
-		      (item.status !== 'SOLD' && item.status !== 'DISCARDED')
-		        ? (item.item_type === 'auction'
-		          ? form({ method: "POST", action: `/market/bid/${encodeURIComponent(item.id)}` },
-		              input({ type: "number", name: "bidAmount", step:"0.000001", min:"0.000001", placeholder: i18n.marketYourBid, required: true }),
-		              br,
-		              button({ class: "buy-btn", type: "submit" }, i18n.marketPlaceBidButton)
-		            )
-		          : form({ method: "POST", action: `/market/buy/${encodeURIComponent(item.id)}` },
-		              input({ type: "hidden", name: "buyerId", value: userId }),
-		              button({ class: "buy-btn", type: "submit" }, i18n.marketActionsBuy)
-		            )
-		        )
-		        : null
-		    ]
-		  )
-		)
-	      )
-	    )
-	  : p(i18n.marketNoItems)
-	)
-      )
-    )
-  );
+    );
 };
 };
 
 
 exports.singleMarketView = async (item, filter) => {
 exports.singleMarketView = async (item, filter) => {
-  return template(
-    item.title,
-    section(
-      div({ class: "filters" },
-        form({ method: 'GET', action: '/market' },
-          button({ type: 'submit', name: 'filter', value: 'all', class: filter === 'all' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterAll),
-          button({ type: 'submit', name: 'filter', value: 'mine', class: filter === 'mine' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterMine),
-          button({ type: 'submit', name: 'filter', value: 'exchange', class: filter === 'exchange' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterItems),
-          button({ type: 'submit', name: 'filter', value: 'auctions', class: filter === 'auctions' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterAuctions),
-          button({ type: 'submit', name: 'filter', value: 'new', class: filter === 'new' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterNew),
-          button({ type: 'submit', name: 'filter', value: 'used', class: filter === 'used' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterUsed),
-          button({ type: 'submit', name: 'filter', value: 'broken', class: filter === 'broken' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterBroken),
-          button({ type: 'submit', name: 'filter', value: 'for sale', class: filter === 'for sale' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterForSale),
-          button({ type: 'submit', name: 'filter', value: 'sold', class: filter === 'sold' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterSold),
-          button({ type: 'submit', name: 'filter', value: 'discarded', class: filter === 'discarded' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterDiscarded),
-          button({ type: 'submit', name: 'filter', value: 'recent', class: filter === 'recent' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterRecent),
-          button({ type: 'submit', name: 'filter', value: 'create', class: "create-button" }, i18n.marketCreateButton)
-        )
-      ),
-      div({ class: "tags-header" },
-        h2(item.title),
-        renderCardField(`${i18n.marketItemType}:`, `${item.item_type.toUpperCase()}`),
-        renderCardField(`${i18n.marketItemStatus}:`, item.status),
-        renderCardField(`${i18n.marketItemCondition}:`, item.item_status),
-        br,
-        div({ class: "market-item image" },
-          item.image
-            ? img({ src: `/blob/${encodeURIComponent(item.image)}` })
-            : img({ src: '/assets/images/default-market.png', alt: item.title })
-        ),
-        renderCardField(`${i18n.marketItemDescription}:`),
-        p(...renderUrl(item.description)),   
-        item.tags && item.tags.length
-          ? div({ class: 'card-tags' },
-              item.tags.map(tag =>
-                a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)
-              )
-            )
-          : null,
-          br,
-        renderCardField(`${i18n.marketItemPrice}:`),
-        br,
-        div({ class: 'card-label' },
-          h2(`${item.price} ECO`),
-          ),
-        br,
-        renderCardField(`${i18n.marketItemStock}:`, item.stock > 0 ? item.stock : i18n.marketOutOfStock),
-        renderCardField(`${i18n.marketItemIncludesShipping}:`, `${item.includesShipping ? i18n.YESLabel : i18n.NOLabel}`),
-        item.deadline ? renderCardField(`${i18n.marketItemAvailable}:`, `${moment(item.deadline).format('YYYY/MM/DD HH:mm:ss')}`) : null,
-        renderCardField(`${i18n.marketItemSeller}:`),
-        br,
-	div({ class: 'card-field' },
-	  a({ class: 'user-link', href: `/author/${encodeURIComponent(item.seller)}` }, item.seller)
-	)
-      ),
-      item.item_type === 'auction' 
-        ? div({ class: "auction-info" },
-            p({ class: "auction-bid-text" }, i18n.marketAuctionBids),
-            table({ class: 'auction-bid-table' },
-              tr(
-                th(i18n.marketAuctionBidTime),
-                th(i18n.marketAuctionUser),
-                th(i18n.marketAuctionBidAmount)
-              ),
-              item.auctions_poll.map(bid => {
-                const [userId, bidAmount, bidTime] = bid.split(':');
-                return tr(
-                  td(moment(bidTime).format('YYYY-MM-DD HH:mm:ss')),
-                  td(a({ href: `/author/${encodeURIComponent(userId)}` }, userId)),
-                  td(`${parseFloat(bidAmount).toFixed(6)} ECO`)
-                );
-              })
+    return template(
+        item.title,
+        section(
+            div({ class: "filters" },
+                form({ method: 'GET', action: '/market' },
+                    button({ type: 'submit', name: 'filter', value: 'all', class: filter === 'all' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterAll),
+                    button({ type: 'submit', name: 'filter', value: 'mine', class: filter === 'mine' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterMine),
+                    button({ type: 'submit', name: 'filter', value: 'exchange', class: filter === 'exchange' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterItems),
+                    button({ type: 'submit', name: 'filter', value: 'auctions', class: filter === 'auctions' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterAuctions),
+                    button({ type: 'submit', name: 'filter', value: 'new', class: filter === 'new' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterNew),
+                    button({ type: 'submit', name: 'filter', value: 'used', class: filter === 'used' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterUsed),
+                    button({ type: 'submit', name: 'filter', value: 'broken', class: filter === 'broken' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterBroken),
+                    button({ type: 'submit', name: 'filter', value: 'for sale', class: filter === 'for sale' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterForSale),
+                    button({ type: 'submit', name: 'filter', value: 'sold', class: filter === 'sold' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterSold),
+                    button({ type: 'submit', name: 'filter', value: 'discarded', class: filter === 'discarded' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterDiscarded),
+                    button({ type: 'submit', name: 'filter', value: 'recent', class: filter === 'recent' ? 'filter-btn active' : 'filter-btn' }, i18n.marketFilterRecent),
+                    button({ type: 'submit', name: 'filter', value: 'create', class: "create-button" }, i18n.marketCreateButton)
+                )
             ),
             ),
-            item.status !== 'SOLD' && item.status !== 'DISCARDED'
-              ? form({ method: "POST", action: `/market/bid/${encodeURIComponent(item.id)}` },
-                  input({ type: "number", name: "bidAmount", step: "0.000001", min: "0.000001", placeholder: i18n.marketYourBid, required: true }),
-                  br(),
-                  button({ class: "buy-btn", type: "submit" }, i18n.marketPlaceBidButton)
+            div({ class: "tags-header" },
+                h2(item.title),
+                renderCardField(`${i18n.marketItemType}:`, `${item.item_type.toUpperCase()}`),
+                renderCardField(`${i18n.marketItemStatus}:`, item.status),
+                renderCardField(`${i18n.marketItemCondition}:`, item.item_status),
+                br,
+                div({ class: "market-item image" },
+                    item.image
+                        ? img({ src: `/blob/${encodeURIComponent(item.image)}` })
+                        : img({ src: '/assets/images/default-market.png', alt: item.title })
+                ),
+                renderCardField(`${i18n.marketItemDescription}:`),
+                p(...renderUrl(item.description)),
+                item.tags && item.tags.length
+                    ? div({ class: 'card-tags' },
+                        item.tags.map(tag =>
+                            a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)
+                        )
+                    )
+                    : null,
+                br,
+                renderCardField(`${i18n.marketItemPrice}:`),
+                br,
+                div({ class: 'card-label' },
+                    h2(`${item.price} ECO`),
+                ),
+                br,
+                renderCardField(`${i18n.marketItemStock}:`, item.stock > 0 ? item.stock : i18n.marketOutOfStock),
+                renderCardField(`${i18n.marketItemIncludesShipping}:`, `${item.includesShipping ? i18n.YESLabel : i18n.NOLabel}`),
+                item.deadline ? renderCardField(`${i18n.marketItemAvailable}:`, `${moment(item.deadline).format('YYYY/MM/DD HH:mm:ss')}`) : null,
+                renderCardField(`${i18n.marketItemSeller}:`),
+                br,
+                div({ class: 'card-field' },
+                    a({ class: 'user-link', href: `/author/${encodeURIComponent(item.seller)}` }, item.seller)
+                )
+            ),
+            item.item_type === 'auction'
+                ? div({ class: "auction-info" },
+                    p({ class: "auction-bid-text" }, i18n.marketAuctionBids),
+                    table({ class: 'auction-bid-table' },
+                        tr(
+                            th(i18n.marketAuctionBidTime),
+                            th(i18n.marketAuctionUser),
+                            th(i18n.marketAuctionBidAmount)
+                        ),
+                        item.auctions_poll.map(bid => {
+                            const [userId, bidAmount, bidTime] = bid.split(':');
+                            return tr(
+                                td(moment(bidTime).format('YYYY-MM-DD HH:mm:ss')),
+                                td(a({ href: `/author/${encodeURIComponent(userId)}` }, userId)),
+                                td(`${parseFloat(bidAmount).toFixed(6)} ECO`)
+                            );
+                        })
+                    ),
+                    item.status !== 'SOLD' && item.status !== 'DISCARDED'
+                        ? form({ method: "POST", action: `/market/bid/${encodeURIComponent(item.id)}` },
+                            input({ type: "number", name: "bidAmount", step: "0.000001", min: "0.000001", placeholder: i18n.marketYourBid, required: true }),
+                            br(),
+                            button({ class: "buy-btn", type: "submit" }, i18n.marketPlaceBidButton)
+                        )
+                        : null
                 )
                 )
-              : null
-          )
-        : null,
-      div({ class: "market-item actions" },
-        (filter === 'mine' && item.status !== 'SOLD' && item.status !== 'DISCARDED' && item.seller === userId && item.item_type !== 'auction') ||
-        (item.status === 'FOR SALE' && item.item_type !== 'auction' && item.seller === userId) ||
-        (item.status === 'FOR SALE' && item.item_type === 'exchange') ||
-        (item.status !== 'SOLD' && item.status !== 'DISCARDED' && item.item_type !== 'auction' && item.seller !== userId)
-          ? [
-              (filter === 'mine' && item.status !== 'SOLD' && item.status !== 'DISCARDED' && item.seller === userId && item.item_type !== 'auction')
-                ? form({ method: "POST", action: `/market/delete/${encodeURIComponent(item.id)}` },
-                    button({ class: "delete-btn", type: "submit" }, i18n.marketActionsDelete)
-                  )
-                : null,
-              (item.status === 'FOR SALE' && item.item_type !== 'auction' && item.seller === userId)
-                ? form({ method: "POST", action: `/market/sold/${encodeURIComponent(item.id)}` },
-                    button({ class: "sold-btn", type: "submit" }, i18n.marketActionsSold)
-                  )
-                : null,
-              (item.status === 'FOR SALE' && item.item_type === 'exchange')
-                ? form({ method: "POST", action: `/market/buy/${encodeURIComponent(item.id)}` },
-                    button({ class: "buy-btn", type: "submit" }, i18n.marketActionsBuy)
-                  )
                 : null,
                 : null,
-              (item.status !== 'SOLD' && item.status !== 'DISCARDED' && item.item_type !== 'auction' && item.seller !== userId)
-                ? form({ method: "POST", action: `/market/buy/${encodeURIComponent(item.id)}` },
-                    button({ class: "buy-btn", type: "submit" }, i18n.marketActionsBuy)
-                  )
-                : null
-            ]
-          : null
-      )
-    )
-  );
+		div({ class: "market-item actions" },
+		    (item.seller === userId && item.status !== 'SOLD' && item.status !== 'DISCARDED' && item.item_type !== 'auction') ? [
+			form({ method: "POST", action: `/market/delete/${encodeURIComponent(item.id)}` },
+			    button({ class: "delete-btn", type: "submit" }, i18n.marketActionsDelete)
+			),
+			form({ method: "GET", action: `/market/edit/${encodeURIComponent(item.id)}` },
+			    button({ class: "update-btn", type: "submit" }, i18n.marketActionsUpdate)
+			),
+			(item.status === 'FOR SALE')
+			    ? form({ method: "POST", action: `/market/sold/${encodeURIComponent(item.id)}` },
+				button({ class: "sold-btn", type: "submit" }, i18n.marketActionsSold)
+			    )
+			    : null
+		    ] : null,
+		    (item.status === 'FOR SALE' && item.item_type !== 'auction' && item.seller !== userId)
+			? form({ method: "POST", action: `/market/buy/${encodeURIComponent(item.id)}` },
+			    button({ class: "buy-btn", type: "submit" }, i18n.marketActionsBuy)
+			)
+			: null
+		)
+        )
+    );
 };
 };

+ 1 - 0
src/views/modules_view.js

@@ -17,6 +17,7 @@ const modulesView = () => {
     { name: 'governance', label: i18n.modulesGovernanceLabel, description: i18n.modulesGovernanceDescription },
     { name: 'governance', label: i18n.modulesGovernanceLabel, description: i18n.modulesGovernanceDescription },
     { name: 'images', label: i18n.modulesImagesLabel, description: i18n.modulesImagesDescription },
     { name: 'images', label: i18n.modulesImagesLabel, description: i18n.modulesImagesDescription },
     { name: 'invites', label: i18n.modulesInvitesLabel, description: i18n.modulesInvitesDescription },
     { name: 'invites', label: i18n.modulesInvitesLabel, description: i18n.modulesInvitesDescription },
+    { name: 'jobs', label: i18n.modulesJobsLabel, description: i18n.modulesJobsDescription },
     { name: 'legacy', label: i18n.modulesLegacyLabel, description: i18n.modulesLegacyDescription },
     { name: 'legacy', label: i18n.modulesLegacyLabel, description: i18n.modulesLegacyDescription },
     { name: 'latest', label: i18n.modulesLatestLabel, description: i18n.modulesLatestDescription },
     { name: 'latest', label: i18n.modulesLatestLabel, description: i18n.modulesLatestDescription },
     { name: 'market', label: i18n.modulesMarketLabel, description: i18n.modulesMarketDescription },
     { name: 'market', label: i18n.modulesMarketLabel, description: i18n.modulesMarketDescription },

+ 8 - 2
src/views/pm_view.js

@@ -1,7 +1,7 @@
 const { div, h2, p, section, button, form, input, textarea, br, label } = require("../server/node_modules/hyperaxe");
 const { div, h2, p, section, button, form, input, textarea, br, label } = require("../server/node_modules/hyperaxe");
 const { template, i18n } = require('./main_views');
 const { template, i18n } = require('./main_views');
 
 
-exports.pmView = async () => {
+exports.pmView = async (initialRecipients = '') => {
   const title = i18n.pmSendTitle;
   const title = i18n.pmSendTitle;
   const description = i18n.pmDescription;
   const description = i18n.pmDescription;
 
 
@@ -17,7 +17,13 @@ exports.pmView = async () => {
           form({ method: "POST", action: "/pm" },
           form({ method: "POST", action: "/pm" },
             label({ for: "recipients" }, i18n.pmRecipients),
             label({ for: "recipients" }, i18n.pmRecipients),
             br(),
             br(),
-            input({ type: "text", name: "recipients", placeholder: i18n.pmRecipientsHint, required: true }),
+            input({
+              type: "text",
+              name: "recipients",
+              placeholder: i18n.pmRecipientsHint,
+              required: true,
+              value: initialRecipients
+            }),
             br(),
             br(),
             label({ for: "subject" }, i18n.pmSubject),
             label({ for: "subject" }, i18n.pmSubject),
             br(),
             br(),

+ 18 - 0
src/views/settings_view.js

@@ -132,6 +132,24 @@ const settingsView = ({ version, aiPrompt }) => {
         )
         )
       )
       )
     ),
     ),
+    section(
+      div({ class: "tags-header" },
+      h2(i18n.ssbLogStream),
+      p(i18n.ssbLogStreamDescription),
+      form(
+        { action: "/settings/ssb-logstream", method: "POST" },
+        input({
+          type: "number",
+          id: "ssb_log_limit",
+          name: "ssb_log_limit",
+          min: 1,
+          max: 100000,
+          value: currentConfig.ssbLogStream?.limit || 1000
+        }), br(),br(),
+        button({ type: "submit" }, i18n.saveSettings)
+      )
+     )
+    ),
     section(
     section(
       div({ class: "tags-header" },
       div({ class: "tags-header" },
         h2(i18n.indexes),
         h2(i18n.indexes),

+ 19 - 1
src/views/stats_view.js

@@ -7,7 +7,7 @@ exports.statsView = (stats, filter) => {
   const modes = ['ALL', 'MINE', 'TOMBSTONE'];
   const modes = ['ALL', 'MINE', 'TOMBSTONE'];
   const types = [
   const types = [
     'bookmark', 'event', 'task', 'votes', 'report', 'feed',
     'bookmark', 'event', 'task', 'votes', 'report', 'feed',
-    'image', 'audio', 'video', 'document', 'transfer', 'post', 'tribe', 'market'
+    'image', 'audio', 'video', 'document', 'transfer', 'post', 'tribe', 'market', 'forum', 'job'
   ];
   ];
   const totalContent = types.reduce((sum, t) => sum + (stats.content[t] || 0), 0);
   const totalContent = types.reduce((sum, t) => sum + (stats.content[t] || 0), 0);
   const totalOpinions = types.reduce((sum, t) => sum + (stats.opinions[t] || 0), 0);
   const totalOpinions = types.reduce((sum, t) => sum + (stats.opinions[t] || 0), 0);
@@ -63,6 +63,15 @@ exports.statsView = (stats, filter) => {
             div({ style: blockStyle },
             div({ style: blockStyle },
               h2(`${i18n.statsDiscoveredMarket}: ${stats.content.market}`)
               h2(`${i18n.statsDiscoveredMarket}: ${stats.content.market}`)
             ),
             ),
+            div({ style: blockStyle },
+              h2(`${i18n.statsDiscoveredJob}: ${stats.content.job}`)
+            ),
+            div({ style: blockStyle },
+              h2(`${i18n.statsDiscoveredTransfer}: ${stats.content.transfer}`)
+            ),
+            div({ style: blockStyle },
+              h2(`${i18n.statsDiscoveredForum}: ${stats.content.forum}`)
+            ),
             div({ style: blockStyle },
             div({ style: blockStyle },
               h2(`${i18n.statsNetworkOpinions}: ${totalOpinions}`),
               h2(`${i18n.statsNetworkOpinions}: ${totalOpinions}`),
               ul(types.map(t =>
               ul(types.map(t =>
@@ -89,6 +98,15 @@ exports.statsView = (stats, filter) => {
               div({ style: blockStyle },
               div({ style: blockStyle },
                 h2(`${i18n.statsYourMarket}: ${stats.content.market}`)
                 h2(`${i18n.statsYourMarket}: ${stats.content.market}`)
               ),
               ),
+              div({ style: blockStyle },
+                h2(`${i18n.statsYourJob}: ${stats.content.job}`)
+              ),
+              div({ style: blockStyle },
+                h2(`${i18n.statsYourTransfer}: ${stats.content.transfer}`)
+              ),
+              div({ style: blockStyle },
+                h2(`${i18n.statsYourForum}: ${stats.content.forum}`)
+              ),
               div({ style: blockStyle },
               div({ style: blockStyle },
                 h2(`${i18n.statsYourOpinions}: ${totalOpinions}`),
                 h2(`${i18n.statsYourOpinions}: ${totalOpinions}`),
                 ul(types.map(t =>
                 ul(types.map(t =>

+ 3 - 3
src/views/task_view.js

@@ -55,7 +55,7 @@ const renderTaskItem = (task, filter, userId) => {
       )
       )
     ),
     ),
     br,
     br,
-    task.tags && task.tags.length
+      Array.isArray(task.tags) && task.tags.length
       ? div({ class: 'card-tags' },
       ? div({ class: 'card-tags' },
           task.tags.map(tag =>
           task.tags.map(tag =>
             a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
             a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
@@ -205,7 +205,7 @@ exports.singleTaskView = async (task, filter) => {
               : i18n.noAssignees
               : i18n.noAssignees
           )
           )
         ),
         ),
-        task.tags && task.tags.length
+          Array.isArray(task.tags) && task.tags.length
           ? div({ class: 'card-tags' },
           ? div({ class: 'card-tags' },
               task.tags.map(tag =>
               task.tags.map(tag =>
                 a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
                 a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
@@ -214,7 +214,7 @@ exports.singleTaskView = async (task, filter) => {
           : null
           : null
       ),
       ),
       div({ class: "task-actions" },
       div({ class: "task-actions" },
-        form({ method: "POST", action: `/tasks/attend/${encodeURIComponent(task.id)}` },
+        form({ method: "POST", action: `/tasks/assign/${encodeURIComponent(task.id)}` },
           button({ type: "submit" },
           button({ type: "submit" },
             task.assignees.includes(userId)
             task.assignees.includes(userId)
               ? i18n.taskUnassignButton
               ? i18n.taskUnassignButton