psy преди 2 дни
родител
ревизия
58cf0c6ebe

+ 12 - 0
docs/CHANGELOG.md

@@ -13,6 +13,18 @@ All notable changes to this project will be documented in this file.
 ### Security
 -->
 
+## v0.5.7 - 2025-11-25
+
+### Added
+
+ + Collapsible menu entries (Core plugin).
+
+### Fixed
+
+ + Remote videos fail to load at Firefox/LibreWolf (Core plugin).
+ + Fixed the comment query to return all posts whose root is the topic ID (Core plugin).
+ + Fixed render-format for latest posts (Core plugin).
+ + Fixed inhabitants listing for short-time activities (Activity plugin).
 
 ## v0.5.6 - 2025-11-21
 

+ 15 - 29
src/backend/backend.js

@@ -261,7 +261,7 @@ const { about, blob, friend, meta, post, vote } = models({
   cooler,
   isPublic: config.public,
 });
-const { handleBlobUpload } = require('../backend/blobHandler.js');
+const { handleBlobUpload, serveBlob } = require('../backend/blobHandler.js');
 
 // load plugin models (static)
 const exportmodeModel = require('../models/exportmode_model');
@@ -303,17 +303,21 @@ const bankingModel = require("../models/banking_model")({ services: { cooler },
 const parliamentModel = require('../models/parliament_model')({ cooler, services: { tribes: tribesModel, votes: votesModel, inhabitants: inhabitantsModel, banking: bankingModel } });
 const courtsModel = require('../models/courts_model')({ cooler, services: { votes: votesModel, inhabitants: inhabitantsModel, tribes: tribesModel, banking: bankingModel } });
 
-//votes (comments)
+// content (comments)
 const getVoteComments = async (voteId) => {
   const rawComments = await post.topicComments(voteId);
-  const filtered = (rawComments || []).filter(c => {
-    const content = c.value && c.value.content;
-    if (!content) return false;
-    return content.type === 'post' &&
-           content.root === voteId &&
-           content.dest === voteId;
-  });
-  return filtered;
+  const comments = (rawComments || [])
+    .filter(c => {
+      const content = c.value && c.value.content;
+      if (!content) return false;
+      return content.type === 'post' && content.root === voteId;
+    })
+    .sort((a, b) => {
+      const ta = a.value && a.value.timestamp ? a.value.timestamp : 0;
+      const tb = b.value && b.value.timestamp ? b.value.timestamp : 0;
+      return ta - tb;
+    });
+  return comments;
 };
 
 // starting warmup
@@ -1486,25 +1490,7 @@ router
     };
     ctx.body = await json(message);
   })
-  .get("/blob/:blobId", async (ctx) => {
-    const { blobId } = ctx.params;
-    const id = blobId.startsWith('&') ? blobId : `&${blobId}`;
-    const buffer = await blob.getResolved({ blobId });
-    let fileType;
-    try {
-      fileType = await FileType.fromBuffer(buffer);
-    } catch {
-      fileType = null;
-    }
-    let mime = fileType?.mime || "application/octet-stream";
-    if (mime === "application/octet-stream" && buffer.slice(0, 4).toString() === "%PDF") {
-      mime = "application/pdf";
-    }
-    ctx.set("Content-Type", mime);
-    ctx.set("Content-Disposition", `inline; filename="${blobId}"`);
-    ctx.set("Cache-Control", "public, max-age=31536000, immutable");
-    ctx.body = buffer;
-  })
+  .get("/blob/:blobId", serveBlob)
   .get("/image/:imageSize/:blobId", async (ctx) => {
     const { blobId, imageSize } = ctx.params;
     const size = Number(imageSize);

+ 137 - 3
src/backend/blobHandler.js

@@ -2,7 +2,7 @@ const pull = require('../server/node_modules/pull-stream');
 const FileType = require("../server/node_modules/file-type");
 const promisesFs = require('fs').promises;
 const ssb = require("../client/gui");
-const config = require("../server/SSB_server").config; 
+const config = require("../server/SSB_server").config;
 const cooler = ssb({ offline: config.offline });
 
 const handleBlobUpload = async function (ctx, fileFieldName) {
@@ -20,7 +20,10 @@ const handleBlobUpload = async function (ctx, fileFieldName) {
 
   const blob = { name: blobUpload.name };
   blob.id = await new Promise((resolve, reject) => {
-    pull(pull.values([data]), ssbClient.blobs.add((err, ref) => err ? reject(err) : resolve(ref)));
+    pull(
+      pull.values([data]),
+      ssbClient.blobs.add((err, ref) => (err ? reject(err) : resolve(ref)))
+    );
   });
 
   try {
@@ -38,4 +41,135 @@ const handleBlobUpload = async function (ctx, fileFieldName) {
   return `\n[${blob.name}](${blob.id})`;
 };
 
-module.exports = { handleBlobUpload };
+function waitForBlob(ssbClient, blobId, timeoutMs = 60000) {
+  return new Promise((resolve, reject) => {
+    let done = false;
+
+    const finishOk = () => {
+      if (done) return;
+      done = true;
+      clearTimeout(timer);
+      resolve();
+    };
+
+    const finishErr = (err) => {
+      if (done) return;
+      done = true;
+      clearTimeout(timer);
+      reject(err);
+    };
+
+    const timer = setTimeout(() => {
+      finishErr(new Error(`Timeout waiting for blob ${blobId}`));
+    }, timeoutMs);
+
+    if (!ssbClient.blobs || typeof ssbClient.blobs.has !== 'function') {
+      return finishErr(new Error('ssb.blobs.has is not available'));
+    }
+
+    ssbClient.blobs.has(blobId, (err, has) => {
+      if (err) return finishErr(err);
+      if (has) return finishOk();
+
+      if (typeof ssbClient.blobs.want !== 'function') {
+        return finishErr(new Error('ssb.blobs.want is not available'));
+      }
+
+      ssbClient.blobs.want(blobId, (err2) => {
+        if (err2) return finishErr(err2);
+        finishOk();
+      });
+    });
+  });
+}
+
+const serveBlob = async function (ctx) {
+  const encodedParam = (ctx.params.id || ctx.params.blobId || '').trim();
+  const raw = decodeURIComponent(encodedParam);
+
+  if (!raw) {
+    ctx.status = 400;
+    ctx.body = 'Invalid blob id';
+    return;
+  }
+
+  const blobId = raw.startsWith('&') ? raw : `&${raw}`;
+
+  const ssbClient = await cooler.open();
+
+  try {
+    await waitForBlob(ssbClient, blobId, 60000);
+  } catch (err) {
+    ctx.status = 504;
+    ctx.body = 'Blob not available';
+    return;
+  }
+
+  let buffer;
+  try {
+    buffer = await new Promise((resolve, reject) => {
+      pull(
+        ssbClient.blobs.get(blobId),
+        pull.collect((err, chunks) => {
+          if (err) return reject(err);
+          resolve(Buffer.concat(chunks));
+        })
+      );
+    });
+  } catch (err) {
+    ctx.status = 500;
+    ctx.body = 'Error reading blob';
+    return;
+  }
+
+  const size = buffer.length;
+
+  let mime = 'application/octet-stream';
+  try {
+    const ft = await FileType.fromBuffer(buffer);
+    if (ft && ft.mime) mime = ft.mime;
+  } catch {}
+
+  ctx.type = mime;
+  ctx.set('Content-Disposition', `inline; filename="${raw}"`);
+  ctx.set('Cache-Control', 'public, max-age=31536000, immutable');
+
+  const range = ctx.headers.range;
+
+  if (range) {
+    const match = /^bytes=(\d*)-(\d*)$/.exec(range);
+    if (!match) {
+      ctx.status = 416;
+      ctx.set('Content-Range', `bytes */${size}`);
+      return;
+    }
+
+    let start = match[1] ? parseInt(match[1], 10) : 0;
+    let end = match[2] ? parseInt(match[2], 10) : size - 1;
+
+    if (Number.isNaN(start) || start < 0) start = 0;
+    if (Number.isNaN(end) || end >= size) end = size - 1;
+
+    if (start > end || start >= size) {
+      ctx.status = 416;
+      ctx.set('Content-Range', `bytes */${size}`);
+      return;
+    }
+
+    const chunk = buffer.slice(start, end + 1);
+
+    ctx.status = 206;
+    ctx.set('Content-Range', `bytes ${start}-${end}/${size}`);
+    ctx.set('Accept-Ranges', 'bytes');
+    ctx.set('Content-Length', String(chunk.length));
+    ctx.body = chunk;
+  } else {
+    ctx.status = 200;
+    ctx.set('Accept-Ranges', 'bytes');
+    ctx.set('Content-Length', String(size));
+    ctx.body = buffer;
+  }
+};
+
+module.exports = { handleBlobUpload, serveBlob };
+

+ 116 - 1
src/client/assets/styles/style.css

@@ -196,8 +196,123 @@ nav ul li a:hover {
   text-decoration: underline;
 }
 
-/* Main content area + Menu */
+/* nav menus */
+.oasis-nav-group {
+  list-style: none;
+  margin-bottom: 0.75rem;
+}
+
+.oasis-nav-toggle {
+  display: none;
+}
+
+.oasis-nav-header {
+  display: flex;
+  align-items: center;
+  justify-content: flex-start;
+  cursor: pointer;
+  padding: 0.5rem 0.75rem;
+  font-weight: 600;
+  font-size: 0.8rem;
+  text-transform: uppercase;
+  letter-spacing: 0.05em;
+  color: #ffb400;
+  transition: background 0.2s ease, color 0.2s ease;
+  text-align: left;
+  border: 1px solid rgba(255, 180, 0, 0.6);
+  border-radius: 4px;
+  margin: 0 0.25rem;
+  box-sizing: border-box;
+}
+
+.oasis-nav-header:hover {
+  background: rgba(255, 255, 255, 0.05);
+  color: #ffd36a;
+}
+
+.oasis-nav-arrow {
+  font-size: 0.7rem;
+  margin-left: auto;
+  transition: transform 0.2s ease;
+}
+
+.oasis-nav-header + .oasis-nav-list {
+  margin-top: 0.35rem;
+}
+
+.oasis-nav-list {
+  max-height: 0;
+  overflow: hidden;
+  transition: max-height 0.25s ease-out;
+  padding-left: 0;
+  margin: 0 0 0 0.75rem;
+}
+
+.oasis-nav-toggle:checked + .oasis-nav-header + .oasis-nav-list {
+  max-height: 1000px;
+}
+
+.oasis-nav-toggle:checked + .oasis-nav-header .oasis-nav-arrow {
+  transform: rotate(180deg);
+}
+
+.oasis-nav-list li {
+  list-style: none;
+  padding: 0;
+  margin: 0;
+}
+
+.oasis-nav-list li a {
+  display: flex;
+  align-items: center;
+  padding: 0.35rem 1.25rem 0.35rem 1.5rem;
+  font-size: 0.85rem;
+  text-decoration: none;
+  opacity: 0.85;
+  transition: opacity 0.15s ease;
+}
+
+.oasis-nav-list li a:hover {
+  opacity: 1;
+}
+
+.oasis-nav-list .emoji {
+  margin-right: 0.4rem;
+}
+
+.oasis-header-marquee {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  overflow: hidden;
+  white-space: nowrap;
+  font-size: 0.78rem;
+  color: #ffc45a;
+}
+
+.oasis-header-marquee-inner {
+  display: inline-block;
+  padding: 0 1rem;
+  animation: oasis-marquee 30s linear infinite;
+}
+
+.oasis-marquee-item {
+  margin: 0 0.4rem;
+  opacity: 0.9;
+}
+
+.oasis-marquee-separator {
+  margin: 0 0.4rem;
+  opacity: 0.5;
+}
+
+@keyframes oasis-marquee {
+  0% { transform: translateX(100%); }
+  100% { transform: translateX(-100%); }
+}
 
+/* Main content area + Menu */
 /* 
 .main-content { border: 2px solid red !important; }
 .sidebar-left { border: 2px solid blue !important; }

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

@@ -114,6 +114,17 @@ module.exports = {
     marketTitle: "Market",
     opinionsTitle: "Opinions",
     saveSettings: "Save configuration",
+    // menu categories
+    menuPersonal: "Personal",
+    menuContent: "Content",
+    menuGovernance: "Governance",
+    menuOffice: "Office",
+    menuMultiverse: "Multiverse",
+    menuNetwork: "Network",
+    menuCreative: "Creative",
+    menuEconomy: "Economy",
+    menuMedia: "Media",
+    menuTools: "Tools",
     // post actions
     comment: "Comment",
     subtopic: "Subtopic",

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

@@ -111,6 +111,17 @@ module.exports = {
     marketTitle: "Mercado",
     opinionsTitle: "Opiniones",
     saveSettings: "Guardar configuración",
+    // menu categories
+    menuPersonal: "Personal",
+    menuContent: "Contenido",
+    menuGovernance: "Gobernanza",
+    menuOffice: "Oficina",
+    menuMultiverse: "Multiverso",
+    menuNetwork: "Red",
+    menuCreative: "Creativo",
+    menuEconomy: "Economía",
+    menuMedia: "Multimedia",
+    menuTools: "Herramientas",
     // post actions
     comment: "Comentar",
     subtopic: "Subtopic",

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

@@ -111,6 +111,17 @@ module.exports = {
     marketTitle: "Merkatua",
     opinionsTitle: "Iritziak",
     saveSettings: "Gorde konfigurazioa",
+    // menu categories
+    menuPersonal: "Pertsonala",
+    menuContent: "Edukia",
+    menuGovernance: "Gobernantza",
+    menuOffice: "Bulegoa",
+    menuMultiverse: "Multibertsoa",
+    menuNetwork: "Sarea",
+    menuCreative: "Sortzailea",
+    menuEconomy: "Ekonomia",
+    menuMedia: "Multimedia",
+    menuTools: "Tresnak",
     // post actions
     comment: "Iruzkindu",
     subtopic: "Azpi-gaia",

+ 11 - 0
src/client/assets/translations/oasis_fr.js

@@ -111,6 +111,17 @@ module.exports = {
     marketTitle: "Marché",
     opinionsTitle: "Avis",
     saveSettings: "Enregistrer la configuration",
+    // menu categories
+    menuPersonal: "Personnel",
+    menuContent: "Contenu",
+    menuGovernance: "Gouvernance",
+    menuOffice: "Bureau",
+    menuMultiverse: "Multivers",
+    menuNetwork: "Réseau",
+    menuCreative: "Créatif",
+    menuEconomy: "Économie",
+    menuMedia: "Médias",
+    menuTools: "Outils",
     // post actions
     comment: "Commenter",
     subtopic: "Sous-sujet",

+ 5 - 4
src/client/middleware.js

@@ -37,14 +37,15 @@ module.exports = ({ host, port, middleware, allowHost }) => {
     return true;
   };
 
-  app.on("error", (err, ctx) => {
+   app.on("error", (err, ctx) => {
+    if (err && (err.code === 'ECONNRESET' || err.code === 'EPIPE')) {
+      return;
+    }
     console.error(err);
-
-    if (isValidRequest(ctx.request)) {
+    if (ctx && isValidRequest(ctx.request)) {
       err.message = err.stack;
       err.expose = true;
     }
-
     return null;
   });
 

+ 5 - 0
src/models/activity_model.js

@@ -162,6 +162,11 @@ module.exports = ({ cooler }) => {
           const key = `${a.type}:${a.author}`;
           const prev = byKey.get(key);
           if (!prev || effTs > prev.__effTs) byKey.set(key, { ...a, __effTs: effTs });
+        } else if (a.type === 'about') {
+          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 });
         } else if (a.type === 'tribe') {
           const t = norm(c.title);
           if (t) {

+ 29 - 26
src/models/main_models.js

@@ -1080,33 +1080,37 @@ const post = {
     const ssb = await cooler.open();
     const myFeedId = ssb.id;
     const query = [
-    {
-      $filter: {
-        dest: rootId,
+      {
+        $filter: {
+          value: {
+            content: {
+              type: "post",
+              root: rootId,
+            },
+          },
+        },
       },
-    },
-  ];
-  const messages = await getMessages({
-    myFeedId,
-    customOptions,
-    ssb,
-    query,
-    filter: (msg) => msg.value.content.root === rootId && hasNoFork(msg),
-  });
-  const fullMessages = await Promise.all(
-    messages.map(async (msg) => {
-      if (typeof msg === 'string') {
-        return new Promise((resolve, reject) => {
-          ssb.get({ id: msg, meta: true, private: true }, (err, fullMsg) => {
-            if (err) reject(err);
-            else resolve(fullMsg);
+    ];
+    const messages = await getMessages({
+      myFeedId,
+      customOptions,
+      ssb,
+      query,
+    });
+    const fullMessages = await Promise.all(
+      messages.map(async (msg) => {
+        if (typeof msg === "string") {
+          return new Promise((resolve, reject) => {
+            ssb.get({ id: msg, meta: true, private: true }, (err, fullMsg) => {
+              if (err) reject(err);
+              else resolve(fullMsg);
+            });
           });
-        });
-      }
-      return msg;
-    })
-  );
-  return fullMessages;
+        }
+        return msg;
+      })
+    );
+    return fullMessages;
   },
   likes: async ({ feed }, customOptions = {}) => {
       const ssb = await cooler.open();
@@ -1871,7 +1875,6 @@ const post = {
   };
   models.post = post;
 
-
 // SPREAD MODEL
 models.vote = {
   publish: async ({ messageKey, value, recps }) => {

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

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

+ 1 - 1
src/server/package.json

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

+ 18 - 2
src/views/activity_view.js

@@ -396,11 +396,27 @@ function renderActionCards(actions, userId) {
     }
 
     if (type === 'post') {
-      const { contentWarning, text } = content;
+      const { contentWarning, text } = content || {};
+      const rawText = text || '';
+      const isHtml = typeof rawText === 'string' && /<\/?[a-z][\s\S]*>/i.test(rawText);
+      let bodyNode;
+      if (isHtml) {
+        const hasAnchor = /<a\b[^>]*>/i.test(rawText);
+        const linkified = hasAnchor
+          ? rawText
+          : rawText.replace(
+              /(https?:\/\/[^\s<]+)/g,
+              (url) =>
+                `<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`
+            );
+        bodyNode = div({ class: 'post-text', innerHTML: linkified });
+      } else {
+        bodyNode = p({ class: 'post-text' }, ...renderUrl(rawText));
+      }
       cardBody.push(
         div({ class: 'card-section post' },
           contentWarning ? h2({ class: 'content-warning' }, contentWarning) : '',
-          p({ innerHTML: text })
+          bodyNode
         )
       );
     }

Файловите разлики са ограничени, защото са твърде много
+ 549 - 236
src/views/main_views.js


+ 19 - 6
src/views/video_view.js

@@ -127,7 +127,13 @@ const renderVideoList = (filteredVideos, filter) => {
           video.tags?.length
             ? div({ class: "card-tags" },
                 video.tags.map(tag =>
-                  a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link", style: "margin-right: 0.8em; margin-bottom: 0.5em;" }, `#${tag}`)
+                  a(
+                    {
+                      href: `/search?query=%23${encodeURIComponent(tag)}`,
+                      class: "tag-link"
+                    },
+                    `#${tag}`
+                  )
                 )
               )
             : null,
@@ -223,7 +229,7 @@ exports.videoView = async (videos, filter, videoId) => {
 
 exports.singleVideoView = async (video, filter, comments = []) => {
   const isAuthor = video.author === userId;
-  const hasOpinions = Object.keys(video.opinions || {}).length > 0; 
+  const hasOpinions = Object.keys(video.opinions || {}).length > 0;
 
   return template(
     i18n.videoTitle,
@@ -263,12 +269,18 @@ exports.singleVideoView = async (video, filter, comments = []) => {
           : p(i18n.videoNoFile),
         p(...renderUrl(video.description)),
         video.tags?.length
-            ? div({ class: "card-tags" },
-                video.tags.map(tag =>
-                  a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link", style: "margin-right: 0.8em; margin-bottom: 0.5em;" }, `#${tag}`)
+          ? div({ class: "card-tags" },
+              video.tags.map(tag =>
+                a(
+                  {
+                    href: `/search?query=%23${encodeURIComponent(tag)}`,
+                    class: "tag-link"
+                  },
+                  `#${tag}`
                 )
               )
-            : null,
+            )
+          : null,
         br,
         p({ class: 'card-footer' },
           span({ class: 'date-link' }, `${moment(video.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed} `),
@@ -286,3 +298,4 @@ exports.singleVideoView = async (video, filter, comments = []) => {
     )
   );
 };
+