Browse Source

Oasis release 0.5.8

psy 1 day ago
parent
commit
e301f91b74

+ 9 - 1
docs/CHANGELOG.md

@@ -13,7 +13,15 @@ All notable changes to this project will be documented in this file.
 ### Security
 -->
 
-## v0.5.7 - 2025-11-25
+## v0.5.8 - 2025-11-25
+
+### Fixed
+
+ + Fixed post preview from a pre-cached context (Core plugin).
+ + Fixed tasks assignement to others different to the author (Core plugin).
+ + Fixed comments context adding different to blog/post (Core plugin).
+
+## v0.5.7 - 2025-11-24
 
 ### Added
 

+ 77 - 62
src/backend/backend.js

@@ -362,7 +362,6 @@ async function renderBlobMarkdown(text, mentions = {}, myFeedId, myUsername) {
   return text;
 }
 
-let formattedTextCache = null; 
 const ADDR_PATH = path.join(__dirname, "..", "configs", "wallet-addresses.json");
 function readAddrMap() {
   try { return JSON.parse(fs.readFileSync(ADDR_PATH, "utf8")); } catch { return {}; }
@@ -373,68 +372,84 @@ function writeAddrMap(map) {
 }
 
 const preparePreview = async function (ctx) {
-  let text = String(ctx.request.body.text || "");
-  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 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);
+            }
+        }
+
+        if (found.length > 0) {
+            mentions[key] = found;
+        }
     }
-    if (found.length > 0) {
-      mentions[key] = found;
+
+    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;
+    });
+
+    const replacer = (match, prefix, token) => {
+        const matches = mentions[token];
+        if (matches && matches.length === 1) {
+            return `${prefix}[@${matches[0].name}](${matches[0].feed})`;
+        }
+        return match;
+    };
+
+    text = text.replace(rex, replacer);
+
+    const blobMarkdown = await handleBlobUpload(ctx, "blob");
+    if (blobMarkdown) {
+        text += blobMarkdown;
     }
-  }
-  Object.keys(mentions).forEach((key) => {
-    let matches = mentions[key];
-    const meaningful = matches.filter((m) => (m.rel?.followsMe || m.rel?.following) && !m.rel?.blocking);
-    mentions[key] = meaningful.length > 0 ? meaningful : matches;
-  });
-  const replacer = (match, prefix, token) => {
-    const matches = mentions[token];
-    if (matches && matches.length === 1) {
-      return `${prefix}[@${matches[0].name}](${matches[0].feed})`;
-    }
-    return match;
-  };
-  text = text.replace(rex, replacer);
-  const blobMarkdown = await handleBlobUpload(ctx, "blob");
-  if (blobMarkdown) {
-    text += blobMarkdown;
-  }
-  const ssbClient = await cooler.open();
-  const authorMeta = {
-    id: ssbClient.id,
-    name: await about.name(ssbClient.id),
-    image: await about.image(ssbClient.id),
-  };
-  const renderedText = await renderBlobMarkdown(text, mentions, authorMeta.id, authorMeta.name);
-  const hasBrTags = /<br\s*\/?>/.test(renderedText);
-  const formattedText = formattedTextCache || (!hasBrTags ? renderedText.replace(/\n/g, '<br>') : renderedText);
-  if (!formattedTextCache && !hasBrTags) {
-    formattedTextCache = formattedText;
-  }
-  const contentWarning = ctx.request.body.contentWarning || '';
-  let finalContent = formattedText;
-  if (contentWarning && !finalContent.startsWith(contentWarning)) {
-    finalContent = `<br>${finalContent}`;
-  }
-  return { authorMeta, text: renderedText, formattedText: finalContent, mentions };
+
+    const ssbClient = await cooler.open();
+    const authorMeta = {
+        id: ssbClient.id,
+        name: await about.name(ssbClient.id),
+        image: await about.image(ssbClient.id),
+    };
+
+    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)
@@ -898,7 +913,7 @@ router
     const imageId = ctx.params.imageId;
     const filter = ctx.query.filter || 'all'; 
     const image = await imagesModel.getImageById(imageId);
-    const comments = await getVoteComments(imageId); // o getImageComments(imageId)
+    const comments = await getVoteComments(imageId);
     const imageWithCount = { ...image, commentCount: comments.length };
     ctx.body = await singleImageView(imageWithCount, filter, comments);
    })

+ 56 - 3
src/models/activity_model.js

@@ -76,6 +76,45 @@ module.exports = ({ cooler }) => {
         if (type !== 'project') {
           const tip = arr.reduce((best, a) => (a.ts > best.ts ? a : best), arr[0]);
           for (const a of arr) idToTipId.set(a.id, tip.id);
+
+          if (type === 'task' && tip && tip.content && tip.content.isPublic !== 'PRIVATE') {
+            const uniq = (xs) => Array.from(new Set((Array.isArray(xs) ? xs : []).filter(x => typeof x === 'string' && x.trim().length)));
+            const sorted = arr
+              .filter(a => a.type === 'task' && a.content && typeof a.content === 'object')
+              .sort((a, b) => (a.ts || 0) - (b.ts || 0));
+
+            let prev = null;
+
+            for (const ev of sorted) {
+              const cur = uniq(ev.content.assignees);
+              if (prev) {
+                const prevSet = new Set(prev);
+                const curSet = new Set(cur);
+                const added = cur.filter(x => !prevSet.has(x));
+                const removed = prev.filter(x => !curSet.has(x));
+
+                if (added.length || removed.length) {
+                  const overlayId = `${ev.id}:assignees:${added.join(',')}:${removed.join(',')}`;
+                  idToAction.set(overlayId, {
+                    id: overlayId,
+                    author: ev.author,
+                    ts: ev.ts,
+                    type: 'taskAssignment',
+                    content: {
+                      taskId: tip.id,
+                      title: tip.content.title || ev.content.title || '',
+                      added,
+                      removed,
+                      isPublic: tip.content.isPublic
+                    }
+                  });
+                  idToTipId.set(overlayId, overlayId);
+                }
+              }
+              prev = cur;
+            }
+          }
+
           continue;
         }
 
@@ -147,12 +186,14 @@ module.exports = ({ cooler }) => {
       const perAuthorUnique = new Set(['karmaScore']);
       const byKey = new Map();
       const norm = s => String(s || '').trim().toLowerCase();
+
       for (const a of deduped) {
         const c = a.content || {};
         const effTs =
           (c.updatedAt && Date.parse(c.updatedAt)) ||
           (c.createdAt && Date.parse(c.createdAt)) ||
           (a.ts || 0);
+
         if (mediaTypes.has(a.type)) {
           const u = c.url || c.title || `${a.type}:${a.id}`;
           const key = `${a.type}:${u}`;
@@ -166,7 +207,17 @@ module.exports = ({ cooler }) => {
           const target = c.about || a.author;
           const key = `about:${target}`;
           const prev = byKey.get(key);
-          if (!prev || effTs > prev.__effTs) byKey.set(key, { ...a, __effTs: effTs });
+          const prevContent = prev && (prev.content || {});
+          const prevHasImage = !!(prevContent && prevContent.image);
+          const newHasImage = !!c.image;
+
+          if (!prev) {
+            byKey.set(key, { ...a, __effTs: effTs, __hasImage: newHasImage });
+          } else if (!prevHasImage && newHasImage) {
+            byKey.set(key, { ...a, __effTs: effTs, __hasImage: newHasImage });
+          } else if (prevHasImage === newHasImage && effTs > prev.__effTs) {
+            byKey.set(key, { ...a, __effTs: effTs, __hasImage: newHasImage });
+          }
         } else if (a.type === 'tribe') {
           const t = norm(c.title);
           if (t) {
@@ -182,7 +233,8 @@ module.exports = ({ cooler }) => {
           byKey.set(key, { ...a, __effTs: effTs });
         }
       }
-      deduped = Array.from(byKey.values()).map(x => { delete x.__effTs; return x });
+
+      deduped = Array.from(byKey.values()).map(x => { delete x.__effTs; delete x.__hasImage; return x });
       let out;
       if (filter === 'mine') out = deduped.filter(a => a.author === userId);
       else if (filter === 'recent') { const cutoff = Date.now() - 24 * 60 * 60 * 1000; out = deduped.filter(a => (a.ts || 0) >= cutoff) }
@@ -198,8 +250,9 @@ module.exports = ({ cooler }) => {
           const t = String(a.type || '').toLowerCase();
           return t === 'courtscase' || t === 'courtsnomination' || t === 'courtsnominationvote';
         });
+      else if (filter === 'task')
+        out = deduped.filter(a => a.type === 'task' || a.type === 'taskAssignment');
       else out = deduped.filter(a => a.type === filter);
-
       out.sort((a, b) => (b.ts || 0) - (a.ts || 0));
       return out;
     }

+ 19 - 1
src/models/tasks_model.js

@@ -53,8 +53,25 @@ module.exports = ({ cooler }) => {
       );
       const c = old.content;
       if (c.type !== 'task') throw new Error('Invalid type');
-      if (c.author !== userId) throw new Error('Not the author');
+      const keys = Object.keys(updatedData || {}).filter(k => updatedData[k] !== undefined);
+      const assigneesOnly = keys.length === 1 && keys[0] === 'assignees';
+      const taskCreator = old.author || c.author;
+      if (!assigneesOnly && taskCreator !== userId) throw new Error('Not the author');
       if (c.status === 'CLOSED') throw new Error('Cannot edit a closed task');
+          const uniq = (arr) => Array.from(new Set((Array.isArray(arr) ? arr : []).filter(x => typeof x === 'string' && x.trim().length)));
+          let nextAssignees = Array.isArray(c.assignees) ? uniq(c.assignees) : [];
+      if (assigneesOnly) {
+        const proposed = uniq(updatedData.assignees);
+        const oldNoSelf = uniq(nextAssignees.filter(x => x !== userId)).sort();
+        const newNoSelf = uniq(proposed.filter(x => x !== userId)).sort();
+        if (oldNoSelf.length !== newNoSelf.length || oldNoSelf.some((v, i) => v !== newNoSelf[i])) {
+          throw new Error('Not allowed');
+        }
+        const hadSelf = nextAssignees.includes(userId);
+        const hasSelfNow = proposed.includes(userId);
+        if (hadSelf === hasSelfNow) throw new Error('Not allowed');
+        nextAssignees = proposed;
+      }
       let newStart = c.startTime;
       if (updatedData.startTime != null && updatedData.startTime !== '') {
         const m = moment(updatedData.startTime);
@@ -96,6 +113,7 @@ module.exports = ({ cooler }) => {
         tags: newTags,
         isPublic: newVisibility,
         status: updatedData.status ?? c.status,
+        assignees: assigneesOnly ? nextAssignees : (updatedData.assignees !== undefined ? uniq(updatedData.assignees) : nextAssignees),
         updatedAt: new Date().toISOString(),
         replaces: taskId
       };

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

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

+ 1 - 1
src/server/package.json

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

+ 33 - 0
src/views/activity_view.js

@@ -91,6 +91,8 @@ function renderActionCards(actions, userId) {
         .replace(/([a-z])([A-Z])/g, '$1 · $2');
       const finalSub = pretty || 'EVENT';
       headerText = `[COURTS · ${finalSub.toUpperCase()}]`;
+    } else if (type === 'taskAssignment') {
+      headerText = `[${String(i18n.typeTask || 'TASK').toUpperCase()} · ASSIGNMENT]`;
     } else {
       const typeLabel = i18n[`type${capitalize(type)}`] || type;
       headerText = `[${String(typeLabel).toUpperCase()}]`;
@@ -383,6 +385,34 @@ function renderActionCards(actions, userId) {
         )
       );
     }
+    
+    if (type === 'taskAssignment') {
+        const { title, added, removed } = content || {};
+        const addList = Array.isArray(added) ? added : [];
+        const remList = Array.isArray(removed) ? removed : [];
+        const renderUserList = (ids) =>
+            ids.map((id, i) => [i > 0 ? ', ' : '', a({ class: 'user-link', href: `/author/${encodeURIComponent(id)}` }, id)]).flat();
+        cardBody.push(
+            div({ class: 'card-section task' },
+                div({ class: 'card-field' },
+                    span({ class: 'card-label' }, i18n.title + ':'),
+                    span({ class: 'card-value' }, title || '')
+                ),
+                addList.length
+                    ? div({ class: 'card-field' },
+                        span({ class: 'card-label' }, (i18n.taskAssignedTo || 'Assigned to') + ':'),
+                        span({ class: 'card-value' }, ...renderUserList(addList))
+                    )
+                    : '',
+                remList.length
+                    ? div({ class: 'card-field' },
+                        span({ class: 'card-label' }, (i18n.taskUnassignedFrom || 'Unassigned from') + ':'),
+                        span({ class: 'card-value' }, ...renderUserList(remList))
+                    )
+                    : ''
+            )
+        );
+    }
 
     if (type === 'feed') {
       const { renderTextWithStyles } = require('../backend/renderTextWithStyles');
@@ -960,6 +990,7 @@ function getViewDetailsAction(type, action) {
     case 'bookmark':   return `/bookmarks/${id}`;
     case 'event':      return `/events/${id}`;
     case 'task':       return `/tasks/${id}`;
+    case 'taskAssignment': return `/tasks/${encodeURIComponent(action.content?.taskId || action.tipId || action.id)}`;
     case 'about':      return `/author/${encodeURIComponent(action.author)}`;
     case 'post':       return `/thread/${id}#${id}`;
     case 'vote':       return `/thread/${encodeURIComponent(action.content.vote.link)}#${encodeURIComponent(action.content.vote.link)}`;
@@ -1025,6 +1056,8 @@ exports.activityView = (actions, filter, userId) => {
       const t = String(action.type || '').toLowerCase();
       return t === 'courtscase' || t === 'courtsnomination' || t === 'courtsnominationvote';
     });
+  } else if (filter === 'task') {
+    filteredActions = actions.filter(action => action.type !== 'tombstone' && (action.type === 'task' || action.type === 'taskAssignment'));
   } else {
     filteredActions = actions.filter(action => (action.type === filter || filter === 'all') && action.type !== 'tombstone');
   }

+ 414 - 189
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, 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 { 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, video: videoHyperaxe, audio: audioHyperaxe } = require("../server/node_modules/hyperaxe");
 
 const lodash = require("../server/node_modules/lodash");
 const markdown = require("./markdown");
@@ -946,204 +946,420 @@ const postAside = ({ key, value }) => {
 };
 
 const post = ({ msg, aside = false, preview = false }) => {
-  const encoded = {
-    key: encodeURIComponent(msg.key),
-    author: encodeURIComponent(msg.value?.author),
-    parent: encodeURIComponent(msg.value?.content?.root),
-  };
-
-  const url = {
-    author: `/author/${encoded.author}`,
-    likeForm: `/like/${encoded.key}`,
-    link: `/thread/${encoded.key}#${encoded.key}`,
-    parent: `/thread/${encoded.parent}#${encoded.parent}`,
-    avatar: msg.value?.meta?.author?.avatar?.url || '/assets/images/default-avatar.png',
-    json: `/json/${encoded.key}`,
-    subtopic: `/subtopic/${encoded.key}`,
-    comment: `/comment/${encoded.key}`,
-  };
-
-  const isPrivate = Boolean(msg.value?.meta?.private); 
-  const isBlocked = Boolean(msg.value?.meta?.blocking);
-  const isRoot = msg.value?.content?.root == null;
-  const isFork = msg.value?.meta?.postType === "subtopic";
-  const hasContentWarning = typeof msg.value?.content?.contentWarning === "string";
-  const isThreadTarget = Boolean(lodash.get(msg, "value.meta.thread.target", false));
-
-  const { name } = msg.value?.meta?.author || { name: "Anonymous" };
+    const encoded = {
+        key: encodeURIComponent(msg.key),
+        author: encodeURIComponent(msg.value?.author),
+        parent: encodeURIComponent(msg.value?.content?.root),
+    };
 
-  const rawText = msg.value?.content?.text || "";
-  const emptyContent = "<p>undefined</p>\n";
+    const url = {
+        author: `/author/${encoded.author}`,
+        likeForm: `/like/${encoded.key}`,
+        link: `/thread/${encoded.key}#${encoded.key}`,
+        parent: `/thread/${encoded.parent}#${encoded.parent}`,
+        avatar: msg.value?.meta?.author?.avatar?.url || '/assets/images/default-avatar.png',
+        json: `/json/${encoded.key}`,
+        subtopic: `/subtopic/${encoded.key}`,
+        comment: `/comment/${encoded.key}`,
+    };
 
-  const isProbablyHtml =
-    typeof rawText === "string" &&
-    /<\/?[a-z][\s\S]*>/i.test(rawText.trim());
+    const isPrivate = Boolean(msg.value?.meta?.private);
+    const isBlocked = Boolean(msg.value?.meta?.blocking);
+    const isRoot = msg.value?.content?.root == null;
+    const isFork = msg.value?.meta?.postType === "subtopic";
+    const hasContentWarning = typeof msg.value?.content?.contentWarning === "string";
+    const isThreadTarget = Boolean(lodash.get(msg, "value.meta.thread.target", false));
+
+    const { name } = msg.value?.meta?.author || { name: "Anonymous" };
+
+    const content = msg.value?.content || {};
+    const contentType = String(content.type || "");
+
+    const THREAD_ENTITY_TYPES = new Set([
+        'bookmark',
+        'image',
+        'audio',
+        'video',
+        'document',
+        'votes',
+        'event',
+        'task',
+        'report',
+        'market',
+        'project',
+        'job'
+    ]);
+
+    const safeUpper = (s) => String(s || '').toUpperCase();
+    const safeStr = (v) => (v == null ? '' : String(v));
+    const isMsgId = (s) => typeof s === 'string' && (s.startsWith('%') || s.startsWith('&') || s.startsWith('@'));
+    const fmtDate = (v) => {
+        if (!v) return '';
+        const m = moment(v, moment.ISO_8601, true);
+        if (m.isValid()) return m.format('YYYY-MM-DD HH:mm:ss');
+        const n = typeof v === 'number' ? v : Date.parse(v);
+        if (!Number.isFinite(n)) return '';
+        return moment(n).format('YYYY-MM-DD HH:mm:ss');
+    };
 
-  let articleElement;
+    const renderField = (labelText, valueNode) => {
+        if (valueNode == null || valueNode === '') return null;
+        return div(
+            { class: 'card-field' },
+            span({ class: 'card-label' }, labelText),
+            span({ class: 'card-value' }, valueNode)
+        );
+    };
 
-  if (rawText === emptyContent) {
-    articleElement = article(
-      { class: "content" },
-      pre({
-        innerHTML: highlightJs.highlight(
-          JSON.stringify(msg, null, 2),
-          { language: "json", ignoreIllegals: true }
-        ).value,
-      })
-    );
-  } else if (isProbablyHtml) {
-    let html = rawText;
-    if (!/<a\b[^>]*>/i.test(html)) {
-      html = html.replace(
-        /(https?:\/\/[^\s<]+)/g,
-        (url) =>
-          `<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`
-      );
-    }
-    articleElement = article({ class: "content", innerHTML: html });
-  } else {
-    articleElement = article(
-      { class: "content" },
-      p({ class: "post-text" }, ...renderUrl(rawText))
-    );
-  }
+    const entityTitle = (c) => {
+        const t = String(c.type || '').toLowerCase();
+        if (t === 'votes') return safeStr(c.question || c.title);
+        if (t === 'bookmark') return safeStr(c.title || c.name || c.url);
+        if (t === 'market') return safeStr(c.title);
+        if (t === 'project') return safeStr(c.title);
+        if (t === 'job') return safeStr(c.title);
+        if (t === 'report') return safeStr(c.title);
+        if (t === 'task') return safeStr(c.title);
+        if (t === 'event') return safeStr(c.title);
+        if (t === 'document') return safeStr(c.title || c.name || c.url);
+        if (t === 'image' || t === 'audio' || t === 'video') return safeStr(c.title || c.name || c.url);
+        return safeStr(c.title || c.name || c.question || c.url);
+    };
 
-  if (preview) {
-    return section(
-      { id: msg.key, class: "post-preview" },
-      hasContentWarning
-        ? details(summary(msg.value?.content?.contentWarning), articleElement)
-        : articleElement
-    );
-  }
+    const renderEntityRoot = (c) => {
+        const t = String(c.type || '').toLowerCase();
+        const header = `[${safeUpper(t)}]`;
+        const titleText = entityTitle(c) || '(sin título)';
+
+        const nodes = [];
+        nodes.push(
+            div(
+                { class: 'card-field', style: 'margin-bottom:10px;' },
+                span({ class: 'card-label', style: 'font-weight:800;' }, header),
+                span({ class: 'card-value', style: 'margin-left:10px; font-weight:800;' }, titleText)
+            )
+        );
 
-  const ts_received = msg.value?.meta?.timestamp?.received;
+        if (t === 'votes') {
+            const status = safeStr(c.status);
+            const deadline = fmtDate(c.deadline);
+            const totalVotes = (typeof c.totalVotes !== 'undefined') ? safeStr(c.totalVotes) : '';
+            const tags = Array.isArray(c.tags) ? c.tags.filter(Boolean) : [];
+
+            const f1 = renderField((i18n.status || 'Status') + ':', status ? safeUpper(status) : '');
+            const f2 = renderField((i18n.deadline || 'Deadline') + ':', deadline);
+            const f3 = renderField((i18n.voteTotalVotes || 'Total votes') + ':', totalVotes);
+            if (f1) nodes.push(f1);
+            if (f2) nodes.push(f2);
+            if (f3) nodes.push(f3);
+
+            if (tags.length) {
+                nodes.push(
+                    div(
+                        { class: 'card-tags', style: 'margin-top:10px;' },
+                        ...tags.map(tag =>
+                            a(
+                                { href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' },
+                                `#${tag}`
+                            )
+                        )
+                    )
+                );
+            }
+        } else if (t === 'report') {
+            const status = safeStr(c.status);
+            const severity = safeStr(c.severity);
+            const r1 = renderField((i18n.status || 'Status') + ':', status ? safeUpper(status) : '');
+            const r2 = renderField((i18n.severity || 'Severity') + ':', severity ? safeUpper(severity) : '');
+            if (r1) nodes.push(r1);
+            if (r2) nodes.push(r2);
+        } else if (t === 'task') {
+            const status = safeStr(c.status);
+            const priority = safeStr(c.priority);
+            const startTime = fmtDate(c.startTime);
+            const endTime = fmtDate(c.endTime);
+
+            const r1 = renderField((i18n.status || 'Status') + ':', status ? safeUpper(status) : '');
+            const r2 = renderField((i18n.priority || 'Priority') + ':', priority ? safeUpper(priority) : '');
+            const r3 = renderField((i18n.taskStartTimeLabel || 'Start') + ':', startTime);
+            const r4 = renderField((i18n.taskEndTimeLabel || 'End') + ':', endTime);
+
+            if (r1) nodes.push(r1);
+            if (r2) nodes.push(r2);
+            if (r3) nodes.push(r3);
+            if (r4) nodes.push(r4);
+        } else if (t === 'event') {
+            const dateStr = fmtDate(c.date);
+            const location = safeStr(c.location);
+            const price = (typeof c.price !== 'undefined') ? safeStr(c.price) : '';
+
+            const r1 = renderField((i18n.date || 'Date') + ':', dateStr);
+            const r2 = renderField((i18n.location || 'Location') + ':', location);
+            const r3 = renderField((i18n.price || 'Price') + ':', price ? `${price} ECO` : '');
+
+            if (r1) nodes.push(r1);
+            if (r2) nodes.push(r2);
+            if (r3) nodes.push(r3);
+        } else if (t === 'bookmark') {
+            const u = safeStr(c.url);
+            if (u) {
+                nodes.push(
+                    renderField((i18n.url || 'URL') + ':', a({ href: u, target: '_blank', rel: 'noopener noreferrer' }, u))
+                );
+            }
+        } else if (t === 'image') {
+            const u = safeStr(c.url);
+            if (u && isMsgId(u)) {
+                nodes.push(
+                    div({ class: 'card-field', style: 'margin-top:10px;' },
+                        img({ src: `/blob/${encodeURIComponent(u)}`, class: 'feed-image img-content' })
+                    )
+                );
+            }
+        } else if (t === 'audio') {
+            const u = safeStr(c.url);
+            if (u && isMsgId(u)) {
+                nodes.push(
+                    div({ class: 'card-field', style: 'margin-top:10px;' },
+                        audioHyperaxe({ controls: true, src: `/blob/${encodeURIComponent(u)}` })
+                    )
+                );
+            }
+        } else if (t === 'video') {
+            const u = safeStr(c.url);
+            if (u && isMsgId(u)) {
+                nodes.push(
+                    div({ class: 'card-field', style: 'margin-top:10px;' },
+                        videoHyperaxe({ controls: true, src: `/blob/${encodeURIComponent(u)}` })
+                    )
+                );
+            }
+	} else if (t === 'document') {
+	  const u = safeStr(c.url);
+	  if (u && isMsgId(u)) {
+	    const safeId = String(msg.key || u).replace(/[^a-zA-Z0-9_-]/g, '');
+	    nodes.push(
+	      div({ class: 'card-field', style: 'margin-top:10px;' },
+		div({
+		  id: `pdf-container-${safeId}`,
+		  class: 'pdf-viewer-container',
+		  'data-pdf-url': `/blob/${encodeURIComponent(u)}`
+		})
+	      )
+	    );
+	  }
+
+        } else if (t === 'market') {
+            const status = safeStr(c.status);
+            const price = (typeof c.price !== 'undefined') ? safeStr(c.price) : '';
+            const r1 = renderField((i18n.status || 'Status') + ':', status ? safeUpper(status) : '');
+            const r2 = renderField((i18n.price || 'Price') + ':', price ? `${price} ECO` : '');
+            if (r1) nodes.push(r1);
+            if (r2) nodes.push(r2);
+        } else if (t === 'project') {
+            const status = safeStr(c.status);
+            const r1 = renderField((i18n.status || 'Status') + ':', status ? safeUpper(status) : '');
+            if (r1) nodes.push(r1);
+        } else if (t === 'job') {
+            const status = safeStr(c.status);
+            const location = safeStr(c.location);
+            const salary = (typeof c.salary !== 'undefined') ? safeStr(c.salary) : '';
+
+            const r1 = renderField((i18n.status || 'Status') + ':', status ? safeUpper(status) : '');
+            const r2 = renderField((i18n.jobLocation || 'Location') + ':', location ? safeUpper(location) : '');
+            const r3 = renderField((i18n.jobSalary || 'Salary') + ':', salary ? `${salary} ECO` : '');
+
+            if (r1) nodes.push(r1);
+            if (r2) nodes.push(r2);
+            if (r3) nodes.push(r3);
+        }
 
-  if (!ts_received || !ts_received.iso8601 || !moment(ts_received.iso8601, moment.ISO_8601, true).isValid()) {
-    return null;
-  }
+        return article({ class: 'content' }, ...nodes.filter(Boolean));
+    };
 
-  const validTimestamp = moment(ts_received.iso8601, moment.ISO_8601);
-  const timeAgo = validTimestamp.fromNow();
-  const timeAbsolute = validTimestamp.toISOString().split(".")[0].replace("T", " ");
+    const rawText = content.text || "";
+    const emptyContent = "<p>undefined</p>\n";
 
-  const likeButton = msg.value?.meta?.voted
-    ? { value: 0, class: "liked" }
-    : { value: 1, class: null };
+    const isProbablyHtml =
+        typeof rawText === "string" &&
+        /<\/?[a-z][\s\S]*>/i.test(rawText.trim());
 
-  const likeCount = msg.value?.meta?.votes?.length || 0;
-  const maxLikedNameLength = 16;
-  const maxLikedNames = 16;
+    let articleElement;
 
-  const likedByNames = msg.value?.meta?.votes
-    .slice(0, maxLikedNames)
-    .map((person) => person.name)
-    .map((name) => name.slice(0, maxLikedNameLength))
-    .join(", ");
+    if (contentType !== 'post' && contentType !== 'blog' && THREAD_ENTITY_TYPES.has(contentType)) {
+        articleElement = renderEntityRoot(content);
+    } else if (rawText === emptyContent) {
+        articleElement = article(
+            { class: "content" },
+            div(
+                { class: "card-field", style: "margin-bottom:10px;" },
+                span({ class: "card-label" }, (i18n.invalidPost || 'Invalid content') + ':'),
+                span({ class: "card-value" }, (i18n.invalidPostHint || 'This message has invalid/empty text.'))
+            ),
+            details(
+                summary(i18n.viewJson || 'View JSON'),
+                pre({
+                    innerHTML: highlightJs.highlight(
+                        JSON.stringify(msg, null, 2),
+                        { language: "json", ignoreIllegals: true }
+                    ).value,
+                })
+            )
+        );
+    } else if (isProbablyHtml) {
+        let html = rawText;
+        if (!/<a\b[^>]*>/i.test(html)) {
+            html = html.replace(
+                /(https?:\/\/[^\s<]+)/g,
+                (u) => `<a href="${u}" target="_blank" rel="noopener noreferrer">${u}</a>`
+            );
+        }
+        articleElement = article({ class: "content", innerHTML: html });
+    } else {
+        articleElement = article(
+            { class: "content" },
+            p({ class: "post-text" }, ...renderUrl(rawText))
+        );
+    }
 
-  const additionalLikesMessage =
-    likeCount > maxLikedNames ? `+${likeCount - maxLikedNames} more` : ``;
+    if (preview) {
+        return section(
+            { id: msg.key, class: "post-preview" },
+            hasContentWarning
+                ? details(summary(msg.value?.content?.contentWarning), articleElement)
+                : articleElement
+        );
+    }
 
-  const likedByMessage =
-    likeCount > 0 ? `${likedByNames} ${additionalLikesMessage}` : null;
+    const ts_received = msg.value?.meta?.timestamp?.received;
+    const iso =
+        (ts_received && ts_received.iso8601) ||
+        (typeof msg.value?.timestamp === 'number' ? new Date(msg.value.timestamp).toISOString() : null) ||
+        (content.createdAt ? new Date(content.createdAt).toISOString() : null);
 
-  const messageClasses = ["post"];
+    if (!iso || !moment(iso, moment.ISO_8601, true).isValid()) {
+        return null;
+    }
 
-  const recps = [];
+    const validTimestamp = moment(iso, moment.ISO_8601);
+    const timeAgo = validTimestamp.fromNow();
+    const timeAbsolute = validTimestamp.toISOString().split(".")[0].replace("T", " ");
+
+    const likeButton = msg.value?.meta?.voted
+        ? { value: 0, class: "liked" }
+        : { value: 1, class: null };
+
+    const likeCount = msg.value?.meta?.votes?.length || 0;
+    const maxLikedNameLength = 16;
+    const maxLikedNames = 16;
+
+    const likedByNames = msg.value?.meta?.votes
+        .slice(0, maxLikedNames)
+        .map((person) => person.name)
+        .map((n) => n.slice(0, maxLikedNameLength))
+        .join(", ");
+
+    const additionalLikesMessage =
+        likeCount > maxLikedNames ? `+${likeCount - maxLikedNames} more` : ``;
+
+    const likedByMessage =
+        likeCount > 0 ? `${likedByNames} ${additionalLikesMessage}` : null;
+
+    const messageClasses = ["post"];
+    const recps = [];
+
+    const addRecps = (recpsInfo) => {
+        recpsInfo.forEach((recp) => {
+            recps.push(
+                a(
+                    { href: `/author/${encodeURIComponent(recp.feedId)}` },
+                    img({ class: "avatar", src: recp.avatarUrl, alt: "" })
+                )
+            );
+        });
+    };
 
-  const addRecps = (recpsInfo) => {
-    recpsInfo.forEach((recp) => {
-      recps.push(
-        a(
-          { href: `/author/${encodeURIComponent(recp.feedId)}` },
-          img({ class: "avatar", src: recp.avatarUrl, alt: "" })
-        )
-      );
-    });
-  };
+    if (isPrivate) {
+        messageClasses.push("private");
+        addRecps(msg.value?.meta?.recpsInfo || []);
+    }
 
-  if (isPrivate) {
-    messageClasses.push("private");
-    addRecps(msg.value?.meta?.recpsInfo || []);
-  }
+    if (isThreadTarget) {
+        messageClasses.push("thread-target");
+    }
 
-  if (isThreadTarget) {
-    messageClasses.push("thread-target");
-  }
+    if (isBlocked) {
+        messageClasses.push("blocked");
+        return section(
+            {
+                id: msg.key,
+                class: messageClasses.join(" "),
+            },
+            i18n.relationshipBlockingPost
+        );
+    }
 
-  if (isBlocked) {
-    messageClasses.push("blocked");
-    return section(
-      {
-        id: msg.key,
-        class: messageClasses.join(" "),
-      },
-      i18n.relationshipBlockingPost
+    const articleContent = article(
+        { class: "content" },
+        hasContentWarning ? div({ class: "post-subject" }, msg.value?.content?.contentWarning) : null,
+        articleElement
     );
-  }
-
-  const postOptions = {
-    post: null,
-    comment: i18n.commentDescription({ parentUrl: url.parent }),
-    subtopic: i18n.subtopicDescription({ parentUrl: url.parent }),
-    mystery: i18n.mysteryDescription,
-  };
 
-  const articleContent = article(
-    { class: "content" },
-    hasContentWarning ? div({ class: "post-subject" }, msg.value?.content?.contentWarning) : null,
-    articleElement
-  );
-
-  const fragment = section(
-    {
-      id: msg.key,
-      class: messageClasses.join(" "),
-    },
-    header(
-      div(
-        { class: "header-content" },
-        a(
-          { href: url.author },
-          img({ class: "avatar-profile", src: url.avatar, alt: "" })
-        ),
-        span({ class: "created-at" }, `${i18n.createdBy} `, a({ href: url.author }, "@", name), ` | ${timeAbsolute} | ${i18n.sendTime} `, a({ href: url.link }, timeAgo)),
-        isPrivate ? "🔒" : null,
-        isPrivate ? recps : null
-      )
-    ),
-    articleContent,
-    footer(
-      div(
-        form(
-          { action: url.likeForm, method: "post" },
-          button(
-            {
-              name: "voteValue",
-              type: "submit",
-              value: likeButton.value,
-              class: likeButton.class,
-              title: likedByMessage,
-            },
-            `☉ ${likeCount}`
-          )
+    const fragment = section(
+        {
+            id: msg.key,
+            class: messageClasses.join(" "),
+        },
+        header(
+            div(
+                { class: "header-content" },
+                a(
+                    { href: url.author },
+                    img({ class: "avatar-profile", src: url.avatar, alt: "" })
+                ),
+                span(
+                    { class: "created-at" },
+                    `${i18n.createdBy} `,
+                    a({ href: url.author }, "@", name),
+                    ` | ${timeAbsolute} | ${i18n.sendTime} `,
+                    a({ href: url.link }, timeAgo)
+                ),
+                isPrivate ? "🔒" : null,
+                isPrivate ? recps : null
+            )
         ),
-        a({ href: url.comment }, i18n.comment),
-        isPrivate || isRoot || isFork
-          ? null
-          : a({ href: url.subtopic }, nbsp, i18n.subtopic)
-      ),
-      br()
-    )
-  );
+        articleContent,
+        footer(
+            div(
+                form(
+                    { action: url.likeForm, method: "post" },
+                    button(
+                        {
+                            name: "voteValue",
+                            type: "submit",
+                            value: likeButton.value,
+                            class: likeButton.class,
+                            title: likedByMessage,
+                        },
+                        `☉ ${likeCount}`
+                    )
+                ),
+                a({ href: url.comment }, i18n.comment),
+                isPrivate || isRoot || isFork
+                    ? null
+                    : a({ href: url.subtopic }, nbsp, i18n.subtopic)
+            ),
+            br()
+        )
+    );
 
-  const threadSeparator = [br()];
+    const threadSeparator = [br()];
 
-  if (aside) {
-    return [fragment, postAside(msg), isRoot ? threadSeparator : null];
-  } else {
-    return fragment;
-  }
+    if (aside) {
+        return [fragment, postAside(msg), isRoot ? threadSeparator : null];
+    } else {
+        return fragment;
+    }
 };
 
 exports.editProfileView = ({ name, description }) =>
@@ -1853,14 +2069,23 @@ exports.publishCustomView = async () => {
 exports.threadView = ({ messages }) => {
   const rootMessage = messages[0];
   const rootAuthorName = rootMessage.value.meta.author.name;
-  const rootSnippet = postSnippet(
-    lodash.get(rootMessage, "value.content.text", i18n.mysteryDescription)
-  );
-  return template([`@${rootAuthorName}`], 
-    div(
-    thread(messages)
-    )
+
+  const needsPdfViewer = Array.isArray(messages) && messages.some((m) => {
+    const t = String(m?.value?.content?.type || "").toLowerCase();
+    return t === "document";
+  });
+
+  const tpl = template(
+    [`@${rootAuthorName}`],
+    div(thread(messages))
   );
+
+  return `${tpl}${
+    needsPdfViewer
+      ? `<script type="module" src="/js/pdf.min.mjs"></script>
+         <script src="/js/pdf-viewer.js"></script>`
+      : ""
+  }`;
 };
 
 exports.publishView = (preview, text, contentWarning) => {
@@ -2023,13 +2248,13 @@ const generatePreview = ({ previewData, contentWarning, action }) => {
 };
 
 exports.previewView = ({ previewData, contentWarning }) => {
-  const publishAction = "/publish";
-  const preview = generatePreview({
-    previewData,
-    contentWarning,
-    action: publishAction,
-  });
-  return exports.publishView(preview, previewData.formattedText, contentWarning);
+    const publishAction = "/publish";
+    const preview = generatePreview({
+        previewData,
+        contentWarning,
+        action: publishAction,
+    });
+    return exports.publishView(preview, previewData.text || "", contentWarning);
 };
 
 const viewInfoBox = ({ viewTitle = null, viewDescription = null }) => {

+ 1 - 1
src/views/task_view.js

@@ -289,7 +289,7 @@ exports.singleTaskView = async (task, filter, comments = []) => {
           span({ class: 'card-label' }, i18n.taskAssignedTo + ':'),
           span({ class: 'card-value' },
             Array.isArray(task.assignees) && task.assignees.length
-              ? task.assignees.map((id, i) => [i > 0 ? ', ' : '', a({ href: `/author/${encodeURIComponent(id)}` }, id)]).flat()
+              ? task.assignees.map((id, i) => [i > 0 ? ', ' : '', a({ class: 'user-link', href: `/author/${encodeURIComponent(id)}` }, id)]).flat()
               : i18n.noAssignees
           )
         ),