psy 2 дней назад
Родитель
Сommit
131af60fd4

+ 11 - 0
docs/CHANGELOG.md

@@ -13,6 +13,17 @@ All notable changes to this project will be documented in this file.
 ### Security
 ### Security
 -->
 -->
 
 
+## v0.6.5 - 2026-01-16
+
+### Added
+
+ + Blockexplorer search engine (Blockexplorer plugin).
+
+### Fixed
+
+ + Spreading threads + comments (Activity plugin).
+ + Minor fixes (Activity plugin).
+
 ## v0.6.4 - 2026-01-05
 ## v0.6.4 - 2026-01-05
 
 
 ### Added
 ### Added

+ 29 - 8
src/backend/backend.js

@@ -670,18 +670,37 @@ router
     if (!checkMod(ctx, 'pixeliaMod')) { ctx.redirect('/modules'); return; }
     if (!checkMod(ctx, 'pixeliaMod')) { ctx.redirect('/modules'); return; }
     const pixelArt = await pixeliaModel.listPixels();
     const pixelArt = await pixeliaModel.listPixels();
     ctx.body = pixeliaView(pixelArt);
     ctx.body = pixeliaView(pixelArt);
-  })
+  })  
   .get('/blockexplorer', async (ctx) => {
   .get('/blockexplorer', async (ctx) => {
-    const userId = getViewerId(); 
-    const query = ctx.query;
-    const filter = query.filter || 'recent';
-    const blockchainData = await blockchainModel.listBlockchain(filter, userId);
-    ctx.body = renderBlockchainView(blockchainData, filter, userId);
+    const userId = getViewerId();
+    const query = ctx.query || {};
+    const search = {
+      id: query.id || '',
+      author: query.author || '',
+      from: query.from || '',
+      to: query.to || ''
+    };
+    const searchActive = Object.values(search).some(v => String(v || '').trim().length > 0);
+    let filter = query.filter || 'recent';
+    if (searchActive && String(filter).toLowerCase() === 'recent') filter = 'all';
+    const blockchainData = await blockchainModel.listBlockchain(filter, userId, search);
+    ctx.body = renderBlockchainView(blockchainData, filter, userId, search);
   })
   })
   .get('/blockexplorer/block/:id', async (ctx) => {
   .get('/blockexplorer/block/:id', async (ctx) => {
+    const userId = getViewerId();
+    const query = ctx.query || {};
+    const search = {
+      id: query.id || '',
+      author: query.author || '',
+      from: query.from || '',
+      to: query.to || ''
+    };
+    const searchActive = Object.values(search).some(v => String(v || '').trim().length > 0);
+    let filter = query.filter || 'recent';
+    if (searchActive && String(filter).toLowerCase() === 'recent') filter = 'all';
     const blockId = ctx.params.id;
     const blockId = ctx.params.id;
     const block = await blockchainModel.getBlockById(blockId);
     const block = await blockchainModel.getBlockById(blockId);
-    ctx.body = renderSingleBlockView(block);
+    ctx.body = renderSingleBlockView(block, filter, userId, search);
   })
   })
   .get("/public/latest", async (ctx) => {
   .get("/public/latest", async (ctx) => {
     if (!checkMod(ctx, 'latestMod')) { ctx.redirect('/modules'); return; }
     if (!checkMod(ctx, 'latestMod')) { ctx.redirect('/modules'); return; }
@@ -1015,9 +1034,11 @@ router
   })
   })
   .get('/activity', async ctx => {
   .get('/activity', async ctx => {
     const filter = qf(ctx, 'recent'), userId = getViewerId();
     const filter = qf(ctx, 'recent'), userId = getViewerId();
+    const q = String((ctx.query && ctx.query.q) || '');
     try { await bankingModel.ensureSelfAddressPublished(); } catch (_) {}
     try { await bankingModel.ensureSelfAddressPublished(); } catch (_) {}
     try { await bankingModel.getUserEngagementScore(userId); } catch (_) {}
     try { await bankingModel.getUserEngagementScore(userId); } catch (_) {}
-    ctx.body = activityView(await activityModel.listFeed(filter), filter, userId);
+    const allActions = await activityModel.listFeed('all');
+    ctx.body = activityView(allActions, filter, userId, q);
   })
   })
   .get("/profile", async (ctx) => {
   .get("/profile", async (ctx) => {
     const myFeedId = await meta.myFeedId(), gt = Number(ctx.request.query.gt || -1), lt = Number(ctx.request.query.lt || -1);
     const myFeedId = await meta.myFeedId(), gt = Number(ctx.request.query.gt || -1), lt = Number(ctx.request.query.lt || -1);

+ 72 - 5
src/client/assets/styles/style.css

@@ -1051,9 +1051,47 @@ button.create-button:hover {
   border-radius: 50%;
   border-radius: 50%;
 }
 }
 
 
-.activity-dot.green { background-color: #2ecc71; }
-.activity-dot.orange { background-color: #f39c12; }
-.activity-dot.red { background-color: #e74c3c; }
+.activity-filters{display:grid;grid-template-columns:repeat(6,1fr);gap:16px;margin-bottom:12px}
+.activity-filter-col{display:flex;flex-direction:column;gap:8px}
+.activity-search{display:flex;gap:12px;align-items:center;margin:14px 0 18px 0;flex-wrap:wrap}
+.activity-search-input{min-width:240px;max-width:520px;width:100%}
+.post-text-pre{white-space:pre-wrap}
+.activity-image-thumb{width:100%;max-width:720px;height:auto;border-radius:14px;object-fit:cover}
+.activity-avatar{width:256px;height:256px;}
+.activity-pub{display:flex;flex-direction:column;gap:10px;align-items:flex-start}
+.activity-pub-title{margin:0}
+.activity-pub-title .user-link{display:inline}
+.activity-contact-links{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
+.activity-contact-arrow{opacity:.85}
+.reply-context{margin:0 0 .55rem 0;padding:.5rem .6rem;border-radius:12px;background:rgba(255,255,255,.04)}
+.reply-context-author{margin-left:.45rem}
+.reply-context-text{margin:.35rem 0 0 0;opacity:.92;font-size:.97em}
+.activity-spread-title{margin:.25rem 0 .55rem 0;font-size:1.08em}
+.activity-spread-text{line-height:1.45;font-size:1.02em;word-break:break-word;overflow-wrap:anywhere}
+.activity-contact {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  flex-wrap: wrap;
+}
+
+.activity-contact-avatar-link {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.activity-contact-avatar {
+  width: 256px;
+  height: 256px;
+  object-fit: cover;
+  border-radius: 12px;
+}
+
+.activity-contact-arrow {
+  font-weight: 800;
+  opacity: 0.85;
+}
 
 
 /* Documents */
 /* Documents */
 .pdf-viewer-container {
 .pdf-viewer-container {
@@ -1411,7 +1449,6 @@ display:flex; gap:8px; margin-top:16px;
 { 
 { 
 padding:8px 12px; border:none; border-radius:4px; cursor:pointer; background:#ff6600; color:#fff; 
 padding:8px 12px; border:none; border-radius:4px; cursor:pointer; background:#ff6600; color:#fff; 
 }
 }
-
 .filter-btn.active { 
 .filter-btn.active { 
 background:#ffa500;
 background:#ffa500;
  }
  }
@@ -2371,6 +2408,37 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
   margin: 0 auto;
   margin: 0 auto;
 }
 }
 
 
+.blockexplorer-search{
+  margin:16px 0 20px;
+}
+.blockexplorer-search-form{
+  display:flex;
+  flex-direction:column;
+  gap:12px;
+}
+.blockexplorer-search-row{
+  display:flex;
+  flex-direction:column;
+  gap:10px;
+}
+.blockexplorer-search-pair{
+  display:flex;
+  flex-wrap:wrap;
+  gap:6px;
+}
+.blockexplorer-search-dates{
+  display:flex;
+  flex-wrap:wrap;
+  gap:12px;
+}
+.blockexplorer-search-input{
+  min-width:220px;
+}
+.blockexplorer-search-actions{
+  display:flex;
+  justify-content:flex-start;
+}
+
 .block {
 .block {
   background: #23242a;
   background: #23242a;
   border-radius: 16px;
   border-radius: 16px;
@@ -2929,4 +2997,3 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
   padding-bottom: 0;
   padding-bottom: 0;
   margin: 0;
   margin: 0;
 }
 }
-

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

@@ -57,6 +57,7 @@ module.exports = {
     privateDelete: "Delete",
     privateDelete: "Delete",
     pmCreateButton: "Write a PM",
     pmCreateButton: "Write a PM",
     pmReply: "Reply",
     pmReply: "Reply",
+    inReplyTo: "IN REPLY TO",
     pmPreview: "Preview",
     pmPreview: "Preview",
     pmPreviewTitle: "Message preview",
     pmPreviewTitle: "Message preview",
     noPrivateMessages: "You haven't received any private message, yet.",
     noPrivateMessages: "You haven't received any private message, yet.",

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

@@ -1871,6 +1871,7 @@ module.exports = {
     pmCreateButton: "Escribir MP",
     pmCreateButton: "Escribir MP",
     noPrivateMessages: "No hay mensajes privados.",
     noPrivateMessages: "No hay mensajes privados.",
     pmReply: "Responder",
     pmReply: "Responder",
+    inReplyTo: "EN RESPUESTA A",
     pmPreview: "Previsualizar",
     pmPreview: "Previsualizar",
     pmPreviewTitle: "Vista previa",
     pmPreviewTitle: "Vista previa",
     performed: "→",
     performed: "→",

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

@@ -1825,6 +1825,7 @@ module.exports = {
     pmCreateButton: "MP idatzi",
     pmCreateButton: "MP idatzi",
     noPrivateMessages: "Ez dago mezu pribaturik.",
     noPrivateMessages: "Ez dago mezu pribaturik.",
     pmReply: "Erantzun",
     pmReply: "Erantzun",
+    inReplyTo: "HONI ERANTZUNEZ",
     pmPreview: "Aurrebista",
     pmPreview: "Aurrebista",
     pmPreviewTitle: "Mezuaren aurrebista",
     pmPreviewTitle: "Mezuaren aurrebista",
     performed: "→",
     performed: "→",

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

@@ -1871,6 +1871,7 @@ module.exports = {
     pmCreateButton: "Écrire un MP",
     pmCreateButton: "Écrire un MP",
     noPrivateMessages: "Aucun message privé.",
     noPrivateMessages: "Aucun message privé.",
     pmReply: "Répondre",
     pmReply: "Répondre",
+    inReplyTo: "EN RÉPONSE À",
     pmPreview: "Aperçu",
     pmPreview: "Aperçu",
     pmPreviewTitle: "Aperçu",
     pmPreviewTitle: "Aperçu",
     performed: "→",
     performed: "→",

+ 128 - 13
src/models/blockchain_model.js

@@ -14,20 +14,96 @@ module.exports = ({ cooler }) => {
   const hasBlob = async (ssbClient, url) =>
   const hasBlob = async (ssbClient, url) =>
     new Promise(resolve => ssbClient.blobs.has(url, (err, has) => resolve(!err && has)));
     new Promise(resolve => ssbClient.blobs.has(url, (err, has) => resolve(!err && has)));
 
 
-  const isClosedSold = s => String(s || '').toUpperCase() === 'SOLD' || String(s || '').toUpperCase() === 'CLOSED';
+  const isClosedSold = s =>
+    String(s || '').toUpperCase() === 'SOLD' || String(s || '').toUpperCase() === 'CLOSED';
 
 
   const projectRank = (status) => {
   const projectRank = (status) => {
     const S = String(status || '').toUpperCase();
     const S = String(status || '').toUpperCase();
     if (S === 'COMPLETED') return 3;
     if (S === 'COMPLETED') return 3;
-    if (S === 'ACTIVE')    return 2;
-    if (S === 'PAUSED')    return 1;
+    if (S === 'ACTIVE') return 2;
+    if (S === 'PAUSED') return 1;
     if (S === 'CANCELLED') return 0;
     if (S === 'CANCELLED') return 0;
     return -1;
     return -1;
   };
   };
 
 
+  const safeDecode = (s) => {
+    try { return decodeURIComponent(String(s || '')); } catch { return String(s || ''); }
+  };
+
+  const parseTs = (s) => {
+    const raw = String(s || '').trim();
+    if (!raw) return null;
+    const ts = new Date(raw).getTime();
+    return Number.isFinite(ts) ? ts : null;
+  };
+
+  const matchBlockId = (blockId, q) => {
+    const a = String(blockId || '');
+    const b = String(q || '');
+    if (!a || !b) return true;
+    const al = a.toLowerCase();
+    const bl = b.toLowerCase();
+    if (al.includes(bl)) return true;
+    const ad = safeDecode(a).toLowerCase();
+    const bd = safeDecode(b).toLowerCase();
+    return ad.includes(bd) || ad.includes(bl) || al.includes(bd);
+  };
+
+  const matchAuthorOrName = (authorId, authorName, query) => {
+    const q0 = String(query || '').trim().toLowerCase();
+    if (!q0) return true;
+
+    const qNoAt = q0.replace(/^@/, '');
+    const aid = String(authorId || '').toLowerCase();
+    if (aid.includes(q0)) return true;
+    if (qNoAt && aid.includes('@' + qNoAt)) return true;
+
+    const nm0 = String(authorName || '').trim().toLowerCase();
+    const nmNoAt = nm0.replace(/^@/, '');
+    if (!nmNoAt) return false;
+
+    if (nm0.includes(q0)) return true;
+    if (qNoAt && nmNoAt.includes(qNoAt)) return true;
+
+    return false;
+  };
+
+  const buildNameIndexFromAbout = async (ssbClient, minLimit = 5000) => {
+    const nameByFeedId = new Map();
+    if (!ssbClient?.query?.read) return nameByFeedId;
+
+    const limit = Math.max(minLimit, logLimit);
+
+    const source = await ssbClient.query.read({
+      query: [{ $filter: { value: { content: { type: 'about' } } } }],
+      reverse: true,
+      limit
+    });
+
+    const aboutMsgs = await new Promise((resolve, reject) => {
+      pull(
+        source,
+        pull.take(limit),
+        pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs || [])))
+      );
+    });
+
+    for (const msg of aboutMsgs) {
+      const c = msg?.value?.content;
+      if (!c || c.type !== 'about') continue;
+      const aboutId = String(c.about || msg?.value?.author || '').trim();
+      const nm = typeof c.name === 'string' ? c.name.trim() : '';
+      if (!aboutId || !nm) continue;
+      if (!nameByFeedId.has(aboutId)) nameByFeedId.set(aboutId, nm);
+    }
+
+    return nameByFeedId;
+  };
+
   return {
   return {
-    async listBlockchain(filter = 'all') {
+    async listBlockchain(filter = 'all', userId, search = {}) {
       const ssbClient = await openSsb();
       const ssbClient = await openSsb();
+
       const results = await new Promise((resolve, reject) =>
       const results = await new Promise((resolve, reject) =>
         pull(
         pull(
           ssbClient.createLogStream({ reverse: true, limit: logLimit }),
           ssbClient.createLogStream({ reverse: true, limit: logLimit }),
@@ -39,11 +115,20 @@ module.exports = ({ cooler }) => {
       const idToBlock = new Map();
       const idToBlock = new Map();
       const referencedAsReplaces = new Set();
       const referencedAsReplaces = new Set();
 
 
+      const nameByFeedId = new Map();
+
       for (const msg of results) {
       for (const msg of results) {
         const k = msg.key;
         const k = msg.key;
         const c = msg.value?.content;
         const c = msg.value?.content;
         const author = msg.value?.author;
         const author = msg.value?.author;
         if (!c?.type) continue;
         if (!c?.type) continue;
+
+        if (c.type === 'about') {
+          const aboutId = String(c.about || author || '').trim();
+          const nm = typeof c.name === 'string' ? c.name.trim() : '';
+          if (aboutId && nm && !nameByFeedId.has(aboutId)) nameByFeedId.set(aboutId, nm);
+        }
+
         if (c.type === 'tombstone' && c.target) {
         if (c.type === 'tombstone' && c.target) {
           tombstoned.add(c.target);
           tombstoned.add(c.target);
           idToBlock.set(k, { id: k, author, ts: msg.value.timestamp, type: c.type, content: c });
           idToBlock.set(k, { id: k, author, ts: msg.value.timestamp, type: c.type, content: c });
@@ -104,22 +189,55 @@ module.exports = ({ cooler }) => {
       });
       });
 
 
       let filtered = blockData;
       let filtered = blockData;
+
       if (filter === 'RECENT' || filter === 'recent') {
       if (filter === 'RECENT' || filter === 'recent') {
         const now = Date.now();
         const now = Date.now();
-        filtered = blockData.filter(b => b && now - b.ts <= 24 * 60 * 60 * 1000);
+        filtered = filtered.filter(b => b && now - b.ts <= 24 * 60 * 60 * 1000);
       }
       }
       if (filter === 'MINE' || filter === 'mine') {
       if (filter === 'MINE' || filter === 'mine') {
-        filtered = blockData.filter(b => b && b.author === config.keys.id);
+        const me = userId || config.keys.id;
+        filtered = filtered.filter(b => b && b.author === me);
       }
       }
       if (filter === 'PARLIAMENT' || filter === 'parliament') {
       if (filter === 'PARLIAMENT' || filter === 'parliament') {
         const pset = new Set(['parliamentTerm','parliamentProposal','parliamentLaw','parliamentCandidature','parliamentRevocation']);
         const pset = new Set(['parliamentTerm','parliamentProposal','parliamentLaw','parliamentCandidature','parliamentRevocation']);
-        filtered = blockData.filter(b => b && pset.has(b.type));
+        filtered = filtered.filter(b => b && pset.has(b.type));
       }
       }
       if (filter === 'COURTS' || filter === 'courts') {
       if (filter === 'COURTS' || filter === 'courts') {
         const cset = new Set(['courtsCase','courtsEvidence','courtsAnswer','courtsVerdict','courtsSettlement','courtsSettlementProposal','courtsSettlementAccepted','courtsNomination','courtsNominationVote']);
         const cset = new Set(['courtsCase','courtsEvidence','courtsAnswer','courtsVerdict','courtsSettlement','courtsSettlementProposal','courtsSettlementAccepted','courtsNomination','courtsNominationVote']);
-        filtered = blockData.filter(b => b && cset.has(b.type));
+        filtered = filtered.filter(b => b && cset.has(b.type));
       }
       }
 
 
+      const s = search || {};
+      const authorQ = String(s.author || '').trim();
+      const idQ = String(s.id || '').trim();
+      const fromTs = parseTs(s.from);
+      const toTs = parseTs(s.to);
+
+      let aboutIndex = null;
+      const needsNameSearch = !!authorQ && !authorQ.toLowerCase().includes('.ed25519');
+
+      if (needsNameSearch) {
+        aboutIndex = await buildNameIndexFromAbout(ssbClient, 10000);
+        for (const [fid, nm] of aboutIndex.entries()) {
+          if (!nameByFeedId.has(fid)) nameByFeedId.set(fid, nm);
+        }
+      }
+
+      filtered = filtered.filter(b => {
+        if (!b) return false;
+        if (fromTs != null && b.ts < fromTs) return false;
+        if (toTs != null && b.ts > toTs) return false;
+
+        if (authorQ) {
+          const nm = nameByFeedId.get(b.author) || '';
+          if (!matchAuthorOrName(b.author, nm, authorQ)) return false;
+        }
+
+        if (idQ && !matchBlockId(b.id, idQ)) return false;
+
+        return true;
+      });
+
       return filtered.filter(Boolean);
       return filtered.filter(Boolean);
     },
     },
 
 
@@ -190,6 +308,7 @@ module.exports = ({ cooler }) => {
 
 
       const block = idToBlock.get(id);
       const block = idToBlock.get(id);
       if (!block) return null;
       if (!block) return null;
+
       if (block.type === 'document') {
       if (block.type === 'document') {
         const valid = await hasBlob(ssbClient, block.content.url);
         const valid = await hasBlob(ssbClient, block.content.url);
         if (!valid) return null;
         if (!valid) return null;
@@ -202,11 +321,7 @@ module.exports = ({ cooler }) => {
         ? (!liveTipIds.has(block.id) || tombstoned.has(block.id))
         ? (!liveTipIds.has(block.id) || tombstoned.has(block.id))
         : referencedAsReplaces.has(block.id) || tombstoned.has(block.id) || rootDeleted;
         : referencedAsReplaces.has(block.id) || tombstoned.has(block.id) || rootDeleted;
 
 
-      return {
-        ...block,
-        isTombstoned,
-        isReplaced
-      };
+      return { ...block, isTombstoned, isReplaced };
     }
     }
   };
   };
 };
 };

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

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

+ 1 - 1
src/server/package.json

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

+ 192 - 90
src/views/activity_view.js

@@ -58,6 +58,11 @@ function safeMsgId(x) {
   return '';
   return '';
 }
 }
 
 
+function normalizeSpreadLink(x) {
+  const s = safeMsgId(x);
+  return typeof s === 'string' && s.startsWith('thread:') ? s.slice(7) : s;
+}
+
 function stripHtml(s) {
 function stripHtml(s) {
   return String(s || '')
   return String(s || '')
     .replace(/<[^>]*>/g, ' ')
     .replace(/<[^>]*>/g, ' ')
@@ -66,7 +71,7 @@ function stripHtml(s) {
 }
 }
 
 
 function excerptPostText(content, max = 220) {
 function excerptPostText(content, max = 220) {
-  const raw = stripHtml(content?.text || '');
+  const raw = stripHtml(content?.text || content?.description || content?.title || '');
   if (!raw) return '';
   if (!raw) return '';
   return raw.length > max ? raw.slice(0, max - 1) + '…' : raw;
   return raw.length > max ? raw.slice(0, max - 1) + '…' : raw;
 }
 }
@@ -99,7 +104,7 @@ function getThreadIdFromPost(action) {
   const c = action.value?.content || action.content || {};
   const c = action.value?.content || action.content || {};
   const fork = safeMsgId(c.fork);
   const fork = safeMsgId(c.fork);
   const root = safeMsgId(c.root);
   const root = safeMsgId(c.root);
-  return fork || root || action.id;
+  return fork || root || safeMsgId(action);
 }
 }
 
 
 function getReplyToIdFromPost(action, byId) {
 function getReplyToIdFromPost(action, byId) {
@@ -117,8 +122,14 @@ function getReplyToIdFromPost(action, byId) {
 
 
 function buildActivityItemsWithPostThreads(deduped, allActions) {
 function buildActivityItemsWithPostThreads(deduped, allActions) {
   const byId = new Map();
   const byId = new Map();
-  for (const a of allActions) if (a?.id) byId.set(a.id, a);
-  for (const a of deduped) if (a?.id) byId.set(a.id, a);
+  for (const a of allActions) {
+    const id = safeMsgId(a);
+    if (id) byId.set(id, a);
+  }
+  for (const a of deduped) {
+    const id = safeMsgId(a);
+    if (id) byId.set(id, a);
+  }
 
 
   const groups = new Map();
   const groups = new Map();
   const out = [];
   const out = [];
@@ -135,7 +146,7 @@ function buildActivityItemsWithPostThreads(deduped, allActions) {
 
 
   for (const [threadId, posts] of groups.entries()) {
   for (const [threadId, posts] of groups.entries()) {
     const sortedDesc = posts.slice().sort((a, b) => (b.ts || 0) - (a.ts || 0));
     const sortedDesc = posts.slice().sort((a, b) => (b.ts || 0) - (a.ts || 0));
-    const hasReplies = sortedDesc.some(p => getThreadIdFromPost(p) !== p.id) || sortedDesc.length > 1;
+    const hasReplies = sortedDesc.some(p => getThreadIdFromPost(p) !== safeMsgId(p)) || sortedDesc.length > 1;
 
 
     if (!hasReplies || sortedDesc.length === 1) {
     if (!hasReplies || sortedDesc.length === 1) {
       out.push(sortedDesc[0]);
       out.push(sortedDesc[0]);
@@ -143,9 +154,19 @@ function buildActivityItemsWithPostThreads(deduped, allActions) {
     }
     }
 
 
     const latest = sortedDesc[0];
     const latest = sortedDesc[0];
-    const rootAction = byId.get(threadId);
+    let rootAction = byId.get(threadId);
+    let rootId = threadId;
+
+    if (!rootAction) {
+      const asc = posts.slice().sort((a, b) => (a.ts || 0) - (b.ts || 0));
+      rootAction = asc[0] || null;
+      rootId = safeMsgId(rootAction) || threadId;
+    } else {
+      rootId = safeMsgId(rootAction) || threadId;
+    }
+
     const replies = sortedDesc
     const replies = sortedDesc
-      .filter(p => p.id !== threadId)
+      .filter(p => safeMsgId(p) !== rootId)
       .slice()
       .slice()
       .sort((a, b) => (a.ts || 0) - (b.ts || 0));
       .sort((a, b) => (a.ts || 0) - (b.ts || 0));
 
 
@@ -158,13 +179,13 @@ function buildActivityItemsWithPostThreads(deduped, allActions) {
         threadId,
         threadId,
         root: rootAction
         root: rootAction
           ? {
           ? {
-              id: rootAction.id,
+              id: safeMsgId(rootAction),
               author: rootAction.author,
               author: rootAction.author,
               text: excerptPostText(rootAction.value?.content || rootAction.content || {}, 240)
               text: excerptPostText(rootAction.value?.content || rootAction.content || {}, 240)
             }
             }
           : null,
           : null,
         replies: replies.map(p => ({
         replies: replies.map(p => ({
-          id: p.id,
+          id: safeMsgId(p),
           author: p.author,
           author: p.author,
           ts: p.ts,
           ts: p.ts,
           text: excerptPostText(p.value?.content || p.content || {}, 200)
           text: excerptPostText(p.value?.content || p.content || {}, 200)
@@ -179,6 +200,34 @@ function buildActivityItemsWithPostThreads(deduped, allActions) {
 
 
 function renderActionCards(actions, userId, allActions) {
 function renderActionCards(actions, userId, allActions) {
   const all = Array.isArray(allActions) ? allActions : actions;
   const all = Array.isArray(allActions) ? allActions : actions;
+  const byIdAll = new Map();
+  for (const a0 of all) {
+    const id0 = safeMsgId(a0);
+    if (id0) byIdAll.set(id0, a0);
+  }
+  const profileById = new Map();
+  for (const a0 of all) {
+    if (!a0 || a0.type !== 'about') continue;
+    const c0 = a0.value?.content || a0.content || {};
+    const id0 = c0.about || a0.author || '';
+    if (!id0) continue;
+    const name0 = (typeof c0.name === 'string' && c0.name.trim()) ? c0.name.trim() : id0;
+    const image0 = (typeof c0.image === 'string' && c0.image.trim()) ? c0.image.trim() : '';
+    const ts0 = a0.ts || 0;
+    const prev = profileById.get(id0);
+    if (!prev) {
+      profileById.set(id0, { id: id0, name: name0, image: image0, ts: ts0 });
+      continue;
+    }
+    const prevHasImg = !!prev.image;
+    const newHasImg = !!image0;
+    if (!prevHasImg && newHasImg) profileById.set(id0, { id: id0, name: name0, image: image0, ts: ts0 });
+    else if (prevHasImg === newHasImg && ts0 > (prev.ts || 0)) profileById.set(id0, { id: id0, name: name0, image: image0, ts: ts0 });
+  }
+  const getProfile = (id) => {
+    const p0 = profileById.get(id);
+    return p0 ? { id: p0.id, name: p0.name, image: p0.image } : { id, name: id, image: '' };
+  };
   const validActions = actions
   const validActions = actions
     .filter(action => {
     .filter(action => {
       const content = action.value?.content || action.content;
       const content = action.value?.content || action.content;
@@ -212,23 +261,25 @@ function renderActionCards(actions, userId, allActions) {
 
 
   const seenDocumentTitles = new Set();
   const seenDocumentTitles = new Set();
   const items = buildActivityItemsWithPostThreads(deduped, all);
   const items = buildActivityItemsWithPostThreads(deduped, all);
+
   const spreadOrdinalById = new Map();
   const spreadOrdinalById = new Map();
   const spreadsByLink = new Map();
   const spreadsByLink = new Map();
 
 
   for (const a of all) {
   for (const a of all) {
     if (!a || a.type !== 'spread') continue;
     if (!a || a.type !== 'spread') continue;
     const c = a.value?.content || a.content || {};
     const c = a.value?.content || a.content || {};
-    const link = c.spreadTargetId || c.vote?.link || '';
-    if (!link || !a.id) continue;
+    const link = normalizeSpreadLink(c.spreadTargetId || c.vote?.link || '');
+    const aId = safeMsgId(a);
+    if (!link || !aId) continue;
     if (!spreadsByLink.has(link)) spreadsByLink.set(link, []);
     if (!spreadsByLink.has(link)) spreadsByLink.set(link, []);
     spreadsByLink.get(link).push(a);
     spreadsByLink.get(link).push(a);
   }
   }
 
 
   for (const list of spreadsByLink.values()) {
   for (const list of spreadsByLink.values()) {
-    list.sort((a, b) => (a.ts || 0) - (b.ts || 0) || String(a.id || '').localeCompare(String(b.id || '')));
+    list.sort((a, b) => (a.ts || 0) - (b.ts || 0) || String(safeMsgId(a) || '').localeCompare(String(safeMsgId(b) || '')));
     for (let i = 0; i < list.length; i++) {
     for (let i = 0; i < list.length; i++) {
-      spreadOrdinalById.set(list[i].id, i + 1);
-    }
+      spreadOrdinalById.set(safeMsgId(list[i]), i + 1);
+    } 
   }
   }
 
 
   const cards = items.map(action => {
   const cards = items.map(action => {
@@ -487,7 +538,7 @@ function renderActionCards(actions, userId, allActions) {
       const { url } = content;
       const { url } = content;
       cardBody.push(
       cardBody.push(
         div({ class: 'card-section image' },
         div({ class: 'card-section image' },
-          img({ src: `/blob/${encodeURIComponent(url)}`, class: 'feed-image img-content' })
+          img({ src: `/blob/${encodeURIComponent(url)}`, class: 'activity-image-thumb' })
         )
         )
       );
       );
     }
     }
@@ -635,7 +686,7 @@ function renderActionCards(actions, userId, allActions) {
       );
       );
     }
     }
 
 
-    if (type === 'post') {
+  if (type === 'post') {
       const { contentWarning, text } = content || {};
       const { contentWarning, text } = content || {};
       const rawText = text || '';
       const rawText = text || '';
       const isHtml = typeof rawText === 'string' && /<\/?[a-z][\s\S]*>/i.test(rawText);
       const isHtml = typeof rawText === 'string' && /<\/?[a-z][\s\S]*>/i.test(rawText);
@@ -649,20 +700,28 @@ function renderActionCards(actions, userId, allActions) {
               (url) =>
               (url) =>
                 `<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`
                 `<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`
             );
             );
-        bodyNode = div({ class: 'post-text', innerHTML: linkified });
+       bodyNode = div({ class: 'post-text', innerHTML: linkified });
       } else {
       } else {
-        bodyNode = p({ class: 'post-text' }, ...renderUrl(rawText));
+        bodyNode = p({ class: 'post-text post-text-pre' }, ...renderUrlPreserveNewlines(rawText));
       }
       }
-      const byId = new Map(actions.map(a => [a.id, a]));
       const threadId = getThreadIdFromPost(action);
       const threadId = getThreadIdFromPost(action);
-      const replyToId = getReplyToIdFromPost(action, byId);
-      if (threadId && threadId !== action.id) {
-        const ctxHref = `/thread/${encodeURIComponent(threadId)}#${encodeURIComponent(replyToId || threadId)}`;
-        const parent = byId.get(replyToId) || byId.get(threadId);
-        const parentAuthor = parent?.author;
-      }
+      const replyToId = getReplyToIdFromPost(action, byIdAll);
+      const isReply = !!(threadId && threadId !== action.id);
+      const ctxHref = isReply ? `/thread/${encodeURIComponent(threadId)}#${encodeURIComponent(replyToId || threadId)}` : '';
+      const parent = isReply ? (byIdAll.get(replyToId) || byIdAll.get(threadId)) : null;
+      const parentContent = parent ? (parent.value?.content || parent.content || {}) : {};
+      const parentAuthor = parent?.author || '';
+      const parentText = parent ? excerptPostText(parentContent, 220) : '';
       cardBody.push(
       cardBody.push(
         div({ class: 'card-section post' },
         div({ class: 'card-section post' },
+          isReply
+            ? div(
+                { class: 'reply-context' },
+                a({ href: ctxHref, class: 'tag-link' }, i18n.inReplyTo || 'IN REPLY TO'),
+                parentAuthor ? span({ class: 'reply-context-author' }, a({ href: `/author/${encodeURIComponent(parentAuthor)}`, class: 'user-link' }, parentAuthor)) : '',
+                parentText ? p({ class: 'post-text reply-context-text post-text-pre' }, ...renderUrlPreserveNewlines(parentText)) : ''
+              )
+            : '',
           contentWarning ? h2({ class: 'content-warning' }, contentWarning) : '',
           contentWarning ? h2({ class: 'content-warning' }, contentWarning) : '',
           bodyNode
           bodyNode
         )
         )
@@ -689,18 +748,18 @@ function renderActionCards(actions, userId, allActions) {
                 )
                 )
             ),
             ),
             div({ class: 'card-body' },
             div({ class: 'card-body' },
-                root && root.text
-                    ? div({ class: 'card-section' },
-                        p({ class: 'post-text' }, ...renderUrl(root.text))
-                    )
-                    : '',
-                div({ class: 'card-section' },
+		root && root.text
+		    ? div({ class: 'card-section' },
+			p({ class: 'post-text', style: 'white-space:pre-wrap;' }, ...renderUrlPreserveNewlines(root.text))
+		    )
+		    : '',
+		div({ class: 'card-section' },
 		show.map(r => {
 		show.map(r => {
 		    const commentHref = `/thread/${encodeURIComponent(threadId)}#${encodeURIComponent(r.id)}`;
 		    const commentHref = `/thread/${encodeURIComponent(threadId)}#${encodeURIComponent(r.id)}`;
 		    const rDate = r.ts ? new Date(r.ts).toLocaleString() : '';
 		    const rDate = r.ts ? new Date(r.ts).toLocaleString() : '';
 		    return div({ class: 'thread-reply-item' },
 		    return div({ class: 'thread-reply-item' },
 			div({ class: 'thread-reply' },
 			div({ class: 'thread-reply' },
-			    r.text ? p({ class: 'post-text' }, ...renderUrl(r.text)) : ''
+			    r.text ? p({ class: 'post-text', style: 'white-space:pre-wrap;' }, ...renderUrlPreserveNewlines(r.text)) : ''
 			),
 			),
 			div({ class: 'card-footer thread-reply-footer' },
 			div({ class: 'card-footer thread-reply-footer' },
 			    span({ class: 'date-link' }, rDate),
 			    span({ class: 'date-link' }, rDate),
@@ -711,11 +770,6 @@ function renderActionCards(actions, userId, allActions) {
 			)
 			)
 		    );
 		    );
 		}),
 		}),
-                overflow
-                    ? div({ style: 'display:flex; justify-content:center; margin-top:12px;' },
-                        a({ class: 'filter-btn', href: viewMoreHref }, i18n.continueReading)
-                    )
-                    : ''
             )
             )
         ),
         ),
         p({ class: 'card-footer' },
         p({ class: 'card-footer' },
@@ -758,42 +812,55 @@ function renderActionCards(actions, userId, allActions) {
       }
       }
     }
     }
 
 
-    if (type === 'spread') {
-        const { spreadOriginalAuthor, spreadTitle, spreadContentWarning, spreadText, spreadTotalSpreads } = content || {};
-        const spreadsLabel = (i18n.totalspreads || 'Total spreads') + ':';
-        const total = Number(spreadTotalSpreads || 0);
-        cardBody.push(
-            div({ class: 'card-section vote' },
-                spreadTitle
-                    ? h2(
-                        { class: 'post-title', style: 'margin:.25rem 0 .55rem 0; font-size:1.08em;' },
-                        spreadTitle
-                    )
-                    : '',
-                spreadContentWarning ? h2({ class: 'content-warning' }, spreadContentWarning) : '',
-                spreadText
-                    ? div(
-                        {
-                            class: 'post-text',
-                            style: 'white-space:pre-wrap; line-height:1.45; font-size:1.02em; word-break:break-word; overflow-wrap:anywhere;'
-                        },
-                        ...renderUrlPreserveNewlines(spreadText)
-                    )
-                    : '',
-                spreadOriginalAuthor
-                    ? div(
-                        { class: 'card-field' },
-                        span({ class: 'card-value' }, a({ href: `/author/${encodeURIComponent(spreadOriginalAuthor)}`, class: 'user-link' }, spreadOriginalAuthor))
-                    )
-                    : '',
-                div(
-                    { class: 'card-field' },
-                    span({ class: 'card-label' }, spreadsLabel),
-                    span({ class: 'card-value' }, String(total))
-                )
-            )
-        );
-    }
+ if (type === 'spread') {
+  const link = normalizeSpreadLink(content?.spreadTargetId || content?.vote?.link || '');
+  const target = link ? (byIdAll.get(link) || byIdAll.get(decodeMaybe(link)) || byIdAll.get(encodeURIComponent(link))) : null;
+  const tContent = target ? (target.value?.content || target.content || {}) : {};
+  const spreadTitle =
+    (typeof content?.spreadTitle === 'string' && content.spreadTitle.trim())
+      ? content.spreadTitle.trim()
+      : (typeof tContent?.title === 'string' && tContent.title.trim())
+        ? tContent.title.trim()
+        : (typeof tContent?.name === 'string' && tContent.name.trim())
+          ? tContent.name.trim()
+          : '';
+  const spreadContentWarning =
+    (typeof content?.spreadContentWarning === 'string' && content.spreadContentWarning.trim())
+      ? content.spreadContentWarning.trim()
+      : (typeof tContent?.contentWarning === 'string' && tContent.contentWarning.trim())
+        ? tContent.contentWarning.trim()
+        : '';
+  const spreadText =
+    (typeof content?.spreadText === 'string' && content.spreadText.trim())
+      ? content.spreadText.trim()
+      : excerptPostText(tContent, 700);
+  const spreadOriginalAuthor =
+    (typeof content?.spreadOriginalAuthor === 'string' && content.spreadOriginalAuthor.trim())
+      ? content.spreadOriginalAuthor.trim()
+      : (target?.author || '');
+  const ord = spreadOrdinalById.get(safeMsgId(action)) || 0;
+  const totalChron = link && spreadsByLink.has(link) ? spreadsByLink.get(link).length : 0;
+  const label = (i18n.spreadChron || 'Spread') + ':';
+  const value = ord && totalChron ? `${ord}/${totalChron}` : (ord ? String(ord) : '');
+  cardBody.push(
+    div({ class: 'card-section vote' },
+      spreadTitle ? h2({ class: 'post-title activity-spread-title' }, spreadTitle) : '',
+      spreadContentWarning ? h2({ class: 'content-warning' }, spreadContentWarning) : '',
+      spreadText ? div({ class: 'post-text activity-spread-text post-text-pre' }, ...renderUrlPreserveNewlines(spreadText)) : '',
+      spreadOriginalAuthor
+        ? div({ class: 'card-field' },
+            span({ class: 'card-value' }, a({ href: `/author/${encodeURIComponent(spreadOriginalAuthor)}`, class: 'user-link' }, spreadOriginalAuthor))
+          )
+        : '',
+      value
+        ? div({ class: 'card-field' },
+            span({ class: 'card-label' }, label),
+            span({ class: 'card-value' }, value)
+          )
+        : ''
+    )
+  );
+}
 
 
     if (type === 'vote') {
     if (type === 'vote') {
       const { vote } = content;
       const { vote } = content;
@@ -812,33 +879,48 @@ function renderActionCards(actions, userId, allActions) {
         div({ class: 'card-section about' },
         div({ class: 'card-section about' },
           h2(a({ href: `/author/${encodeURIComponent(about)}`, class: "user-link" }, `@`, name)),
           h2(a({ href: `/author/${encodeURIComponent(about)}`, class: "user-link" }, `@`, name)),
           image
           image
-            ? img({ src: `/blob/${encodeURIComponent(image)}` })
-            : img({ src: '/assets/images/default-avatar.png', alt: name })
+            ? img({ src: `/blob/${encodeURIComponent(image)}`, alt: name, class: 'activity-avatar' })
+            : img({ src: '/assets/images/default-avatar.png', alt: name, class: 'activity-avatar' })
         )
         )
       );
       );
     }
     }
 
 
     if (type === 'contact') {
     if (type === 'contact') {
-      const { contact } = content;
+      const { contact } = content || {};
+      const aId = action.author || '';
+      const bId = contact || '';
+      const pa = getProfile(aId);
+      const pb = getProfile(bId);
+      const srcA = pa.image ? `/blob/${encodeURIComponent(pa.image)}` : '/assets/images/default-avatar.png';
+      const srcB = pb.image ? `/blob/${encodeURIComponent(pb.image)}` : '/assets/images/default-avatar.png';
       cardBody.push(
       cardBody.push(
         div({ class: 'card-section contact' },
         div({ class: 'card-section contact' },
-          p({ class: 'card-field' },
-            a({ href: `/author/${encodeURIComponent(contact)}`, class: "user-link"}, contact)
+          div({ class: 'activity-contact' },
+            a({ href: `/author/${encodeURIComponent(aId)}`, class: 'activity-contact-avatar-link' },
+              img({ src: srcA, alt: pa.name || pa.id, class: 'activity-contact-avatar' })
+            ),
+            span({ class: 'activity-contact-arrow' }, ''),
+            a({ href: `/author/${encodeURIComponent(bId)}`, class: 'activity-contact-avatar-link' },
+              img({ src: srcB, alt: pb.name || pb.id, class: 'activity-contact-avatar' })
+            )
           )
           )
         )
         )
       );
       );
     }
     }
 
 
     if (type === 'pub') {
     if (type === 'pub') {
-      const { address } = content;
-      const { host, key } = address || {};
+      const { address } = content || {};
+      const { key } = address || {};
+      const pr = getProfile(key || '');
+      const src = pr.image ? `/blob/${encodeURIComponent(pr.image)}` : '/assets/images/default-avatar.png';
       cardBody.push(
       cardBody.push(
-        div({ class: 'card-section pub' },
-          p({ class: 'card-field' },
-            a({ href: `/author/${encodeURIComponent(key || '')}`, class: "user-link" }, key || '')
-          )
+        div({ class: 'card-section pub activity-pub' },
+          br(),
+          a({ href: `/author/${encodeURIComponent(pr.id)}`, class: 'user-link' }, pr.name || pr.id),
+          br(),
+          img({ src, alt: pr.name || pr.id, class: 'activity-avatar' })
         )
         )
-      );
+     );
     }
     }
 
 
     if (type === 'market') {
     if (type === 'market') {
@@ -1271,7 +1353,7 @@ function renderActionCards(actions, userId, allActions) {
 }
 }
 
 
 function getViewDetailsAction(type, action) {
 function getViewDetailsAction(type, action) {
-  const id = encodeURIComponent(action.tipId || action.id);
+  const id = encodeURIComponent(safeMsgId(action.tipId || action.id || action.key || action));
   switch (type) {
   switch (type) {
     case 'parliamentCandidature':   return `/parliament?filter=candidatures`;
     case 'parliamentCandidature':   return `/parliament?filter=candidatures`;
     case 'parliamentTerm':          return `/parliament?filter=government`;
     case 'parliamentTerm':          return `/parliament?filter=government`;
@@ -1293,9 +1375,11 @@ function getViewDetailsAction(type, action) {
     case 'tribeFeedRefeed':
     case 'tribeFeedRefeed':
     return `/tribe/${encodeURIComponent(action.content?.tribeId || '')}`;
     return `/tribe/${encodeURIComponent(action.content?.tribeId || '')}`;
     case 'spread': {
     case 'spread': {
-      const link = action.content?.spreadTargetId || action.content?.vote?.link || '';
+      const link = normalizeSpreadLink(action.content?.spreadTargetId || action.content?.vote?.link || '');
       return link ? `/thread/${encodeURIComponent(link)}#${encodeURIComponent(link)}` : `/activity`;
       return link ? `/thread/${encodeURIComponent(link)}#${encodeURIComponent(link)}` : `/activity`;
     }
     }
+    case 'post':       return `/thread/${id}#${id}`;
+    case 'vote':       return `/thread/${encodeURIComponent(action.content.vote.link)}#${encodeURIComponent(action.content.vote.link)}`;
     case 'votes':      return `/votes/${id}`;
     case 'votes':      return `/votes/${id}`;
     case 'transfer':   return `/transfers/${id}`;
     case 'transfer':   return `/transfers/${id}`;
     case 'pixelia':    return `/pixelia`;
     case 'pixelia':    return `/pixelia`;
@@ -1312,8 +1396,6 @@ function getViewDetailsAction(type, action) {
     case 'task':       return `/tasks/${id}`;
     case 'task':       return `/tasks/${id}`;
     case 'taskAssignment': return `/tasks/${encodeURIComponent(action.content?.taskId || action.tipId || action.id)}`;
     case 'taskAssignment': return `/tasks/${encodeURIComponent(action.content?.taskId || action.tipId || action.id)}`;
     case 'about':      return `/author/${encodeURIComponent(action.author)}`;
     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)}`;
     case 'contact':    return `/inhabitants`;
     case 'contact':    return `/inhabitants`;
     case 'pub':        return `/invites`;
     case 'pub':        return `/invites`;
     case 'market':     return `/market/${id}`;
     case 'market':     return `/market/${id}`;
@@ -1326,7 +1408,7 @@ function getViewDetailsAction(type, action) {
   }
   }
 }
 }
 
 
-exports.activityView = (actions, filter, userId) => {
+exports.activityView = (actions, filter, userId, q = '') => {
   const title = filter === 'mine' ? i18n.yourActivity : i18n.globalActivity;
   const title = filter === 'mine' ? i18n.yourActivity : i18n.globalActivity;
   const desc = i18n.activityDesc;
   const desc = i18n.activityDesc;
 
 
@@ -1391,6 +1473,26 @@ exports.activityView = (actions, filter, userId) => {
     filteredActions = actions.filter(action => (action.type === filter || filter === 'all') && action.type !== 'tombstone');
     filteredActions = actions.filter(action => (action.type === filter || filter === 'all') && action.type !== 'tombstone');
   }
   }
 
 
+  const qs = String(q || '').trim();
+  if (qs) {
+    const qn = qs.toLowerCase();
+    filteredActions = filteredActions.filter(a0 => {
+      const t = String(a0.type || '').toLowerCase();
+      const author = String(a0.author || '').toLowerCase();
+      const id0 = String(a0.id || '').toLowerCase();
+      const c0 = a0.value?.content || a0.content || {};
+      const blob = [
+        t, author, id0,
+        c0.text, c0.title, c0.name, c0.description, c0.contentWarning,
+        c0.about, c0.contact,
+        c0.spreadTitle, c0.spreadText, c0.spreadOriginalAuthor,
+        c0.vote?.link,
+        c0.address?.host, c0.address?.key
+      ].filter(Boolean).join(' ').toLowerCase();
+      return blob.includes(qn);
+    });
+  }
+
   let html = template(
   let html = template(
     title,
     title,
     section(
     section(

+ 90 - 18
src/views/blockchain_view.js

@@ -20,6 +20,33 @@ const CAT_BLOCK2  = ['pub', 'tribe', 'about', 'contact', 'curriculum', 'vote', '
 const CAT_BLOCK3  = ['banking', 'job', 'market', 'project', 'transfer', 'feed', 'post', 'pixelia'];
 const CAT_BLOCK3  = ['banking', 'job', 'market', 'project', 'transfer', 'feed', 'post', 'pixelia'];
 const CAT_BLOCK4  = ['forum', 'bookmark', 'image', 'video', 'audio', 'document'];
 const CAT_BLOCK4  = ['forum', 'bookmark', 'image', 'video', 'audio', 'document'];
 
 
+const SEARCH_FIELDS = ['author','id','from','to'];
+
+const hiddenSearchInputs = (search) =>
+  SEARCH_FIELDS.map(k => {
+    const v = String(search?.[k] ?? '').trim();
+    return v ? input({ type: 'hidden', name: k, value: v }) : null;
+  }).filter(Boolean);
+
+const toDatetimeLocal = (s) => {
+  const raw = String(s || '').trim();
+  if (!raw) return '';
+  const ts = new Date(raw).getTime();
+  if (!Number.isFinite(ts)) return '';
+  return moment(ts).format('YYYY-MM-DDTHH:mm');
+};
+
+const toQueryString = (filter, search = {}) => {
+  const parts = [];
+  const f = String(filter || '').trim();
+  if (f) parts.push(`filter=${encodeURIComponent(f)}`);
+  for (const k of SEARCH_FIELDS) {
+    const v = String(search?.[k] ?? '').trim();
+    if (v) parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(v)}`);
+  }
+  return parts.length ? `?${parts.join('&')}` : '';
+};
+
 const filterBlocks = (blocks, filter, userId) => {
 const filterBlocks = (blocks, filter, userId) => {
   if (filter === 'recent') return blocks.filter(b => Date.now() - b.ts < 24*60*60*1000);
   if (filter === 'recent') return blocks.filter(b => Date.now() - b.ts < 24*60*60*1000);
   if (filter === 'mine') return blocks.filter(b => b.author === userId);
   if (filter === 'mine') return blocks.filter(b => b.author === userId);
@@ -36,11 +63,12 @@ const filterBlocks = (blocks, filter, userId) => {
   return blocks.filter(b => b.type === filter);
   return blocks.filter(b => b.type === filter);
 };
 };
 
 
-const generateFilterButtons = (filters, currentFilter, action) =>
+const generateFilterButtons = (filters, currentFilter, action, search = {}) =>
   div({ class: 'mode-buttons-cols' },
   div({ class: 'mode-buttons-cols' },
     filters.map(mode =>
     filters.map(mode =>
       form({ method: 'GET', action },
       form({ method: 'GET', action },
         input({ type: 'hidden', name: 'filter', value: mode }),
         input({ type: 'hidden', name: 'filter', value: mode }),
+        ...hiddenSearchInputs(search),
         button({
         button({
           type: 'submit',
           type: 'submit',
           class: currentFilter === mode ? 'filter-btn active' : 'filter-btn'
           class: currentFilter === mode ? 'filter-btn active' : 'filter-btn'
@@ -93,8 +121,21 @@ const getViewDetailsAction = (type, block) => {
   }
   }
 };
 };
 
 
-const renderSingleBlockView = (block, filter) =>
-  template(
+const renderSingleBlockView = (block, filter = 'recent', userId, search = {}) => {
+  if (!block) {
+    return template(
+      i18n.blockchain,
+      section(
+        div({ class: 'tags-header' },
+          h2(i18n.blockchain),
+          p(i18n.blockchainDescription)
+        ),
+        p(i18n.blockchainNoBlocks || 'No blocks')
+      )
+    );
+  }
+
+  return template(
     i18n.blockchain,
     i18n.blockchain,
     section(
     section(
       div({ class: 'tags-header' },
       div({ class: 'tags-header' },
@@ -103,15 +144,15 @@ const renderSingleBlockView = (block, filter) =>
       ),
       ),
       div({ class: 'mode-buttons-row' },
       div({ class: 'mode-buttons-row' },
         div({ style: 'display:flex;flex-direction:column;gap:8px;' },
         div({ style: 'display:flex;flex-direction:column;gap:8px;' },
-          generateFilterButtons(BASE_FILTERS, filter, '/blockexplorer')
+          generateFilterButtons(BASE_FILTERS, filter, '/blockexplorer', search)
         ),
         ),
         div({ style: 'display:flex;flex-direction:column;gap:8px;' },
         div({ style: 'display:flex;flex-direction:column;gap:8px;' },
-          generateFilterButtons(CAT_BLOCK1, filter, '/blockexplorer'),
-          generateFilterButtons(CAT_BLOCK2, filter, '/blockexplorer')
+          generateFilterButtons(CAT_BLOCK1, filter, '/blockexplorer', search),
+          generateFilterButtons(CAT_BLOCK2, filter, '/blockexplorer', search)
         ),
         ),
         div({ style: 'display:flex;flex-direction:column;gap:8px;' },
         div({ style: 'display:flex;flex-direction:column;gap:8px;' },
-          generateFilterButtons(CAT_BLOCK3, filter, '/blockexplorer'),
-          generateFilterButtons(CAT_BLOCK4, filter, '/blockexplorer')
+          generateFilterButtons(CAT_BLOCK3, filter, '/blockexplorer', search),
+          generateFilterButtons(CAT_BLOCK4, filter, '/blockexplorer', search)
         )
         )
       ),
       ),
       div({ class: 'block-single' },
       div({ class: 'block-single' },
@@ -136,6 +177,8 @@ const renderSingleBlockView = (block, filter) =>
       ),
       ),
       div({ class:'block-row block-row--back' },
       div({ class:'block-row block-row--back' },
         form({ method:'GET', action:'/blockexplorer' },
         form({ method:'GET', action:'/blockexplorer' },
+          input({ type: 'hidden', name: 'filter', value: filter }),
+          ...hiddenSearchInputs(search),
           button({ type:'submit', class:'filter-btn' }, `← ${i18n.blockchainBack}`)
           button({ type:'submit', class:'filter-btn' }, `← ${i18n.blockchainBack}`)
         ),
         ),
         !block.isTombstoned && !block.isReplaced && getViewDetailsAction(block.type, block) ?
         !block.isTombstoned && !block.isReplaced && getViewDetailsAction(block.type, block) ?
@@ -150,9 +193,19 @@ const renderSingleBlockView = (block, filter) =>
       )
       )
     )
     )
   );
   );
+};
 
 
-const renderBlockchainView = (blocks, filter, userId) =>
-  template(
+const renderBlockchainView = (blocks, filter, userId, search = {}) => {
+  const s = search || {};
+  const authorVal = String(s.author || '');
+  const idVal = String(s.id || '');
+  const fromVal = toDatetimeLocal(s.from);
+  const toVal = toDatetimeLocal(s.to);
+
+  const shown = filterBlocks(blocks, filter, userId);
+  const qs = toQueryString(filter, s);
+
+  return template(
     i18n.blockchain,
     i18n.blockchain,
     section(
     section(
       div({ class:'tags-header' },
       div({ class:'tags-header' },
@@ -161,20 +214,38 @@ const renderBlockchainView = (blocks, filter, userId) =>
       ),
       ),
       div({ class:'mode-buttons-row' },
       div({ class:'mode-buttons-row' },
         div({ style:'display:flex;flex-direction:column;gap:8px;' },
         div({ style:'display:flex;flex-direction:column;gap:8px;' },
-          generateFilterButtons(BASE_FILTERS,filter,'/blockexplorer')
+          generateFilterButtons(BASE_FILTERS, filter, '/blockexplorer', s)
         ),
         ),
         div({ style:'display:flex;flex-direction:column;gap:8px;' },
         div({ style:'display:flex;flex-direction:column;gap:8px;' },
-          generateFilterButtons(CAT_BLOCK1,filter,'/blockexplorer'),
-          generateFilterButtons(CAT_BLOCK2,filter,'/blockexplorer')
+          generateFilterButtons(CAT_BLOCK1, filter, '/blockexplorer', s),
+          generateFilterButtons(CAT_BLOCK2, filter, '/blockexplorer', s)
         ),
         ),
         div({ style:'display:flex;flex-direction:column;gap:8px;' },
         div({ style:'display:flex;flex-direction:column;gap:8px;' },
-          generateFilterButtons(CAT_BLOCK3,filter,'/blockexplorer'),
-          generateFilterButtons(CAT_BLOCK4,filter,'/blockexplorer')
+          generateFilterButtons(CAT_BLOCK3, filter, '/blockexplorer', s),
+          generateFilterButtons(CAT_BLOCK4, filter, '/blockexplorer', s)
         )
         )
       ),
       ),
-      filterBlocks(blocks,filter,userId).length===0
+	div({ class: 'blockexplorer-search' },
+	  form({ method: 'GET', action: '/blockexplorer', class: 'blockexplorer-search-form' },
+	    input({ type: 'hidden', name: 'filter', value: filter }),
+	    div({ class: 'blockexplorer-search-row' },
+	      div({ class: 'blockexplorer-search-pair' },
+		input({ type: 'text', name: 'id', value: idVal, placeholder: i18n.blockchainBlockID, class: 'blockexplorer-search-input' }),
+		input({ type: 'text', name: 'author', value: authorVal, placeholder: i18n.courtsJudgeIdPh, class: 'blockexplorer-search-input' })
+	      ),
+	      div({ class: 'blockexplorer-search-dates' },
+		input({ type: 'datetime-local', name: 'from', value: fromVal, class: 'blockexplorer-search-input' }),
+		input({ type: 'datetime-local', name: 'to', value: toVal, class: 'blockexplorer-search-input' })
+	      ),
+	      div({ class: 'blockexplorer-search-actions' },
+		button({ type: 'submit', class: 'filter-box__button' }, i18n.searchSubmit)
+	      )
+	    )
+	  )
+	),
+      shown.length === 0
         ? div(p(i18n.blockchainNoBlocks))
         ? div(p(i18n.blockchainNoBlocks))
-        : filterBlocks(blocks,filter,userId)
+        : shown
             .sort((a,b)=>{
             .sort((a,b)=>{
               const ta = a.type==='market'&&a.content.updatedAt
               const ta = a.type==='market'&&a.content.updatedAt
                 ? new Date(a.content.updatedAt).getTime()
                 ? new Date(a.content.updatedAt).getTime()
@@ -187,7 +258,7 @@ const renderBlockchainView = (blocks, filter, userId) =>
             .map(block=>
             .map(block=>
               div({ class:'block' },
               div({ class:'block' },
                 div({ class:'block-buttons' },
                 div({ class:'block-buttons' },
-                  a({ href:`/blockexplorer/block/${encodeURIComponent(block.id)}`, class:'btn-singleview', title:i18n.blockchainDetails },'⦿'),
+                  a({ href:`/blockexplorer/block/${encodeURIComponent(block.id)}${qs}`, class:'btn-singleview', title:i18n.blockchainDetails },'⦿'),
                   !block.isTombstoned && !block.isReplaced && getViewDetailsAction(block.type, block) ?
                   !block.isTombstoned && !block.isReplaced && getViewDetailsAction(block.type, block) ?
                     form({ method:'GET', action:getViewDetailsAction(block.type, block) },
                     form({ method:'GET', action:getViewDetailsAction(block.type, block) },
                       button({ type:'submit', class:'filter-btn' }, i18n.visitContent)
                       button({ type:'submit', class:'filter-btn' }, i18n.visitContent)
@@ -210,6 +281,7 @@ const renderBlockchainView = (blocks, filter, userId) =>
             )
             )
     )
     )
   );
   );
+};
 
 
 module.exports = { renderBlockchainView, renderSingleBlockView };
 module.exports = { renderBlockchainView, renderSingleBlockView };