Browse Source

Oasis release 0.4.9

psy 1 day ago
parent
commit
dc6600cbf1

+ 10 - 0
docs/CHANGELOG.md

@@ -13,6 +13,16 @@ All notable changes to this project will be documented in this file.
 ### Security
 -->
 
+## v0.4.9 - 2025-09-01
+
+### Added
+
+ + French translation.
+ 
+### Changed
+
+- Inbox (PM plugin).
+
 ## v0.4.8 - 2025-08-27
  
 ### Fixed

+ 2 - 5
docs/PUB/deploy.md

@@ -40,11 +40,8 @@ Paste this:
   },
   "gossip": {
     "connections": 50,
-    "seed": true,
-    "seeds": [
-      "solarnethub.com:8008~shs:HzmUrdZb1vRWCwn3giLx3p/EWKuDiO44gXAaeulz3d4="
-    ],
-    "global": true
+    "seed": false,
+    "global": false
   },
   "connections": {
     "incoming": {

+ 21 - 16
src/backend/backend.js

@@ -211,7 +211,7 @@ const legacyModel = require('../models/legacy_model');
 const walletModel = require('../models/wallet_model')
 
 // load plugin models (cooler)
-const pmModel = require('../models/privatemessages_model')({ cooler, isPublic: config.public });
+const pmModel = require('../models/pm_model')({ cooler, isPublic: config.public });
 const bookmarksModel = require("../models/bookmarking_model")({ cooler, isPublic: config.public });
 const opinionsModel = require('../models/opinions_model')({ cooler, isPublic: config.public });
 const eventsModel = require('../models/events_model')({ cooler, isPublic: config.public });
@@ -848,20 +848,16 @@ router
     ctx.body = await createCVView(cv, true)
   })
   .get('/pm', async ctx => {
-    const { recipients = '' } = ctx.query;
-    ctx.body = await pmView(recipients);
+    const { recipients = '', subject = '', quote = '', preview = '' } = ctx.query;
+    const quoted = quote ? quote.split('\n').map(l => '> ' + l).join('\n') + '\n\n' : '';
+    const showPreview = preview === '1';
+    ctx.body = await pmView(recipients, subject, quoted, showPreview);
   })
-  .get("/inbox", async (ctx) => {
-    const inboxMod = ctx.cookies.get("inboxMod") || 'on';
-    if (inboxMod !== 'on') {
-      ctx.redirect('/modules');
-      return;
-    }
-    const inboxMessages = async () => {
-      const messages = await post.inbox();
-      return privateView({ messages });
-    };
-    ctx.body = await inboxMessages();
+  .get('/inbox', async ctx => {
+    const inboxMod = ctx.cookies.get('inboxMod') || 'on';
+    if (inboxMod !== 'on') { ctx.redirect('/modules'); return; }
+    const messages = await pmModel.listAllPrivate();
+    ctx.body = await privateView({ messages }, ctx.query.filter || undefined);
   })
   .get('/tags', async ctx => {
     const filter = ctx.query.filter || 'all'
@@ -1722,9 +1718,18 @@ router
   })
   .post('/pm', koaBody(), async ctx => {
     const { recipients, subject, text } = ctx.request.body;
-    const recipientsArr = recipients.split(',').map(s => s.trim()).filter(Boolean);
+    const recipientsArr = (recipients || '').split(',').map(s => s.trim()).filter(Boolean);
     await pmModel.sendMessage(recipientsArr, subject, text);
-    ctx.redirect('/pm');
+    ctx.redirect('/inbox?filter=sent');
+  })
+  .post('/pm/preview', koaBody(), async ctx => {
+    const { recipients = '', subject = '', text = '' } = ctx.request.body;
+    ctx.body = await pmView(recipients, subject, text, true);
+  })
+  .post('/inbox/delete/:id', koaBody(), async ctx => {
+    const { id } = ctx.params;
+    await pmModel.deleteMessageById(id);
+    ctx.redirect('/inbox');
   })
   .post('/inbox/delete/:id', koaBody(), async ctx => {
     const { id } = ctx.params;

+ 27 - 24
src/client/assets/styles/style.css

@@ -1248,12 +1248,6 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
     margin-top: 10px;
 }
 
-.pm-actions{
-    display: flex;
-    gap: 10px;
-    margin-top: 10px;
-}
-
 .audio-actions {
     display: flex;
     gap: 10px;
@@ -2094,27 +2088,34 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 }
 
 /*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-card.normal-pm { padding: 10px 12px; border-radius: 8px; margin-bottom: 10px; border: 1px solid #ffa300; background: transparent }
+.pm-headerline { display: flex; align-items: center; gap: 6px; font-size: 13px; margin-bottom: 4px }
+.pm-date { font-size: 12px; opacity: .7 }
+.pm-sep { opacity: .8 }
+.pm-spacer { flex: 1 }
+.pm-chars { font-size: 12px; opacity: .7 }
+.pm-subject { margin: 0 0 6px 0; font-size: 14px; font-weight: 600; line-height: 1.2; white-space: pre-wrap }
+.pm-bot { opacity: .85 }
+.pm-body { margin-top: 6px }
+.pm-action-form { display: inline-block; margin: 0 }
+.pm-actions-block {
+  width: 100%;
+  max-width: 720px;
+  border: 0;
 }
-
-.pm-header {
+.pm-actions {
   display: flex;
-  align-items: center;
-  gap: 18px;
-  margin-bottom: 12px;
-  font-size: 1em;
-  color: #ffd54f;
-}
-
-.pm-title {
-  font-weight: bold;
-  margin-bottom: 8px;
+  gap: 10px;
 }
+.thread-level-0 { margin-left: 0 }
+.thread-level-1 { margin-left: 12px; border-left: 1px solid #e5e7eb; padding-left: 10px }
+.thread-level-2 { margin-left: 24px; border-left: 1px solid #e5e7eb; padding-left: 10px }
+.thread-level-3 { margin-left: 36px; border-left: 1px solid #e5e7eb; padding-left: 10px }
+.thread-level-4 { margin-left: 48px; border-left: 1px solid #e5e7eb; padding-left: 10px }
+.thread-level-5 { margin-left: 60px; border-left: 1px solid #e5e7eb; padding-left: 10px }
+.thread-level-6 { margin-left: 72px; border-left: 1px solid #e5e7eb; padding-left: 10px }
+.thread-level-7 { margin-left: 84px; border-left: 1px solid #e5e7eb; padding-left: 10px }
+.thread-level-8 { margin-left: 96px; border-left: 1px solid #e5e7eb; padding-left: 10px }
 
 /*projects*/
 .project-actions {
@@ -2265,3 +2266,5 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 .error-box{background:#222326;border:none;color:#f5c242;padding:12px;border-radius:8px;margin-top:8px}
 .error-title{margin:0 0 6px 0;font-weight:600}
 .error-pre{margin:0;white-space:pre-wrap;font-family:monospace}
+
+

+ 1 - 1
src/client/assets/translations/i18n.js

@@ -1,6 +1,6 @@
 const path = require('path');
 let i18n = {};
-const languages = ['en', 'es', 'eu']; // Add more language codes
+const languages = ['en', 'es', 'fr', 'eu']; // Add more language codes
 
 languages.forEach(language => {
   try {

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

@@ -56,6 +56,9 @@ module.exports = {
     privateDate: "Date",
     privateDelete: "Delete",
     pmCreateButton: "Write a PM",
+    pmReply: "Reply",
+    pmPreview: "Preview",
+    pmPreviewTitle: "Message preview",
     noPrivateMessages: "You haven't received any private message, yet.",
     peers: "Peers",
     privateDescription: ["Private messages are ",strong("encrypted for your public key")," and have a maximum of 7 recipients."],

+ 3 - 0
src/client/assets/translations/oasis_es.js

@@ -1323,6 +1323,9 @@ module.exports = {
     privateDelete: "Borrar",
     pmCreateButton: "Escribir MP",
     noPrivateMessages: "No hay mensajes privados.",
+    pmReply: "Responder",
+    pmPreview: "Previsualizar",
+    pmPreviewTitle: "Vista previa",
     performed: "realizado",
     pmFromLabel: "De:",
     pmToLabel: "Para:",

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

@@ -1324,6 +1324,9 @@ module.exports = {
     privateDelete: "Ezabatu",
     pmCreateButton: "MP idatzi",
     noPrivateMessages: "Ez dago mezu pribaturik.",
+    pmReply: "Erantzun",
+    pmPreview: "Aurrebista",
+    pmPreviewTitle: "Mezuaren aurrebista",
     performed: "egin da",
     pmFromLabel: "Nork:",
     pmToLabel: "Nori:",

File diff suppressed because it is too large
+ 1875 - 0
src/client/assets/translations/oasis_fr.js


+ 124 - 0
src/models/pm_model.js

@@ -0,0 +1,124 @@
+const pull = require('../server/node_modules/pull-stream');
+const util = require('../server/node_modules/util');
+
+module.exports = ({ cooler }) => {
+  let ssb;
+  let userId;
+  const openSsb = async () => {
+    if (!ssb) {
+      ssb = await cooler.open();
+      userId = ssb.id;
+    }
+    return ssb;
+  };
+
+  function uniqueRecps(list) {
+    const out = [];
+    const seen = new Set();
+    for (const x of (list || [])) {
+      if (typeof x !== 'string') continue;
+      const id = x.trim();
+      if (!id || seen.has(id)) continue;
+      seen.add(id);
+      out.push(id);
+    }
+    return out;
+  }
+
+  return {
+    type: 'post',
+
+    async sendMessage(recipients = [], subject = '', text = '') {
+      const ssbClient = await openSsb();
+      const recps = uniqueRecps([userId, ...recipients]);
+      const content = {
+        type: 'post',
+        from: userId,
+        to: recps,
+        subject,
+        text,
+        sentAt: new Date().toISOString(),
+        private: true
+      };
+      const publishAsync = util.promisify(ssbClient.private.publish);
+      return publishAsync(content, recps);
+    },
+
+    async deleteMessageById(messageId) {
+      const ssbClient = await openSsb();
+      const rawMsg = await new Promise((resolve, reject) =>
+        ssbClient.get(messageId, (err, m) =>
+          err ? reject(new Error("Error retrieving message.")) : resolve(m)
+        )
+      );
+      let decrypted;
+      try {
+        decrypted = ssbClient.private.unbox({
+          key: messageId,
+          value: rawMsg,
+          timestamp: rawMsg?.timestamp || Date.now()
+        });
+      } catch {
+        throw new Error("Malformed message.");
+      }
+      const content = decrypted?.value?.content;
+      const author = decrypted?.value?.author;
+      const originalRecps = Array.isArray(content?.to) ? content.to : [];
+      if (!content || !author) throw new Error("Malformed message.");
+      if (content.type === 'tombstone') throw new Error("Message already deleted.");
+      const tombstone = {
+        type: 'tombstone',
+        target: messageId,
+        deletedAt: new Date().toISOString(),
+        private: true
+      };
+      const tombstoneRecps = uniqueRecps([userId, author, ...originalRecps]);
+      const publishAsync = util.promisify(ssbClient.private.publish);
+      return publishAsync(tombstone, tombstoneRecps);
+    },
+
+    async listAllPrivate() {
+      const ssbClient = await openSsb();
+      const raw = await new Promise((resolve, reject) => {
+        pull(
+          ssbClient.createLogStream({ reverse: false }),
+          pull.collect((err, arr) => err ? reject(err) : resolve(arr))
+        );
+      });
+      const posts = [];
+      const tombed = new Set();
+      for (const m of raw) {
+        if (!m || !m.value) continue;
+        const keyIn = m.key || m.value?.key || m.value?.hash || '';
+        const valueIn = m.value || m;
+        const tsIn = m.timestamp || m.value?.timestamp || Date.now();
+        let dec;
+        try {
+          dec = ssbClient.private.unbox({ key: keyIn, value: valueIn, timestamp: tsIn });
+        } catch {
+          continue;
+        }
+        const v = dec?.value || {};
+        const c = v.content || {};
+        const k = dec?.key || keyIn;
+        if (!c || c.private !== true || !k) continue;
+        if (c.type === 'tombstone' && c.target) {
+          tombed.add(c.target);
+          continue;
+        }
+        if (c.type === 'post') {
+          const to = Array.isArray(c.to) ? c.to : [];
+          const author = v.author;
+          if (author === userId || to.includes(userId)) {
+            posts.push({
+              key: k,
+              value: { author, content: c },
+              timestamp: v.timestamp || tsIn
+            });
+          }
+        }
+      }
+      return posts.filter(m => m && m.key && !tombed.has(m.key));
+    }
+  };
+};

+ 0 - 66
src/models/privatemessages_model.js

@@ -1,66 +0,0 @@
-const pull = require('../server/node_modules/pull-stream');
-const util = require('../server/node_modules/util');
-
-module.exports = ({ cooler }) => {
-  let ssb;
-  let userId;
-  const openSsb = async () => {
-    if (!ssb) {
-      ssb = await cooler.open();
-      userId = ssb.id;
-    }
-    return ssb;
-  };
-  
-  return {
-    type: 'post',
-    async sendMessage(recipients = [], subject = '', text = '') {
-      const ssbClient = await openSsb();
-      const content = {
-        type: 'post',
-        from: userId,
-        to: recipients,
-        subject,
-        text,
-        sentAt: new Date().toISOString(),
-        private: true
-      };
-      const publishAsync = util.promisify(ssbClient.private.publish);
-      return publishAsync(content, recipients);
-    },
-
-    async deleteMessageById(messageId) {
-      const ssbClient = await openSsb();
-      const rawMsg = await new Promise((resolve, reject) =>
-        ssbClient.get(messageId, (err, m) =>
-          err ? reject(new Error("Error retrieving message.")) : resolve(m)
-        )
-      );
-      let decrypted;
-      try {
-        decrypted = ssbClient.private.unbox({
-          key: messageId,
-          value: rawMsg,
-          timestamp: rawMsg.timestamp || Date.now()
-        });
-      } catch {
-        throw new Error("Malformed message.");
-      }
-      const content = decrypted?.value?.content;
-      const author = decrypted?.value?.author;
-      const recps = content?.to;
-
-      if (!content || !author || !Array.isArray(recps)) throw new Error("Malformed message.");
-      if (content.type === 'tombstone') throw new Error("Message already deleted.");
-
-      const tombstone = {
-        type: 'tombstone',
-        target: messageId,
-        deletedAt: new Date().toISOString(),
-        private: true
-      };
-      const publishAsync = util.promisify(ssbClient.private.publish);
-      return publishAsync(tombstone, recps);
-    }
-  };
-};

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

@@ -1,12 +1,12 @@
 {
   "name": "@krakenslab/oasis",
-  "version": "0.4.8",
+  "version": "0.4.9",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "@krakenslab/oasis",
-      "version": "0.4.7",
+      "version": "0.4.8",
       "hasInstallScript": true,
       "license": "AGPL-3.0",
       "dependencies": {

+ 1 - 1
src/server/package.json

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

+ 277 - 249
src/views/main_views.js

@@ -21,7 +21,7 @@ const getUserId = async () => {
   return userId;
 };
 
-const { a, article, br, body, button, details, div, em, footer, form, h1, h2, head, header, hr, html, img, input, label, li, link, main, meta, nav, option, p, pre, section, select, span, summary, textarea, title, tr, ul, strong } = require("../server/node_modules/hyperaxe");
+const { a, article, br, body, button, details, div, em, footer, form, h1, h2, h3, head, header, hr, html, img, input, label, li, link, main, meta, nav, option, p, pre, section, select, span, summary, textarea, title, tr, ul, strong } = require("../server/node_modules/hyperaxe");
 
 const lodash = require("../server/node_modules/lodash");
 const markdown = require("./markdown");
@@ -40,7 +40,7 @@ exports.setLanguage = (language) => {
 exports.i18n = i18n;
 exports.selectedLanguage = selectedLanguage;
 
-//markdown
+// markdown
 const markdownUrl = "https://commonmark.org/help/";
 
 const doctypeString = "<!DOCTYPE html>";
@@ -1239,269 +1239,297 @@ exports.mentionsView = ({ messages, myFeedId }) => {
 };
 
 exports.privateView = async (messagesInput, filter) => {
-    const messages = Array.isArray(messagesInput) ? messagesInput : messagesInput.messages;
-    const userId = await getUserId();
+  const messagesRaw = Array.isArray(messagesInput) ? messagesInput : messagesInput.messages
+  const messages = (messagesRaw || []).filter(m => m && m.key && m.value && m.value.content && m.value.content.type === 'post' && m.value.content.private === true)
+  const userId = await getUserId()
 
-    const filtered =
-        filter === 'sent' ? messages.filter(m => m.value.content.from === userId) :
-        filter === 'inbox' ? messages.filter(m => m.value.content.to?.includes(userId)) :
-        messages;
+  const isSent = m => (m?.value?.author === userId) || (m?.value?.content?.from === userId)
+  const isToUser = m => Array.isArray(m?.value?.content?.to) && m.value.content.to.includes(userId)
 
-    const linkAuthor = (id) =>
-        a({ class: 'user-link', href: `/author/${encodeURIComponent(id)}` }, id);
+  const linkAuthor = (id) =>
+    a({ class: 'user-link', href: `/author/${encodeURIComponent(id)}` }, id)
 
-    const hrefFor = {
-        job: (id) => `/jobs/${encodeURIComponent(id)}`,
-        project: (id) => `/projects/${encodeURIComponent(id)}`,
-        market: (id) => `/market/${encodeURIComponent(id)}`
-    };
+  const hrefFor = {
+    job: (id) => `/jobs/${encodeURIComponent(id)}`,
+    project: (id) => `/projects/${encodeURIComponent(id)}`,
+    market: (id) => `/market/${encodeURIComponent(id)}`
+  }
 
-    const clickableCardProps = (href, extraClass = '') => {
-        const props = { class: `pm-card ${extraClass}` };
-        if (href) {
-            props.onclick = `window.location='${href}'`;
-            props.tabindex = 0;
-            props.onkeypress = `if(event.key==='Enter') window.location='${href}'`;
-        }
-        return props;
-    };
+  const clickableCardProps = (href, extraClass = '') => {
+    const props = { class: `pm-card ${extraClass}` }
+    if (href) {
+      props.onclick = `window.location='${href}'`
+      props.tabindex = 0
+      props.onkeypress = `if(event.key==='Enter') window.location='${href}'`
+    }
+    return props
+  }
 
-    const chip = (txt) => span({ class: 'chip' }, txt);
+  const chip = (txt) => span({ class: 'chip' }, txt)
 
-    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}`)
-                : [
-                    span({ class: 'pm-from' }, i18n.pmFromLabel + ' ', linkAuthor(from)),
-                    span({ class: 'pm-to' }, i18n.pmToLabel + ' ', toLinks)
-                ]
-        );
-    }
+  function headerLine({ sentAt, from, toLinks, textLen }) {
+    return div({ class: 'pm-header' },
+      span({ class: 'date-link' }, `${moment(sentAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed}`),
+      span({ class: 'pm-from' }, ' ', i18n.pmFromLabel, ' ', linkAuthor(from)),
+      span({ class: 'pm-to' }, ' ', '→', ' ', i18n.pmToLabel, ' ', toLinks)
+    )
+  }
 
-    function actions({ key, replyId }) {
-        const stop = { onclick: 'event.stopPropagation()' };
-        return div({ class: 'pm-actions' },
-            form({ method: 'POST', action: `/inbox/delete/${encodeURIComponent(key)}`, class: 'delete-message-form', style: 'display:inline-block;margin-right:8px;', ...stop },
-                button({ type: 'submit', class: 'delete-btn' }, i18n.privateDelete)
-            ),
-            form({ method: 'GET', action: '/pm', style: 'display:inline-block;', ...stop },
-                input({ type: 'hidden', name: 'recipients', value: replyId }),
-                button({ type: 'submit', class: 'reply-btn' }, i18n.pmCreateButton)
-            )
-        );
-    }
+  function actions({ key, replyId, subjectRaw, text }) {
+    const stop = { onclick: 'event.stopPropagation()' }
+    const subjectReply = /^(\s*RE:\s*)/i.test(subjectRaw || '') ? (subjectRaw || '') : `RE: ${subjectRaw || ''}`
+    return div({ class: 'pm-actions' },
+      form({ method: 'GET', action: '/pm', class: 'pm-action-form', ...stop },
+        input({ type: 'hidden', name: 'recipients', value: replyId }),
+        input({ type: 'hidden', name: 'subject', value: subjectReply }),
+        input({ type: 'hidden', name: 'quote', value: text || '' }),
+        button({ type: 'submit', class: 'pm-btn reply-btn' }, i18n.pmReply.toUpperCase())
+      ),
+      form({ method: 'POST', action: `/inbox/delete/${encodeURIComponent(key)}`, class: 'pm-action-form', ...stop },
+        button({ type: 'submit', class: 'pm-btn delete-btn' }, i18n.privateDelete.toUpperCase())
+      )
+    )
+  }
 
-    function quoted(str) {
-        const m = str.match(/"([^"]+)"/);
-        return m ? m[1] : '';
-    }
+  function canonicalSubject(s) {
+    return (s || '').replace(/^\s*(RE:\s*)+/i, '').trim()
+  }
 
-    function pickLink(str, kind) {
-        if (kind === 'job') {
-            const m = str.match(/\/jobs\/([%A-Za-z0-9/+._=-]+\.sha256)/);
-            return m ? m[1] : '';
-        }
-        if (kind === 'project') {
-            const m = str.match(/\/projects\/([%A-Za-z0-9/+._=-]+\.sha256)/);
-            return m ? m[1] : '';
-        }
-        if (kind === 'market') {
-            const m = str.match(/\/market\/([%A-Za-z0-9/+._=-]+\.sha256)/);
-            return m ? m[1] : '';
-        }
-        return '';
-    }
+  function participantsKey(m) {
+    const c = m?.value?.content || {}
+    const set = new Set([m?.value?.author, ...(Array.isArray(c.to) ? c.to : [])])
+    return Array.from(set).sort().join('|')
+  }
 
-    function JobCard({ type, sentAt, from, toLinks, text, key }) {
-        const isSub = type === 'JOB_SUBSCRIBED';
-        const icon = isSub ? '🟡' : '🟠';
-        const titleH = isSub ? (i18n.inboxJobSubscribedTitle || 'New subscription to your job offer') : (i18n.inboxJobUnsubscribedTitle || 'Unsubscription from your job offer');
-        const jobTitle = quoted(text) || 'job';
-        const jobId = pickLink(text, 'job');
-        const href = jobId ? hrefFor.job(jobId) : null;
-        return div(
-            clickableCardProps(href, `job-notification ${isSub ? 'job-sub' : 'job-unsub'}`),
-            header({ sentAt, from, toLinks, botIcon: icon, botLabel: i18n.pmBotJobs }),
-            h2({ class: 'pm-title' }, titleH),
-            p(
-                i18n.pmInhabitantWithId, ' ',
-                linkAuthor(from), ' ',
-                isSub ? i18n.pmHasSubscribedToYourJobOffer : (i18n.pmHasUnsubscribedFromYourJobOffer || 'has unsubscribed from your job offer'),
-                ' ',
-                href ? a({ class: 'job-link', href }, `"${jobTitle}"`) : `"${jobTitle}"`
-            ),
-            actions({ key, replyId: from })
-        );
-    }
+  function threadId(m) {
+    return canonicalSubject(m?.value?.content?.subject || '') + '||' + participantsKey(m)
+  }
 
-    function ProjectFollowCard({ type, sentAt, from, toLinks, text, key }) {
-        const isFollow = type === 'PROJECT_FOLLOWED';
-        const icon = isFollow ? '🔔' : '🔕';
-        const titleH = isFollow
-            ? (i18n.inboxProjectFollowedTitle || 'New follower of your project')
-            : (i18n.inboxProjectUnfollowedTitle || 'Unfollowed your project');
-        const projectTitle = quoted(text) || 'project';
-        const projectId = pickLink(text, 'project');
-        const href = projectId ? hrefFor.project(projectId) : null;
-        return div(
-            clickableCardProps(href, `project-${isFollow ? 'follow' : 'unfollow'}-notification`),
-            header({ sentAt, from, toLinks, botIcon: icon, botLabel: i18n.pmBotProjects }),
-            h2({ class: 'pm-title' }, titleH),
-            p(
-                i18n.pmInhabitantWithId, ' ',
-                a({ class: 'user-link', href: `/author/${encodeURIComponent(from)}` }, from),
-                ' ',
-                isFollow ? (i18n.pmHasFollowedYourProject || 'has followed your project') : (i18n.pmHasUnfollowedYourProject || 'has unfollowed your project'),
-                ' ',
-                href ? a({ class: 'project-link', href }, `"${projectTitle}"`) : `"${projectTitle}"`
-            ),
-            actions({ key, replyId: from })
-        );
-    }
+  function threadLevel(s) {
+    const m = (s || '').match(/RE:/gi)
+    return m ? Math.min(m.length, 8) : 0
+  }
 
-    function MarketSoldCard({ sentAt, from, toLinks, subject, text, key }) {
-        const itemTitle = quoted(subject) || quoted(text) || 'item';
-        const buyerId = (text.match(/OASIS ID:\s*([\w=/+.-]+)/) || [])[1] || from;
-        const price = (text.match(/for:\s*\$([\d.]+)/) || [])[1] || '';
-        const marketId = pickLink(text, 'market');
-        const href = marketId ? hrefFor.market(marketId) : null;
-        return div(
-            clickableCardProps(href, 'market-sold-notification'),
-            header({ sentAt, from, toLinks, botIcon: '💰', botLabel: i18n.pmBotMarket }),
-            h2({ class: 'pm-title' }, i18n.inboxMarketItemSoldTitle),
-            p(
-                i18n.pmYourItem, ' ',
-                href ? a({ class: 'market-link', href }, `"${itemTitle}"`) : `"${itemTitle}"`,
-                ' ',
-                i18n.pmHasBeenSoldTo, ' ',
-                linkAuthor(buyerId),
-                price ? ` ${i18n.pmFor} $${price}.` : '.'
-            ),
-            actions({ key, replyId: buyerId })
-        );
-    }
+  function quoted(str) {
+    const m = str.match(/"([^"]+)"/)
+    return m ? m[1] : ''
+  }
 
-    function ProjectPledgeCard({ sentAt, from, toLinks, content, text, key }) {
-        const amount = content.meta?.amount ?? (text.match(/pledged\s+([\d.]+)/)?.[1] || '0');
-        const projectTitle = content.meta?.projectTitle ?? (text.match(/project\s+"([^"]+)"/)?.[1] || 'project');
-        const projectId = content.meta?.projectId ?? pickLink(text, 'project');
-        const href = projectId ? hrefFor.project(projectId) : null;
-        return div(
-            clickableCardProps(href, 'project-pledge-notification'),
-            header({ sentAt, from, toLinks, botIcon: '💚', botLabel: i18n.pmBotProjects }),
-            h2({ class: 'pm-title' }, i18n.inboxProjectPledgedTitle),
-            p(
-                i18n.pmInhabitantWithId, ' ',
-                linkAuthor(from), ' ',
-                i18n.pmHasPledged, ' ',
-                chip(`${amount} ECO`), ' ',
-                i18n.pmToYourProject, ' ',
-                href ? a({ class: 'project-link', href }, `"${projectTitle}"`) : `"${projectTitle}"`
-            ),
-            actions({ key, replyId: from })
-        );
+  function pickLink(str, kind) {
+    if (kind === 'job') {
+      const m = str.match(/\/jobs\/([%A-Za-z0-9/+._=-]+\.sha256)/)
+      return m ? m[1] : ''
     }
-
-    function clickableLinks(str) {
-        return str
-            .replace(/(@[a-zA-Z0-9/+._=-]+\.ed25519)/g,
-                (match, id) => `<a class="user-link" href="/author/${encodeURIComponent(id)}">${match}</a>`
-            )
-            .replace(/\/jobs\/([%A-Za-z0-9/+._=-]+\.sha256)/g,
-                (match, id) => `<a class="job-link" href="${hrefFor.job(id)}">${match}</a>`
-            )
-            .replace(/\/projects\/([%A-Za-z0-9/+._=-]+\.sha256)/g,
-                (match, id) => `<a class="project-link" href="${hrefFor.project(id)}">${match}</a>`
-            )
-            .replace(/\/market\/([%A-Za-z0-9/+._=-]+\.sha256)/g,
-                (match, id) => `<a class="market-link" href="${hrefFor.market(id)}">${match}</a>`
-            );
+    if (kind === 'project') {
+      const m = str.match(/\/projects\/([%A-Za-z0-9/+._=-]+\.sha256)/)
+      return m ? m[1] : ''
+    }
+    if (kind === 'market') {
+      const m = str.match(/\/market\/([%A-Za-z0-9/+._=-]+\.sha256)/)
+      return m ? m[1] : ''
     }
+    return ''
+  }
 
-    return template(
-        i18n.private,
-        section(
-            div({ class: 'tags-header' },
-                h2(i18n.private),
-                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: 'message-list' },
-                filtered.length
-                    ? filtered.map(msg => {
-                        const content = msg?.value?.content;
-                        const author = msg?.value?.author;
-                        if (!content || !author) return div({ class: 'pm-card malformed' }, i18n.pmInvalidMessage);
-                        const subjectRaw = content.subject || '';
-                        const subject = subjectRaw.toUpperCase();
-                        const text = content.text || '';
-                        const sentAt = new Date(content.sentAt || msg.timestamp);
-                        const from = content.from;
-                        const toLinks = (content.to || []).map(addr => linkAuthor(addr));
-
-                        if (subject === 'JOB_SUBSCRIBED' || subject === 'JOB_UNSUBSCRIBED') {
-                            return JobCard({ type: subject, sentAt, from, toLinks, text, key: msg.key });
-                        }
-			if (subject === 'PROJECT_FOLLOWED' || subject === 'PROJECT_UNFOLLOWED') {
-			    return ProjectFollowCard({ type: subject, sentAt, from, toLinks, text, key: msg.key });
-			}
-                        if (subject === 'MARKET_SOLD') {
-                            return MarketSoldCard({ sentAt, from, toLinks, subject: subjectRaw, text, key: msg.key });
-                        }
-                        if (subject === 'PROJECT_PLEDGE' || content.meta?.type === 'project-pledge') {
-                            return ProjectPledgeCard({ sentAt, from, toLinks, content, text, key: msg.key });
-                        }
-
-                        const jobTxt = text.match(/has subscribed to your job offer "([^"]+)"/);
-                        const jobIdLegacy = pickLink(text, 'job');
-                        if (jobTxt && jobIdLegacy) return JobCard({ type: 'JOB_SUBSCRIBED', sentAt, from, toLinks, text, key: msg.key });
-
-                        const projTxt = text.match(/has created a project "([^"]+)"/);
-                        const projIdLegacy = pickLink(text, 'project');
-                        if (projTxt && projIdLegacy) return ProjectCreatedCard({ sentAt, from, toLinks, text, key: msg.key });
-
-                        const saleTxt = subjectRaw.match(/item "([^"]+)" has been sold/) || text.match(/item "([^"]+)" has been sold/);
-                        const marketIdLegacy = pickLink(text, 'market');
-                        if (saleTxt && marketIdLegacy) return MarketSoldCard({ sentAt, from, toLinks, subject: subjectRaw, text, key: msg.key });
-
-                        return div(
-                            { class: 'pm-card normal-pm' },
-                            header({ sentAt, from, toLinks }),
-                            h2(content.subject || i18n.pmNoSubject),
-                            p({ class: 'message-text' }, ...renderUrl(clickableLinks(text))),
-                            actions({ key: msg.key, replyId: from })
-                        );
-                    })
-                    : p({ class: 'empty' }, i18n.noPrivateMessages)
-            )
-        )
-    );
-};
+  function clickableLinks(str) {
+    return str
+      .replace(/(@[a-zA-Z0-9/+._=-]+\.ed25519)/g, (match, id) => `<a class="user-link" href="/author/${encodeURIComponent(id)}">${match}</a>`)
+      .replace(/\/jobs\/([%A-Za-z0-9/+._=-]+\.sha256)/g, (match, id) => `<a class="job-link" href="${hrefFor.job(id)}">${match}</a>`)
+      .replace(/\/projects\/([%A-Za-z0-9/+._=-]+\.sha256)/g, (match, id) => `<a class="project-link" href="${hrefFor.project(id)}">${match}</a>`)
+      .replace(/\/market\/([%A-Za-z0-9/+._=-]+\.sha256)/g, (match, id) => `<a class="market-link" href="${hrefFor.market(id)}">${match}</a>`)
+  }
+
+  const threads = {}
+  for (const m of messages) {
+    const tid = threadId(m)
+    if (!threads[tid]) threads[tid] = []
+    threads[tid].push(m)
+  }
+
+  const inboxSet = new Set()
+  for (const arr of Object.values(threads)) {
+    const hasInbound = arr.some(isToUser)
+    if (hasInbound) for (const m of arr) inboxSet.add(m)
+  }
+
+  const data =
+    filter === 'sent' ? messages.filter(isSent) :
+    filter === 'inbox' ? Array.from(inboxSet) :
+    messages
+
+  const inboxCount = Array.from(inboxSet).length
+  const sentCount = messages.filter(isSent).length
+
+  const sorted = [...data].sort((a, b) => {
+    const ta = threadId(a)
+    const tb = threadId(b)
+    if (ta < tb) return -1
+    if (ta > tb) return 1
+    const sa = new Date(a?.value?.content?.sentAt || a.timestamp || 0).getTime()
+    const sb = new Date(b?.value?.content?.sentAt || b.timestamp || 0).getTime()
+    return sa - sb
+  })
+
+  function JobCard({ type, sentAt, from, toLinks, text, key }) {
+    const isSub = type === 'JOB_SUBSCRIBED'
+    const icon = isSub ? '🟡' : '🟠'
+    const titleH = isSub ? (i18n.inboxJobSubscribedTitle || 'New subscription to your job offer') : (i18n.inboxJobUnsubscribedTitle || 'Unsubscription from your job offer')
+    const jobTitle = quoted(text) || 'job'
+    const jobId = pickLink(text, 'job')
+    const href = jobId ? hrefFor.job(jobId) : null
+    return div(
+      clickableCardProps(href, `job-notification thread-level-0`),
+      headerLine({ sentAt, from, toLinks, textLen: text.length }),
+      h2({ class: 'pm-title' }, `${icon} ${i18n.pmBotJobs} · ${titleH}`),
+      p(
+        i18n.pmInhabitantWithId, ' ',
+        linkAuthor(from), ' ',
+        isSub ? i18n.pmHasSubscribedToYourJobOffer : (i18n.pmHasUnsubscribedFromYourJobOffer || 'has unsubscribed from your job offer'),
+        ' ',
+        href ? a({ class: 'job-link', href }, `"${jobTitle}"`) : `"${jobTitle}"`
+      ),
+      actions({ key, replyId: from, subjectRaw: jobTitle, text })
+    )
+  }
+
+  function ProjectFollowCard({ type, sentAt, from, toLinks, text, key }) {
+    const isFollow = type === 'PROJECT_FOLLOWED'
+    const icon = isFollow ? '🔔' : '🔕'
+    const titleH = isFollow
+      ? (i18n.inboxProjectFollowedTitle || 'New follower of your project')
+      : (i18n.inboxProjectUnfollowedTitle || 'Unfollowed your project')
+    const projectTitle = quoted(text) || 'project'
+    const projectId = pickLink(text, 'project')
+    const href = projectId ? hrefFor.project(projectId) : null
+    return div(
+      clickableCardProps(href, `project-${isFollow ? 'follow' : 'unfollow'}-notification thread-level-0`),
+      headerLine({ sentAt, from, toLinks, textLen: text.length }),
+      h2({ class: 'pm-title' }, `${icon} ${i18n.pmBotProjects} · ${titleH}`),
+      p(
+        i18n.pmInhabitantWithId, ' ',
+        a({ class: 'user-link', href: `/author/${encodeURIComponent(from)}` }, from),
+        ' ',
+        isFollow ? (i18n.pmHasFollowedYourProject || 'has followed your project') : (i18n.pmHasUnfollowedYourProject || 'has unfollowed your project'),
+        ' ',
+        href ? a({ class: 'project-link', href }, `"${projectTitle}"`) : `"${projectTitle}"`
+      ),
+      actions({ key, replyId: from, subjectRaw: projectTitle, text })
+    )
+  }
+
+  function MarketSoldCard({ sentAt, from, toLinks, subject, text, key }) {
+    const itemTitle = quoted(subject) || quoted(text) || 'item'
+    const buyerId = (text.match(/OASIS ID:\s*([\w=/+.-]+)/) || [])[1] || from
+    const price = (text.match(/for:\s*\$([\d.]+)/) || [])[1] || ''
+    const marketId = pickLink(text, 'market')
+    const href = marketId ? hrefFor.market(marketId) : null
+    return div(
+      clickableCardProps(href, 'market-sold-notification thread-level-0'),
+      headerLine({ sentAt, from, toLinks, textLen: text.length }),
+      h2({ class: 'pm-title' }, `💰 ${i18n.pmBotMarket} · ${i18n.inboxMarketItemSoldTitle}`),
+      p(
+        i18n.pmYourItem, ' ',
+        href ? a({ class: 'market-link', href }, `"${itemTitle}"`) : `"${itemTitle}"`,
+        ' ',
+        i18n.pmHasBeenSoldTo, ' ',
+        linkAuthor(buyerId),
+        price ? ` ${i18n.pmFor} $${price}.` : '.'
+      ),
+      actions({ key, replyId: buyerId, subjectRaw: itemTitle, text })
+    )
+  }
+
+  function ProjectPledgeCard({ sentAt, from, toLinks, content, text, key }) {
+    const amount = content.meta?.amount ?? (text.match(/pledged\s+([\d.]+)/)?.[1] || '0')
+    const projectTitle = content.meta?.projectTitle ?? (text.match(/project\s+"([^"]+)"/)?.[1] || 'project')
+    const projectId = content.meta?.projectId ?? pickLink(text, 'project')
+    const href = projectId ? hrefFor.project(projectId) : null
+    return div(
+      clickableCardProps(href, 'project-pledge-notification thread-level-0'),
+      headerLine({ sentAt, from, toLinks, textLen: text.length }),
+      h2({ class: 'pm-title' }, `💚 ${i18n.pmBotProjects} · ${i18n.inboxProjectPledgedTitle}`),
+      p(
+        i18n.pmInhabitantWithId, ' ',
+        linkAuthor(from), ' ',
+        i18n.pmHasPledged, ' ',
+        chip(`${amount} ECO`), ' ',
+        i18n.pmToYourProject, ' ',
+        href ? a({ class: 'project-link', href }, `"${projectTitle}"`) : `"${projectTitle}"`
+      ),
+      actions({ key, replyId: from, subjectRaw: projectTitle, text })
+    )
+  }
+
+  return template(
+    i18n.private,
+    section(
+      div({ class: 'tags-header' },
+        h2(i18n.private),
+        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} (${inboxCount})`),
+          button({
+            type: 'submit',
+            name: 'filter',
+            value: 'sent',
+            class: filter === 'sent' ? 'filter-btn active' : 'filter-btn'
+          }, `${i18n.privateSent} (${sentCount})`),
+          button({
+            type: 'submit',
+            name: 'filter',
+            value: 'create',
+            class: 'create-button',
+            formaction: '/pm',
+            formmethod: 'GET'
+          }, i18n.pmCreateButton)
+        ])
+      ),
+      div({ class: 'message-list' },
+        sorted.length
+          ? sorted.map(msg => {
+              const content = msg.value.content
+              const author = msg.value.author
+              const subjectRaw = content.subject || ''
+              const subjectU = subjectRaw.toUpperCase()
+              const text = content.text || ''
+              const sentAt = new Date(content.sentAt || msg.timestamp)
+              const fromResolved = content.from || author
+              const toLinks = Array.isArray(content.to) ? content.to.map(addr => linkAuthor(addr)) : []
+              const level = threadLevel(subjectRaw)
+
+              if (subjectU === 'JOB_SUBSCRIBED' || subjectU === 'JOB_UNSUBSCRIBED') {
+                return JobCard({ type: subjectU, sentAt, from: fromResolved, toLinks, text, key: msg.key })
+              }
+              if (subjectU === 'PROJECT_FOLLOWED' || subjectU === 'PROJECT_UNFOLLOWED') {
+                return ProjectFollowCard({ type: subjectU, sentAt, from: fromResolved, toLinks, text, key: msg.key })
+              }
+              if (subjectU === 'MARKET_SOLD') {
+                return MarketSoldCard({ sentAt, from: fromResolved, toLinks, subject: subjectRaw, text, key: msg.key })
+              }
+              if (subjectU === 'PROJECT_PLEDGE' || content.meta?.type === 'project-pledge') {
+                return ProjectPledgeCard({ sentAt, from: fromResolved, toLinks, content, text, key: msg.key })
+              }
+
+              return div(
+                { class: `pm-card normal-pm thread-level-${level}` },
+                headerLine({ sentAt, from: fromResolved, toLinks, textLen: text.length }),
+                h2(subjectRaw || i18n.pmNoSubject),
+                p({ class: 'message-text' }, ...renderUrl(clickableLinks(text))),
+                actions({ key: msg.key, replyId: fromResolved, subjectRaw, text })
+              )
+            })
+          : p({ class: 'empty' }, i18n.noPrivateMessages)
+      )
+    )
+  )
+}
 
 exports.publishCustomView = async () => {
   const action = "/publish/custom";

+ 22 - 8
src/views/pm_view.js

@@ -1,9 +1,10 @@
-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, pre } = require("../server/node_modules/hyperaxe");
 const { template, i18n } = require('./main_views');
 
-exports.pmView = async (initialRecipients = '') => {
+exports.pmView = async (initialRecipients = '', initialSubject = '', initialText = '', showPreview = false) => {
   const title = i18n.pmSendTitle;
   const description = i18n.pmDescription;
+  const textLen = (initialText || '').length;
 
   return template(
     title,
@@ -14,7 +15,7 @@ exports.pmView = async (initialRecipients = '') => {
       ),
       section(
         div({ class: "pm-form" },
-          form({ method: "POST", action: "/pm" },
+          form({ method: "POST", action: "/pm", id: "pm-form" },
             label({ for: "recipients" }, i18n.pmRecipients),
             br(),
             input({
@@ -27,14 +28,27 @@ exports.pmView = async (initialRecipients = '') => {
             br(),
             label({ for: "subject" }, i18n.pmSubject),
             br(),
-            input({ type: "text", name: "subject", placeholder: i18n.pmSubjectHint }),
+            input({ type: "text", name: "subject", placeholder: i18n.pmSubjectHint, value: initialSubject }),
             br(),
             label({ for: "text" }, i18n.pmText),
             br(),
-            textarea({ name: "text", rows: "6", cols: "50" }),
-            br(), br(),
-            button({ type: "submit" }, i18n.pmSend)
-          )
+            textarea({ name: "text", rows: "6", cols: "50", id: "pm-text", maxlength: "8096" }, initialText),
+		div({ class: "pm-actions-block" },
+		  div({ class: "pm-actions" },
+		    button({ type: "submit", formaction: "/pm/preview", formmethod: "POST" }, i18n.pmPreview),
+		    button({ type: "submit", class: "btn-compact" }, i18n.pmSend)
+		  )
+		)
+          ),
+          showPreview
+            ? div({ id: "pm-preview-area", class: "pm-preview" },
+                h2(i18n.pmPreviewTitle),
+                p({ id: "pm-preview-count", class: "pm-preview-count" }, `${textLen}/8096`),
+                div({ id: "pm-preview-content", class: "pm-preview-content" },
+                  pre({ class: "pm-pre" }, initialText || '')
+                )
+              )
+            : null
         )
       )
     )

+ 2 - 1
src/views/settings_view.js

@@ -86,7 +86,8 @@ const settingsView = ({ version, aiPrompt }) => {
           { action: "/language", method: "post" },
           select({ name: "language" }, [
             languageOption("English", "en"),
-            languageOption("Castellano", "es"),
+            languageOption("Español", "es"),
+            languageOption("Français", "fr"),
             languageOption("Euskara", "eu")
           ]),
           br(),