Kaynağa Gözat

Oasis release 0.6.3

psy 1 gün önce
ebeveyn
işleme
c03fe0bb4c

+ 8 - 0
docs/CHANGELOG.md

@@ -13,6 +13,14 @@ All notable changes to this project will be documented in this file.
 ### Security
 -->
 
+## v0.6.3 - 2025-12-10
+
+### Fixed
+
+ + Fixed mentions (Core plugin).
+ + Fixed feeds (Feed plugin).
+ + Minor details at market view (Market plugin).
+
 ## v0.6.2 - 2025-12-05
 
 ### Added

+ 168 - 66
src/backend/backend.js

@@ -411,85 +411,187 @@ function writeAddrMap(map) {
 }
 
 const preparePreview = async function (ctx) {
-    let text = String(ctx.request.body.text || "");
-    const contentWarning = String(ctx.request.body.contentWarning || "");
-    const mentions = {};
-    const rex = /(^|\s)(?!\[)@([a-zA-Z0-9\-/.=+]{3,})\b/g;
-    let m;
-
-    while ((m = rex.exec(text)) !== null) {
-        const token = m[2];
-        const key = token;
-        let found = mentions[key] || [];
-
-        if (/\.ed25519$/.test(token)) {
-            const name = await about.name(token);
-            const img = await about.image(token);
-            found.push({
-                feed: token,
-                name,
-                img,
-                rel: { followsMe: false, following: false, blocking: false, me: false }
-            });
-        } else {
-            const matches = about.named(token);
-            for (const match of matches) {
-                found.push(match);
-            }
-        }
+  let text = String(ctx.request.body.text || "")
+  const contentWarning = String(ctx.request.body.contentWarning || "")
+
+  const ensureAt = (id) => {
+    const s = String(id || "")
+    if (!s) return ""
+    return s.startsWith("@") ? s : `@${s.replace(/^@+/, "")}`
+  }
+
+  const stripAt = (id) => String(id || "").replace(/^@+/, "")
+
+  const norm = (s) => String(s || "").trim().toLowerCase()
+
+  const ssbClient = await cooler.open()
+  const authorMeta = {
+    id: ssbClient.id,
+    name: await about.name(ssbClient.id),
+    image: await about.image(ssbClient.id),
+  }
+
+  const myId = String(authorMeta.id)
+
+  text = text.replace(
+    /\[@([^\]]+)\]\s*\(\s*@?([^) \t\r\n]+\.ed25519)\s*\)/g,
+    (_m, label, feed) => `[@${label}](@${stripAt(feed)})`
+  )
+
+  const mentions = {}
+
+  const normalizeMatch = (m) => {
+    const feed = ensureAt(m?.feed || m?.link || m?.id || "")
+    const name = String(m?.name || "")
+    const img = m?.img || m?.image || null
+    const rel = m?.rel || {}
+    return { ...m, feed, name, img, rel }
+  }
+
+  const pushUnique = (key, arr) => {
+    const prev = Array.isArray(mentions[key]) ? mentions[key] : []
+    const seen = new Set(prev.map((x) => String(x?.feed || "")))
+    const out = prev.slice()
+    for (const x of arr) {
+      const f = String(x?.feed || "")
+      if (!f) continue
+      if (seen.has(f)) continue
+      seen.add(f)
+      out.push(x)
+    }
+    if (out.length) mentions[key] = out
+  }
 
-        if (found.length > 0) {
-            mentions[key] = found;
+  const chooseByPhrase = (matches, phrase) => {
+    const p = norm(phrase)
+    const exact = matches.filter((mm) => norm(mm.name) === p)
+    if (exact.length) return exact
+    const starts = matches.filter((mm) => norm(mm.name).startsWith(p))
+    if (starts.length) return starts
+    const incl = matches.filter((mm) => norm(mm.name).includes(p))
+    if (incl.length) return incl
+    return null
+  }
+
+  const rex = /(^|\s)(?!\[)@([a-zA-Z0-9\-/.=+]{3,})(?:\s+([a-zA-Z0-9][a-zA-Z0-9\-/.=+]{1,}))?(?:\s+([a-zA-Z0-9][a-zA-Z0-9\-/.=+]{1,}))?\b/g
+  let m
+
+  while ((m = rex.exec(text)) !== null) {
+    const w1 = m[2]
+    const w2 = m[3]
+    const w3 = m[4]
+
+    if (/\.ed25519$/.test(w1)) {
+      const feed = ensureAt(w1)
+      const name = await about.name(feed)
+      const img = await about.image(feed)
+      pushUnique(w1, [
+        {
+          feed,
+          name,
+          img,
+          rel: { followsMe: false, following: false, blocking: false, me: false }
+        }
+      ])
+      continue
+    }
+
+    const phrase1 = w1
+    const phrase2 = w2 ? `${w1} ${w2}` : null
+    const phrase3 = w3 ? `${w1} ${w2 ? w2 : ""} ${w3}`.replace(/\s+/g, " ").trim() : null
+
+    const matchesRaw = about.named(w1) || []
+    const matchesAll = matchesRaw.map(normalizeMatch)
+    const matches = matchesAll.filter((mm) => String(mm.feed) !== myId && !mm?.rel?.me)
+
+    let chosenKey = phrase1
+    let chosenMatches = matches
+
+    if (phrase3) {
+      const best3 = chooseByPhrase(matches, phrase3)
+      if (best3 && best3.length) {
+        chosenKey = phrase3
+        chosenMatches = best3
+      } else if (phrase2) {
+        const best2 = chooseByPhrase(matches, phrase2)
+        if (best2 && best2.length) {
+          chosenKey = phrase2
+          chosenMatches = best2
         }
+      }
+    } else if (phrase2) {
+      const best2 = chooseByPhrase(matches, phrase2)
+      if (best2 && best2.length) {
+        chosenKey = phrase2
+        chosenMatches = best2
+      }
     }
 
-    Object.keys(mentions).forEach((key) => {
-        const matches = mentions[key];
-        const meaningful = matches.filter(
-            (m) => (m.rel?.followsMe || m.rel?.following) && !m.rel?.blocking
-        );
-        mentions[key] = meaningful.length > 0 ? meaningful : matches;
-    });
+    if (chosenMatches.length > 0) {
+      pushUnique(chosenKey, chosenMatches)
+    }
+  }
 
-    const replacer = (match, prefix, token) => {
-        const matches = mentions[token];
-        if (matches && matches.length === 1) {
-            return `${prefix}[@${matches[0].name}](${matches[0].feed})`;
-        }
-        return match;
-    };
+  Object.keys(mentions).forEach((key) => {
+    const matches = Array.isArray(mentions[key]) ? mentions[key] : []
+    const meaningful = matches.filter((mm) => (mm?.rel?.followsMe || mm?.rel?.following) && !mm?.rel?.blocking && String(mm?.feed || "") !== myId && !mm?.rel?.me)
+    mentions[key] = meaningful.length > 0 ? meaningful : matches
+  })
 
-    text = text.replace(rex, replacer);
+  const rexReplace = /(^|\s)(?!\[)@([a-zA-Z0-9\-/.=+]{3,})(?:\s+([a-zA-Z0-9][a-zA-Z0-9\-/.=+]{1,}))?(?:\s+([a-zA-Z0-9][a-zA-Z0-9\-/.=+]{1,}))?\b/g
 
-    const blobMarkdown = await handleBlobUpload(ctx, "blob");
-    if (blobMarkdown) {
-        text += blobMarkdown;
+  const replacer = (match, prefix, w1, w2, w3) => {
+    const phrase1 = w1
+    const phrase2 = w2 ? `${w1} ${w2}` : null
+    const phrase3 = w3 ? `${w1} ${w2 ? w2 : ""} ${w3}`.replace(/\s+/g, " ").trim() : null
+
+    const tryKey = (k) => {
+      const arr = mentions[k]
+      if (arr && arr.length === 1) {
+        return `${prefix}[@${arr[0].name}](${ensureAt(arr[0].feed)})`
+      }
+      return null
     }
 
-    const ssbClient = await cooler.open();
-    const authorMeta = {
-        id: ssbClient.id,
-        name: await about.name(ssbClient.id),
-        image: await about.image(ssbClient.id),
-    };
+    if (/\.ed25519$/.test(w1)) {
+      const arr = mentions[w1]
+      if (arr && arr.length === 1) return `${prefix}[@${arr[0].name}](${ensureAt(arr[0].feed)})`
+      return match
+    }
 
-    const renderedText = await renderBlobMarkdown(
-        text,
-        mentions,
-        authorMeta.id,
-        authorMeta.name
-    );
+    const r3 = phrase3 ? tryKey(phrase3) : null
+    if (r3) return r3
+    const r2 = phrase2 ? tryKey(phrase2) : null
+    if (r2) return r2
+    const r1 = tryKey(phrase1)
+    if (r1) return r1
+    return match
+  }
 
-    const hasBrTags = /<br\s*\/?>/i.test(renderedText);
-    const hasBlockTags = /<(p|div|ul|ol|li|pre|blockquote|h[1-6]|table|tr|td|th|section|article)\b/i.test(renderedText);
+  text = text.replace(rexReplace, replacer)
 
-    let formattedText = renderedText;
-    if (!hasBrTags && !hasBlockTags && /[\r\n]/.test(renderedText)) {
-        formattedText = renderedText.replace(/\r\n|\r|\n/g, "<br>");
-    }
+  const blobMarkdown = await handleBlobUpload(ctx, "blob")
+  if (blobMarkdown) {
+    text += blobMarkdown
+  }
 
-    return { authorMeta, text, formattedText, mentions, contentWarning };
-};
+  const renderedText = await renderBlobMarkdown(
+    text,
+    mentions,
+    authorMeta.id,
+    authorMeta.name
+  )
+
+  const hasBrTags = /<br\s*\/?>/i.test(renderedText)
+  const hasBlockTags = /<(p|div|ul|ol|li|pre|blockquote|h[1-6]|table|tr|td|th|section|article)\b/i.test(renderedText)
+
+  let formattedText = renderedText
+  if (!hasBrTags && !hasBlockTags && /[\r\n]/.test(renderedText)) {
+    formattedText = renderedText.replace(/\r\n|\r|\n/g, "<br>")
+  }
+
+  return { authorMeta, text, formattedText, mentions, contentWarning }
+}
 
 // set koaMiddleware maxSize: 50 MiB (voted by community at: 09/04/2025)
 const megabyte = Math.pow(2, 20);

+ 38 - 7
src/models/feed_model.js

@@ -3,6 +3,9 @@ const { getConfig } = require("../configs/config-manager.js");
 const categories = require("../backend/opinion_categories");
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
+const FEED_TEXT_MIN = Number(getConfig().feed?.minLength ?? 1);
+const FEED_TEXT_MAX = Number(getConfig().feed?.maxLength ?? 280);
+
 module.exports = ({ cooler }) => {
   let ssb;
   const openSsb = async () => {
@@ -10,6 +13,13 @@ module.exports = ({ cooler }) => {
     return ssb;
   };
 
+  const cleanText = (t) => (typeof t === "string" ? t.trim() : "");
+
+  const isValidFeedText = (t) => {
+    const s = cleanText(t);
+    return s.length >= FEED_TEXT_MIN && s.length <= FEED_TEXT_MAX;
+  };
+
   const getMsg = (ssbClient, id) =>
     new Promise((resolve, reject) => {
       ssbClient.get(id, (err, val) => (err ? reject(err) : resolve({ key: id, value: val })));
@@ -90,9 +100,13 @@ module.exports = ({ cooler }) => {
     const userId = ssbClient.id;
 
     if (typeof text !== "string") throw new Error("Invalid text");
-    const cleaned = text.trim();
-    if (!cleaned) throw new Error("Text required");
-    if (cleaned.length > 280) throw new Error("Text too long");
+    const cleaned = cleanText(text);
+
+    if (!isValidFeedText(cleaned)) {
+      if (cleaned.length < FEED_TEXT_MIN) throw new Error("Text too short");
+      if (cleaned.length > FEED_TEXT_MAX) throw new Error("Text too long");
+      throw new Error("Text required");
+    }
 
     const content = {
       type: "feed",
@@ -123,6 +137,7 @@ module.exports = ({ cooler }) => {
 
     const c = msg?.value?.content;
     if (!c || c.type !== "feed") throw new Error("Invalid feed");
+    if (!isValidFeedText(c.text)) throw new Error("Invalid feed");
 
     const existing = idx.actionsByRoot.get(tipId) || [];
     for (const a of existing) {
@@ -161,6 +176,7 @@ module.exports = ({ cooler }) => {
 
     const c = msg?.value?.content;
     if (!c || c.type !== "feed") throw new Error("Invalid feed");
+    if (!isValidFeedText(c.text)) throw new Error("Invalid feed");
 
     const existing = idx.actionsByRoot.get(tipId) || [];
     for (const a of existing) {
@@ -194,7 +210,17 @@ module.exports = ({ cooler }) => {
 
     const idx = await buildIndex(ssbClient);
 
-    let tips = Array.from(idx.feedsById.values()).filter((m) => !idx.replacedIds.has(m.key) && !idx.tombstoned.has(m.key));
+    const isValidFeedMsg = (m) => {
+      const c = m?.value?.content;
+      return !!c && c.type === "feed" && isValidFeedText(c.text);
+    };
+
+    let tips = Array.from(idx.feedsById.values()).filter(
+      (m) =>
+        !idx.replacedIds.has(m.key) &&
+        !idx.tombstoned.has(m.key) &&
+        isValidFeedMsg(m)
+    );
 
     const textEditedEver = (m) => {
       const seen = new Set();
@@ -267,10 +293,10 @@ module.exports = ({ cooler }) => {
     let feeds = tips.map(materialize);
 
     if (q) {
-      const terms = q.split(/\s+/).map(s => s.trim()).filter(Boolean);
+      const terms = q.split(/\s+/).map((s) => s.trim()).filter(Boolean);
       feeds = feeds.filter((m) => {
         const t = String(m.value?.content?.text || "").toLowerCase();
-        return terms.every(term => t.includes(term));
+        return terms.every((term) => t.includes(term));
       });
     }
     if (tag) feeds = feeds.filter((m) => Array.isArray(m.value?.content?.tags) && m.value.content.tags.includes(tag));
@@ -285,7 +311,12 @@ module.exports = ({ cooler }) => {
     }
 
     if (filter === "TOP") {
-      feeds.sort((a, b) => totalVotes(b) - totalVotes(a) || (b.value?.content?.refeeds || 0) - (a.value?.content?.refeeds || 0) || getTs(b) - getTs(a));
+      feeds.sort(
+        (a, b) =>
+          totalVotes(b) - totalVotes(a) ||
+          (b.value?.content?.refeeds || 0) - (a.value?.content?.refeeds || 0) ||
+          getTs(b) - getTs(a)
+      );
     } else {
       feeds.sort((a, b) => getTs(b) - getTs(a));
     }

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

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

+ 1 - 1
src/server/package.json

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

+ 18 - 2
src/views/activity_view.js

@@ -2,6 +2,19 @@ const { div, h2, p, section, button, form, a, input, img, textarea, br, span, vi
 const { template, i18n } = require('./main_views');
 const moment = require("../server/node_modules/moment");
 const { renderUrl } = require('../backend/renderUrl');
+const { getConfig } = require("../configs/config-manager.js");
+
+const FEED_TEXT_MIN = Number(getConfig().feed?.minLength ?? 1);
+const FEED_TEXT_MAX = Number(getConfig().feed?.maxLength ?? 280);
+
+function cleanFeedText(t) {
+  return typeof t === 'string' ? t.trim() : '';
+}
+
+function isValidFeedText(t) {
+  const s = cleanFeedText(t);
+  return s.length >= FEED_TEXT_MIN && s.length <= FEED_TEXT_MAX;
+}
 
 function capitalize(str) {
   return typeof str === 'string' && str.length ? str[0].toUpperCase() + str.slice(1) : '';
@@ -151,6 +164,7 @@ function renderActionCards(actions, userId) {
       if (typeof content.type === 'string' && content.type.startsWith('tribe') && content.isAnonymous === true) return false;
       if (content.type === 'task' && content.isPublic === "PRIVATE") return false;
       if (content.type === 'event' && content.isPublic === "private") return false;
+      if ((content.type === 'feed' || action.type === 'feed') && !isValidFeedText(content.text)) return false;
       if (content.type === 'market') {
         if (content.stock === 0 && content.status !== 'SOLD') {
           return false;
@@ -560,9 +574,11 @@ function renderActionCards(actions, userId) {
     if (type === 'feed') {
       const { renderTextWithStyles } = require('../backend/renderTextWithStyles');
       const { text, refeeds } = content;
+      if (!isValidFeedText(text)) return null;
+      const safeText = cleanFeedText(text);
       cardBody.push(
         div({ class: 'card-section feed' },
-          div({ class: 'feed-text', innerHTML: renderTextWithStyles(text) }),
+          div({ class: 'feed-text', innerHTML: renderTextWithStyles(safeText) }),
           h2({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeFeedRefeeds + ': '), span({ class: 'card-label' }, refeeds))
         )
       );
@@ -609,7 +625,7 @@ function renderActionCards(actions, userId) {
         const root = c.root;
         const replies = Array.isArray(c.replies) ? c.replies : [];
         const repliesAsc = replies.slice().sort((a, b) => (a.ts || 0) - (b.ts || 0));
-        const limit = 5; // max posts when threading
+        const limit = 5;
         const overflow = repliesAsc.length > limit;
         const show = repliesAsc.slice(Math.max(0, repliesAsc.length - limit));
         const lastId = repliesAsc.length ? repliesAsc[repliesAsc.length - 1].id : threadId;

+ 38 - 15
src/views/feed_view.js

@@ -4,6 +4,9 @@ const { config } = require("../server/SSB_server.js");
 const { renderTextWithStyles } = require("../backend/renderTextWithStyles");
 const opinionCategories = require("../backend/opinion_categories");
 
+const FEED_TEXT_MIN = Number(config?.feed?.minLength ?? 1);
+const FEED_TEXT_MAX = Number(config?.feed?.maxLength ?? 280);
+
 const normalizeOptions = (opts) => {
   if (typeof opts === "string") return { filter: String(opts || "ALL").toUpperCase(), q: "", tag: "" };
   if (!opts || typeof opts !== "object") return { filter: "ALL", q: "", tag: "" };
@@ -62,6 +65,10 @@ const renderTagChips = (tags = []) => {
 
 const renderFeedCard = (feed) => {
   const content = feed.value.content || {};
+  const rawText = typeof content.text === "string" ? content.text : "";
+  const safeText = rawText.trim();
+  if (!safeText) return null;
+
   const voteEntries = Object.entries(content.opinions || {});
   const totalCount = voteEntries.reduce((sum, [, count]) => sum + (Number(count) || 0), 0);
   const createdAt = formatDate(feed);
@@ -70,7 +77,7 @@ const renderFeedCard = (feed) => {
   const alreadyRefeeded = Array.isArray(content.refeeds_inhabitants) && me ? content.refeeds_inhabitants.includes(me) : false;
   const alreadyVoted = Array.isArray(content.opinions_inhabitants) && me ? content.opinions_inhabitants.includes(me) : false;
 
-  const tags = Array.isArray(content.tags) && content.tags.length ? content.tags : extractTags(content.text);
+  const tags = Array.isArray(content.tags) && content.tags.length ? content.tags : extractTags(safeText);
 
   const authorId = content.author || feed.value.author || "";
 
@@ -89,7 +96,7 @@ const renderFeedCard = (feed) => {
       ),
       div(
         { class: "feed-main" },
-        div({ class: "feed-text", innerHTML: renderTextWithStyles(content.text || "") }),
+        div({ class: "feed-text", innerHTML: renderTextWithStyles(safeText) }),
         renderTagChips(tags),
         h2(`${i18n.totalOpinions}: ${totalCount}`),
         p(
@@ -151,26 +158,34 @@ exports.feedView = (feeds, opts = "ALL") => {
         ...generateFilterButtons(["ALL", "MINE", "TODAY", "TOP"], filter, "/feed", extra),
         form({ method: "GET", action: "/feed/create" }, button({ type: "submit", class: "create-button filter-btn" }, i18n.createFeedTitle || "Create Feed"))
       ),
-	div(
-	  { class: "feed-tools-row" },
-	  form(
-	    { method: "GET", action: "/feed", class: "feed-search-form" },
-	    input({ type: "hidden", name: "filter", value: filter }),
-	    tag ? input({ type: "hidden", name: "tag", value: tag }) : null,
-	    input({ type: "text", name: "q", value: q, placeholder: i18n.searchPlaceholder || "Search", class: "feed-search-input" }),
-	    button({ type: "submit", class: "filter-btn feed-search-btn" }, i18n.searchButton || "Search")
-	  )
-	),
+      div(
+        { class: "feed-tools-row" },
+        form(
+          { method: "GET", action: "/feed", class: "feed-search-form" },
+          input({ type: "hidden", name: "filter", value: filter }),
+          tag ? input({ type: "hidden", name: "tag", value: tag }) : null,
+          input({ type: "text", name: "q", value: q, placeholder: i18n.searchPlaceholder || "Search", class: "feed-search-input" }),
+          button({ type: "submit", class: "filter-btn feed-search-btn" }, i18n.searchButton || "Search")
+        )
+      ),
       section(
         filter === "CREATE"
           ? form(
               { method: "POST", action: "/feed/create" },
-              textarea({ name: "text", placeholder: i18n.feedPlaceholder, maxlength: 280, rows: 4, cols: 50 }),
+              textarea({
+                name: "text",
+                placeholder: i18n.feedPlaceholder,
+                required: true,
+                minlength: String(FEED_TEXT_MIN),
+                maxlength: String(FEED_TEXT_MAX),
+                rows: 4,
+                cols: 50
+              }),
               br(),
               button({ type: "submit", class: "create-button" }, i18n.createFeedButton)
             )
           : feeds && feeds.length > 0
-            ? div({ class: "feed-container" }, feeds.map((feed) => renderFeedCard(feed)))
+            ? div({ class: "feed-container" }, feeds.map((feed) => renderFeedCard(feed)).filter(Boolean))
             : div({ class: "no-results" }, p(i18n.noFeedsFound))
       )
     )
@@ -187,7 +202,15 @@ exports.feedCreateView = (opts = {}) => {
       div({ class: "mode-buttons-row" }, ...generateFilterButtons(["ALL", "MINE", "TODAY", "TOP"], "CREATE", "/feed", { q, tag })),
       form(
         { method: "POST", action: "/feed/create" },
-        textarea({ name: "text", maxlength: "280", rows: 5, cols: 50, placeholder: i18n.feedPlaceholder }),
+        textarea({
+          name: "text",
+          required: true,
+          minlength: String(FEED_TEXT_MIN),
+          maxlength: String(FEED_TEXT_MAX),
+          rows: 5,
+          cols: 50,
+          placeholder: i18n.feedPlaceholder
+        }),
         br(),
         button({ type: "submit", class: "create-button" }, i18n.createFeedButton || "Send Feed!")
       )

+ 166 - 90
src/views/main_views.js

@@ -2251,42 +2251,150 @@ exports.publishView = (preview, text, contentWarning) => {
   );
 };
 
-const generatePreview = ({ previewData, contentWarning, action }) => {
-  const { authorMeta, formattedText, mentions } = previewData;
-  const renderedText = formattedText;
-  const msg = {
-    key: "%non-existent.preview",
-    value: {
-      author: authorMeta.id,
-      content: {
-        type: "post",
-        text: renderedText,
-        mentions: mentions,
-      },
-      timestamp: Date.now(),
-      meta: {
-        isPrivate: false,
-        votes: [],
-        author: {
-          name: authorMeta.name,
-          avatar: {
-            url: `http://localhost:3000/blob/${encodeURIComponent(authorMeta.image)}`,
-          },
-        },
-      },
-    },
-  };
-  if (contentWarning) {
-    msg.value.content.contentWarning = contentWarning;
-  }
-  if (msg.value.meta.author.avatar.url === 'http://localhost:3000/blob/%260000000000000000000000000000000000000000000%3D.sha256') {
-    msg.value.meta.author.avatar.url = '/assets/images/default-avatar.png';
+//generate preview
+const ensureAt = (id) => {
+  const s = String(id || "").trim()
+  if (!s) return ""
+  return s.startsWith("@") ? s : `@${s.replace(/^@+/, "")}`
+}
+
+const stripAt = (id) => String(id || "").trim().replace(/^@+/, "")
+
+const authorHref = (feed) => `/author/${encodeURIComponent(ensureAt(feed))}`
+
+const escapeRegex = (s) => String(s || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
+
+const escapeHtml = (s) => {
+  return String(s || "")
+    .replace(/&/g, "&amp;")
+    .replace(/</g, "&lt;")
+    .replace(/>/g, "&gt;")
+    .replace(/"/g, "&quot;")
+    .replace(/'/g, "&#39;")
+}
+
+const normalizeMentionLinks = (text) => {
+  let t = String(text || "")
+  t = t.replace(
+    /\[@([^\]]+)\]\s*\(\s*@?([^) \t\r\n]+\.ed25519)\s*\)/g,
+    (_m, label, feed) => `[@${String(label || "").replace(/^@+/, "")}](@${String(feed || "").replace(/^@+/, "")})`
+  )
+  return t
+}
+
+const injectResolvedMentions = (text, mentions) => {
+  let out = String(text || "")
+  const obj = mentions && typeof mentions === "object" ? mentions : {}
+
+  const entries = Object.entries(obj)
+    .map(([k, v]) => [String(k || "").trim().replace(/\s+/g, " "), Array.isArray(v) ? v : []])
+    .filter(([k, v]) => k && v.length === 1)
+
+  entries.sort((a, b) => b[0].length - a[0].length)
+
+  for (const [token, list] of entries) {
+    const m = list[0] || {}
+    const feed = ensureAt(m.feed || m.link || m.id || "")
+    if (!feed) continue
+
+    const label = String(m.name || token).replace(/^@+/, "")
+    const parts = token.split(/\s+/).filter(Boolean).map(escapeRegex)
+    if (!parts.length) continue
+
+    const tokenPattern = parts.join("\\s+")
+    const re = new RegExp(`(^|\\s)(?!\\[)@${tokenPattern}(?=\\b|$)`, "g")
+    out = out.replace(re, (match, prefix) => `${prefix}[@${label}](${feed})`)
   }
-  const ts = new Date(msg.value.timestamp);
-  lodash.set(msg, "value.meta.timestamp.received.iso8601", ts.toISOString());
-  const ago = Date.now() - Number(ts);
-  const prettyAgo = prettyMs(ago, { compact: true });
-  lodash.set(msg, "value.meta.timestamp.received.since", prettyAgo);
+
+  return out
+}
+
+const markdownMentionsToHtml = (markdownText) => {
+  const escaped = escapeHtml(String(markdownText || ""))
+  const withBr = escaped.replace(/\r\n|\r|\n/g, "<br>")
+
+  const withImages = withBr.replace(
+    /!\[([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g,
+    (_m, alt, blob) => `<img src="/blob/${encodeURIComponent(blob)}" alt="${escapeHtml(alt)}">`
+  )
+
+  const withMentions = withImages.replace(
+    /\[@([^\]]+)\]\(\s*@?([^) \t\r\n]+\.ed25519)\s*\)/g,
+    (_m, label, feed) => {
+      const href = authorHref(feed)
+      const shown = `@${String(label || "").replace(/^@+/, "")}`
+      return `<a class="mention" href="${href}">${escapeHtml(shown)}</a>`
+    }
+  )
+
+  const withLinks = withMentions.replace(
+    /(https?:\/\/[^\s<]+)/g,
+    (u) => `<a href="${u}" target="_blank" rel="noopener noreferrer">${u}</a>`
+  )
+
+  return withLinks
+}
+
+const generatePreview = ({ previewData, contentWarning, action }) => {
+  const mentions =
+    previewData && previewData.mentions && typeof previewData.mentions === "object"
+      ? previewData.mentions
+      : {}
+
+  const rawText = String((previewData && previewData.text) || "")
+  const normalized = normalizeMentionLinks(rawText)
+  const injected = injectResolvedMentions(normalized, mentions)
+  const publishText = normalizeMentionLinks(injected)
+
+  const previewHtml = markdownMentionsToHtml(publishText)
+
+  const mentionCards = Object.entries(mentions)
+    .map(([_token, matches]) => {
+      const list = Array.isArray(matches) ? matches : []
+      const first = list.find((x) => x && (x.feed || x.link || x.id)) || null
+      if (!first) return null
+
+      const feed = ensureAt(first.feed || first.link || first.id || "")
+      if (!feed) return null
+
+      const nameRaw = String(first.name || stripAt(feed) || "")
+      const nameText = nameRaw.startsWith("@") ? nameRaw : `@${nameRaw}`
+
+      const rel = first.rel || {}
+      const relText = rel.followsMe ? i18n.relationshipMutuals : i18n.relationshipNotMutuals
+      const emoji = rel.followsMe ? "☍" : "⚼"
+
+      const avatar = first.img || first.image || ""
+      const avatarUrl =
+        typeof avatar === "string" && avatar.startsWith("&")
+          ? `/blob/${encodeURIComponent(avatar)}`
+          : (typeof avatar === "string" && avatar ? avatar : "/assets/images/default-avatar.png")
+
+      return div(
+        { class: "mention-card" },
+        a({ href: authorHref(feed) }, img({ src: avatarUrl, class: "avatar-profile" })),
+        br,
+        div(
+          { class: "mention-name" },
+          span({ class: "label" }, `${i18n.mentionsName}: `),
+          a({ href: authorHref(feed) }, nameText)
+        ),
+        div(
+          { class: "mention-relationship" },
+          span({ class: "label" }, `${i18n.mentionsRelationship}:`),
+          span({ class: "relationship" }, relText),
+          div(
+            { class: "mention-relationship-details" },
+            span({ class: "emoji" }, emoji),
+            span(
+              { class: "mentions-listing" },
+              a({ class: "user-link", href: authorHref(feed) }, `@${stripAt(feed)}`)
+            )
+          )
+        )
+      )
+    })
+    .filter(Boolean)
 
   return div(
     section(
@@ -2294,80 +2402,48 @@ const generatePreview = ({ previewData, contentWarning, action }) => {
       div(
         { class: "preview-content" },
         h2(i18n.messagePreview),
-        post({ msg, preview: true })
-      ),
+        contentWarning ? div({ class: "content-warning-preview" }, escapeHtml(contentWarning)) : null,
+        div({ class: "preview-rendered", innerHTML: previewHtml })
+      )
     ),
     section(
       { class: "mention-suggestions" },
-      Object.keys(mentions).map((name) => {
-        const matches = mentions[name];
-        return div(
-          h2(i18n.mentionsMatching),
-          { class: "mention-card" },
-          a(
-            {
-              href: `/author/@${encodeURIComponent(matches[0].feed)}`,
-            },
-            img({ src: msg.value.meta.author.avatar.url, class: "avatar-profile" })
-          ),
-          br,
-          div(
-            { class: "mention-name" },
-            span({ class: "label" }, `${i18n.mentionsName}: `),
-            a(
-              {
-                href: `/author/@${encodeURIComponent(matches[0].feed)}`,
-              },
-              `@${authorMeta.name}`
-            )
-          ),
-          div(
-            { class: "mention-relationship" },
-            span({ class: "label" }, `${i18n.mentionsRelationship}:`),
-            span({ class: "relationship" }, matches[0].rel.followsMe ? i18n.relationshipMutuals : i18n.relationshipNotMutuals),
-            { class: "mention-relationship-details" },
-            span({ class: "emoji" }, matches[0].rel.followsMe ? "☍" : "⚼"),
-            span({ class: "mentions-listing" },
-              a({ class: 'user-link', href: `/author/@${encodeURIComponent(matches[0].feed)}` }, `@${matches[0].feed}`)
-            )
-          )
-        );
-      })
+      mentionCards.length ? h2(i18n.mentionsMatching) : null,
+      ...mentionCards
     ),
     section(
       form(
         { action, method: "post" },
-        [
-          input({ type: "hidden", name: "text", value: renderedText }),
-          input({ type: "hidden", name: "contentWarning", value: contentWarning || "" }),
-          input({ type: "hidden", name: "mentions", value: JSON.stringify(mentions) }),
-          button({ type: "submit" }, i18n.publish)
-        ]
+        input({ type: "hidden", name: "text", value: publishText }),
+        input({ type: "hidden", name: "contentWarning", value: contentWarning || "" }),
+        input({ type: "hidden", name: "mentions", value: JSON.stringify(mentions) }),
+        button({ type: "submit" }, i18n.publish)
       )
     )
-  );
-};
+  )
+}
 
 exports.previewView = ({ previewData, contentWarning }) => {
-    const publishAction = "/publish";
-    const preview = generatePreview({
-        previewData,
-        contentWarning,
-        action: publishAction,
-    });
-    return exports.publishView(preview, previewData.text || "", contentWarning);
-};
+  const publishAction = "/publish"
+  const preview = generatePreview({
+    previewData,
+    contentWarning,
+    action: publishAction,
+  })
+  return exports.publishView(preview, (previewData && previewData.text) || "", contentWarning)
+}
 
 const viewInfoBox = ({ viewTitle = null, viewDescription = null }) => {
   if (!viewTitle && !viewDescription) {
-    return null;
+    return null
   }
   return section(
     { class: "viewInfo" },
     viewTitle ? h1(viewTitle) : null,
     viewDescription ? em(viewDescription) : null
-  );
-};
+  )
+}
+//generate preview
 
 exports.likesView = async ({ messages, feed, name }) => {
   const authorLink = a(

+ 41 - 28
src/views/market_view.js

@@ -474,6 +474,44 @@ exports.marketView = async (items, filter, itemToEdit = null, params = {}) => {
                   const parsedBids = polls.map(parseBidEntry).filter(Boolean).sort((a, b) => new Date(b.time) - new Date(a.time))
                   const myBid = item.item_type === "auction" ? parsedBids.some((b) => b.bidder === userId) : false
                   const maxStock = item.initialStock || item.stockMax || item.stock || 1
+                  const stockLeft = Number(item.stock || 0)
+                  const isOwner = String(item.seller) === String(userId)
+
+                  const actionNodesRaw = isOwner
+                    ? renderMarketOwnerActions(item, "/market?filter=mine")
+                    : [
+                        item.status !== "SOLD" && item.status !== "DISCARDED" && item.item_type === "auction"
+                          ? form(
+                              { method: "POST", action: `/market/bid/${encodeURIComponent(item.id)}` },
+                              input({ type: "hidden", name: "returnTo", value: returnTo }),
+                              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" && !isOwner && stockLeft > 0
+                          ? form(
+                              { method: "POST", action: `/market/buy/${encodeURIComponent(item.id)}` },
+                              input({ type: "hidden", name: "returnTo", value: "/inbox?filter=sent" }),
+                              input({ type: "hidden", name: "buyerId", value: userId }),
+                              button({ class: "buy-btn", type: "submit" }, i18n.marketActionsBuy)
+                            )
+                          : null
+                      ].filter(Boolean)
+
+                  const actionNodes = Array.isArray(actionNodesRaw) ? actionNodesRaw.filter(Boolean) : []
+                  const buttonsBlock =
+                    actionNodes.length > 0
+                      ? div(
+                          { class: "market-card buttons" },
+                          div({ style: "display:flex;gap:8px;flex-wrap:wrap;align-items:center;" }, ...actionNodes)
+                        )
+                      : stockLeft <= 0
+                        ? div(
+                            { class: "market-card buttons" },
+                            div({ class: "card-field" }, span({ class: "card-value" }, i18n.marketOutOfStock))
+                          )
+                        : null
 
                   return div(
                     { class: "market-item" },
@@ -558,33 +596,7 @@ exports.marketView = async (items, filter, itemToEdit = null, params = {}) => {
                           button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton)
                         )
                       ),
-                      div(
-                        { class: "market-card buttons" },
-                        div(
-                          { style: "display:flex;gap:8px;flex-wrap:wrap;align-items:center;" },
-                          String(item.seller) === String(userId)
-                            ? renderMarketOwnerActions(item, "/market?filter=mine")
-                            : [
-                                item.status !== "SOLD" && item.status !== "DISCARDED" && item.item_type === "auction"
-                                  ? form(
-                                      { method: "POST", action: `/market/bid/${encodeURIComponent(item.id)}` },
-                                      input({ type: "hidden", name: "returnTo", value: returnTo }),
-                                      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" && String(item.seller) !== String(userId)
-                                  ? form(
-                                      { method: "POST", action: `/market/buy/${encodeURIComponent(item.id)}` },
-                                      input({ type: "hidden", name: "returnTo", value: "/inbox?filter=sent" }),
-                                      input({ type: "hidden", name: "buyerId", value: userId }),
-                                      button({ class: "buy-btn", type: "submit" }, i18n.marketActionsBuy)
-                                    )
-                                  : null
-                              ].filter(Boolean)
-                        )
-                      )
+                      buttonsBlock
                     )
                   )
                 })
@@ -603,7 +615,8 @@ exports.singleMarketView = async (item, filter, comments = [], params = {}) => {
   const sort = params.sort || "recent"
   const returnTo = params.returnTo || buildReturnTo(filter, q, minPrice, maxPrice, sort)
   const topbar = renderMarketTopbar(item, returnTo)
-  const showBuy = item.status === "FOR SALE" && item.item_type !== "auction" && String(item.seller) !== String(userId)
+  const stockLeft = Number(item.stock || 0)
+  const showBuy = item.status === "FOR SALE" && item.item_type !== "auction" && String(item.seller) !== String(userId) && stockLeft > 0
   const maxStock = item.initialStock || item.stockMax || item.stock || 1
 
   return template(