Browse Source

Oasis release 0.6.1

psy 2 days ago
parent
commit
e77a6b7632

+ 12 - 0
docs/CHANGELOG.md

@@ -13,6 +13,18 @@ All notable changes to this project will be documented in this file.
 ### Security
 -->
 
+## v0.6.1 - 2025-12-01
+
+### Changed
+
+ + Added more notifications for tribes activity (Activity plugin).
+ + Reordered filters (Opinions plugin).
+ 
+### Fixed
+
+ + Feed minor changes (Feed plugin).
+ + Tribes feed styles container (Tribes plugin).
+
 ## v0.6.0 - 2025-11-29
 
 ### Changed

+ 20 - 16
src/backend/backend.js

@@ -1639,13 +1639,17 @@ router
     const opinions = await opinionsModel.listOpinions(filter);
     ctx.body = await opinionsView(opinions, filter);
   })
-  .get('/feed', async ctx => {
-    const filter = ctx.query.filter || 'ALL';
-    const feeds = await feedModel.listFeeds(filter);
-    ctx.body = feedView(feeds, filter);
-  })
-  .get('/feed/create', async ctx => {
-    ctx.body = feedCreateView();
+  .get("/feed", async (ctx) => {
+    const filter = String(ctx.query.filter || "ALL").toUpperCase();
+    const q = typeof ctx.query.q === "string" ? ctx.query.q : "";
+    const tag = typeof ctx.query.tag === "string" ? ctx.query.tag : "";
+    const feeds = await feedModel.listFeeds({ filter, q, tag });
+    ctx.body = feedView(feeds, { filter, q, tag });
+  })
+  .get("/feed/create", async (ctx) => {
+    const q = typeof ctx.query.q === "string" ? ctx.query.q : "";
+    const tag = typeof ctx.query.tag === "string" ? ctx.query.tag : "";
+    ctx.body = feedCreateView({ q, tag });
   })
   .get('/forum', async ctx => {
     const forumMod = ctx.cookies.get("forumMod") || 'on';
@@ -2569,19 +2573,19 @@ router
     await agendaModel.restoreItem(itemId);
     ctx.redirect('/agenda?filter=discarded');
   })
-  .post('/feed/create', koaBody(), async ctx => {
-    const { text } = ctx.request.body || {};
-    await feedModel.createFeed(text.trim());
-    ctx.redirect('/feed');
+  .post("/feed/create", koaBody(), async (ctx) => {
+    const text = ctx.request.body && ctx.request.body.text != null ? String(ctx.request.body.text) : "";
+    await feedModel.createFeed(text);
+    ctx.redirect(ctx.get("Referer") || "/feed");
   })
-  .post('/feed/opinions/:feedId/:category', async ctx => {
+  .post("/feed/opinions/:feedId/:category", async (ctx) => {
     const { feedId, category } = ctx.params;
-    await opinionsModel.createVote(feedId, category);
-    ctx.redirect('/feed');
+    await feedModel.addOpinion(feedId, category);
+    ctx.redirect(ctx.get("Referer") || "/feed");
   })
-  .post('/feed/refeed/:id', koaBody(), async ctx => {
+  .post("/feed/refeed/:id", koaBody(), async (ctx) => {
     await feedModel.createRefeed(ctx.params.id);
-    ctx.redirect('/feed');
+    ctx.redirect(ctx.get("Referer") || "/feed");
   })
   .post('/bookmarks/create', koaBody(), async (ctx) => {
     const { url, tags, description, category, lastVisit } = ctx.request.body;

+ 119 - 8
src/client/assets/styles/style.css

@@ -1083,6 +1083,27 @@ button.create-button:hover {
   border-radius: 6px;
 }
 
+.feed-search-form {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  flex-wrap: wrap;
+}
+
+.feed-search-input {
+  flex: 1 1 260px;
+  min-width: 220px;
+  height: 40px;
+  box-sizing: border-box;
+}
+
+.feed-search-btn {
+  height: 40px;
+  display: inline-flex;
+  align-items: center;
+  box-sizing: border-box;
+}
+
 .type-label {
   font-weight: bold;
   margin-top: 0.5em;
@@ -1191,19 +1212,109 @@ display:flex; gap:8px; margin-top:16px;
   cursor: not-allowed;
 }
 
-.tribe-feed form {
+.tribe-feed .feed-row,
+.tribe-feed-full .feed-row {
+  display: flex;
+  align-items: flex-start;
+  gap: 12px;
+}
+
+.tribe-feed .refeed-column,
+.tribe-feed-full .refeed-column {
+  flex: 0 0 68px;
+  min-width: 68px;
+  max-width: 68px;
+  padding: 10px 8px;
+}
+
+.tribe-feed .refeed-column h1,
+.tribe-feed-full .refeed-column h1 {
+  margin: 0 0 8px 0;
+  font-size: 24px;
+}
+
+.tribe-feed .refeed-btn,
+.tribe-feed-full .refeed-btn {
+  padding: 6px 8px;
+  font-size: 12px;
+  width: 100%;
+}
+
+.tribe-feed .feed-main,
+.tribe-feed-full .feed-main {
+  flex: 1 1 auto;
+  min-width: 0;
+  overflow-wrap: anywhere;
+  word-break: break-word;
+}
+
+@media (max-width: 720px) {
+  .tribe-feed .feed-row,
+  .tribe-feed-full .feed-row {
+    flex-direction: column;
+  }
+
+  .tribe-feed .refeed-column,
+  .tribe-feed-full .refeed-column {
+    width: 100%;
+    min-width: 0;
+    max-width: none;
+    align-items: flex-start;
+  }
+}
+
+.tribe-feed .feed-actions,
+.tribe-feed-full .feed-actions {
   display: flex;
   gap: 8px;
-  margin-top: 8px;
+  flex-wrap: wrap;
+  margin-bottom: 8px;
 }
 
-.tribe-feed input[type="text"] {
-  flex: 1;
-  padding: 6px;
-  border-radius: 4px;
-  border: none;
-  background-color: #222;
+.tribe-feed .feed-actions form,
+.tribe-feed-full .feed-actions form,
+.tribe-feed .refeed-column form,
+.tribe-feed-full .refeed-column form {
+  margin: 0;
+}
+
+.tribe-feed-compose {
+  display: grid;
+  grid-template-columns: 1fr auto;
+  gap: 12px;
+  margin-top: 14px;
+  align-items: end;
+}
+
+.tribe-feed-compose textarea {
+  width: 100%;
+  min-width: 0;
+  padding: 12px;
+  border-radius: 10px;
+  border: 1px solid #444;
+  background: #222;
   color: #eee;
+  resize: vertical;
+  min-height: 120px;
+  line-height: 1.45;
+  box-sizing: border-box;
+}
+
+.tribe-feed-compose .tribe-feed-send {
+  height: 40px;
+  padding: 0 14px;
+  border-radius: 10px;
+  white-space: nowrap;
+}
+
+@media (max-width: 720px) {
+  .tribe-feed-compose {
+    grid-template-columns: 1fr;
+  }
+
+  .tribe-feed-compose .tribe-feed-send {
+    width: 100%;
+  }
 }
 
 .tribes-container{ 

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

@@ -1669,7 +1669,7 @@ module.exports = {
     confusingButton: "CONFUSO",
     inspiringButton: "INSPIRADOR",
     spamButton: "SPAM",
-    usefulButton: "ÚTIL",
+    usefulButton: "UTIL",
     informativeButton: "INFORMATIVO",
     wellResearchedButton: "BIEN INVESTIGADO",
     accurateButton: "CONCISO",

+ 146 - 1
src/models/activity_model.js

@@ -114,6 +114,148 @@ module.exports = ({ cooler }) => {
               prev = cur;
             }
           }
+          
+          if (type === 'tribe') {
+		  const baseId = tip.id;
+		  const baseTitle = (tip.content && tip.content.title) || '';
+		  const isAnonymous = tip.content && typeof tip.content.isAnonymous === 'boolean' ? tip.content.isAnonymous : false;
+
+		  const uniq = (xs) => Array.from(new Set((Array.isArray(xs) ? xs : []).filter(x => typeof x === 'string' && x.trim().length)));
+		  const toSet = (xs) => new Set(uniq(xs));
+		  const excerpt = (s, max = 220) => {
+		    const t = String(s || '').replace(/\s+/g, ' ').trim();
+		    return t.length > max ? t.slice(0, max - 1) + '…' : t;
+		  };
+		  const feedMap = (feed) => {
+		    const m = new Map();
+		    for (const it of (Array.isArray(feed) ? feed : [])) {
+		      if (!it || typeof it !== 'object') continue;
+		      const id = typeof it.id === 'string' || typeof it.id === 'number' ? String(it.id) : '';
+		      if (!id) continue;
+		      m.set(id, it);
+		    }
+		    return m;
+		  };
+
+		  const sorted = arr
+		    .filter(a => a.type === 'tribe' && a.content && typeof a.content === 'object')
+		    .sort((a, b) => (a.ts || 0) - (b.ts || 0));
+
+		  let prev = null;
+
+		  for (const ev of sorted) {
+		    if (!prev) { prev = ev; continue; }
+
+		    const prevMembers = toSet(prev.content.members);
+		    const curMembers = toSet(ev.content.members);
+		    const added = Array.from(curMembers).filter(x => !prevMembers.has(x));
+		    const removed = Array.from(prevMembers).filter(x => !curMembers.has(x));
+
+		    for (const member of added) {
+		      const overlayId = `${ev.id}:tribeJoin:${member}`;
+		      idToAction.set(overlayId, {
+			id: overlayId,
+			author: member,
+			ts: ev.ts,
+			type: 'tribeJoin',
+			content: { type: 'tribeJoin', tribeId: baseId, tribeTitle: baseTitle, isAnonymous, member }
+		      });
+		      idToTipId.set(overlayId, overlayId);
+		    }
+
+		    for (const member of removed) {
+		      const overlayId = `${ev.id}:tribeLeave:${member}`;
+		      idToAction.set(overlayId, {
+			id: overlayId,
+			author: member,
+			ts: ev.ts,
+			type: 'tribeLeave',
+			content: { type: 'tribeLeave', tribeId: baseId, tribeTitle: baseTitle, isAnonymous, member }
+		      });
+		      idToTipId.set(overlayId, overlayId);
+		    }
+
+		    const prevFeed = feedMap(prev.content.feed);
+		    const curFeed = feedMap(ev.content.feed);
+
+		    for (const [fid, item] of curFeed.entries()) {
+		      if (prevFeed.has(fid)) continue;
+		      const feedAuthor = (item && typeof item.author === 'string' && item.author.trim().length) ? item.author : ev.author;
+		      const overlayId = `${ev.id}:tribeFeedPost:${fid}:${feedAuthor}`;
+		      idToAction.set(overlayId, {
+			id: overlayId,
+			author: feedAuthor,
+			ts: ev.ts,
+			type: 'tribeFeedPost',
+			content: {
+			  type: 'tribeFeedPost',
+			  tribeId: baseId,
+			  tribeTitle: baseTitle,
+			  isAnonymous,
+			  feedId: fid,
+			  date: item.date || ev.ts,
+			  text: excerpt(item.message || '')
+			}
+		      });
+		      idToTipId.set(overlayId, overlayId);
+		    }
+
+		    for (const [fid, curItem] of curFeed.entries()) {
+		      const prevItem = prevFeed.get(fid);
+		      if (!prevItem) continue;
+
+		      const pInh = toSet(prevItem.refeeds_inhabitants);
+		      const cInh = toSet(curItem.refeeds_inhabitants);
+		      const newInh = Array.from(cInh).filter(x => !pInh.has(x));
+
+		      const curRefeeds = Number(curItem.refeeds || 0);
+		      const prevRefeeds = Number(prevItem.refeeds || 0);
+
+		      const postText = excerpt(curItem.message || '');
+
+		      if (newInh.length) {
+			for (const who of newInh) {
+			  const overlayId = `${ev.id}:tribeFeedRefeed:${fid}:${who}`;
+			  idToAction.set(overlayId, {
+			    id: overlayId,
+			    author: who,
+			    ts: ev.ts,
+			    type: 'tribeFeedRefeed',
+			    content: {
+			      type: 'tribeFeedRefeed',
+			      tribeId: baseId,
+			      tribeTitle: baseTitle,
+			      isAnonymous,
+			      feedId: fid,
+			      text: postText
+			    }
+			  });
+			  idToTipId.set(overlayId, overlayId);
+			}
+		      } else if (curRefeeds > prevRefeeds && ev.author) {
+			const who = ev.author;
+			const overlayId = `${ev.id}:tribeFeedRefeed:${fid}:${who}`;
+			idToAction.set(overlayId, {
+			  id: overlayId,
+			  author: who,
+			  ts: ev.ts,
+			  type: 'tribeFeedRefeed',
+			  content: {
+			    type: 'tribeFeedRefeed',
+			    tribeId: baseId,
+			    tribeTitle: baseTitle,
+			    isAnonymous,
+			    feedId: fid,
+			    text: postText
+			  }
+			});
+			idToTipId.set(overlayId, overlayId);
+		      }
+		    }
+
+		    prev = ev;
+		  }
+		}
 
           continue;
         }
@@ -162,6 +304,7 @@ module.exports = ({ cooler }) => {
       const latest = [];
       for (const a of idToAction.values()) {
         if (tombstoned.has(a.id)) continue;
+        if (a.type === 'tribe' && parentOf.has(a.id)) continue;
         const c = a.content || {};
         if (c.root && tombstoned.has(c.root)) continue;
         if (a.type === 'vote' && tombstoned.has(c.vote?.link)) continue;
@@ -181,7 +324,8 @@ module.exports = ({ cooler }) => {
         }
         latest.push({ ...a, tipId: idToTipId.get(a.id) || a.id });
       }
-      let deduped = latest.filter(a => !a.tipId || a.tipId === a.id);
+      let deduped = latest.filter(a => !a.tipId || a.tipId === a.id || (a.type === 'tribe' && !parentOf.has(a.id)));
+
       const mediaTypes = new Set(['image','video','audio','document','bookmark']);
       const perAuthorUnique = new Set(['karmaScore']);
       const byKey = new Map();
@@ -241,6 +385,7 @@ module.exports = ({ cooler }) => {
       else if (filter === 'all') out = deduped;
       else if (filter === 'banking') out = deduped.filter(a => a.type === 'bankWallet' || a.type === 'bankClaim');
       else if (filter === 'karma') out = deduped.filter(a => a.type === 'karmaScore');
+      else if (filter === 'tribe') out = deduped.filter(a => a.type === 'tribe' || String(a.type || '').startsWith('tribe'));
       else if (filter === 'parliament')
         out = deduped.filter(a =>
           ['parliamentCandidature','parliamentTerm','parliamentProposal','parliamentRevocation','parliamentLaw'].includes(a.type)

+ 233 - 102
src/models/feed_model.js

@@ -1,167 +1,298 @@
-const pull = require('../server/node_modules/pull-stream');
-const { getConfig } = require('../configs/config-manager.js');
-const categories = require('../backend/opinion_categories');
+const pull = require("../server/node_modules/pull-stream");
+const { getConfig } = require("../configs/config-manager.js");
+const categories = require("../backend/opinion_categories");
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 module.exports = ({ cooler }) => {
   let ssb;
-  const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
+  const openSsb = async () => {
+    if (!ssb) ssb = await cooler.open();
+    return ssb;
+  };
 
   const getMsg = (ssbClient, id) =>
     new Promise((resolve, reject) => {
-      ssbClient.get(id, (err, msg) => err ? reject(err) : resolve(msg));
+      ssbClient.get(id, (err, val) => (err ? reject(err) : resolve({ key: id, value: val })));
     });
 
   const getAllMessages = (ssbClient) =>
     new Promise((resolve, reject) => {
-      pull(
-        ssbClient.createLogStream({ limit: logLimit }),
-        pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
-      );
+      pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs))));
     });
 
-  const resolveCurrentId = async (id) => {
-    const ssbClient = await openSsb();
+  const extractTags = (text) => {
+    const list = (String(text || "").match(/#[A-Za-z0-9_]{1,32}/g) || []).map((t) => t.slice(1).toLowerCase());
+    return Array.from(new Set(list));
+  };
+
+  const buildIndex = async (ssbClient) => {
     const messages = await getAllMessages(ssbClient);
+
     const forward = new Map();
-    for (const m of messages) {
-      const c = m.value?.content;
-      if (!c) continue;
-      if (c.type === 'feed' && c.replaces) forward.set(c.replaces, m.key);
+    const replacedIds = new Set();
+    const tombstoned = new Set();
+    const feedsById = new Map();
+    const actions = [];
+
+    for (const msg of messages) {
+      const c = msg?.value?.content;
+      const k = msg?.key;
+      if (!c || !k) continue;
+      if (c.type === "tombstone" && c.target) {
+        tombstoned.add(c.target);
+        continue;
+      }
+      if (c.type === "feed") {
+        feedsById.set(k, msg);
+        if (c.replaces) {
+          forward.set(c.replaces, k);
+          replacedIds.add(c.replaces);
+        }
+        continue;
+      }
+      if (c.type === "feed-action") {
+        actions.push(msg);
+        continue;
+      }
     }
-    let cur = id;
-    while (forward.has(cur)) cur = forward.get(cur);
-    return cur;
+
+    const resolve = (id) => {
+      let cur = id;
+      const seen = new Set();
+      while (forward.has(cur) && !seen.has(cur)) {
+        seen.add(cur);
+        cur = forward.get(cur);
+      }
+      return cur;
+    };
+
+    const actionsByRoot = new Map();
+    for (const a of actions) {
+      const c = a?.value?.content || {};
+      const target = c.root || c.target;
+      if (!target) continue;
+      const root = resolve(target);
+      if (!actionsByRoot.has(root)) actionsByRoot.set(root, []);
+      actionsByRoot.get(root).push(a);
+    }
+
+    return { resolve, tombstoned, feedsById, replacedIds, actionsByRoot };
+  };
+
+  const resolveCurrentId = async (id) => {
+    const ssbClient = await openSsb();
+    const idx = await buildIndex(ssbClient);
+    return idx.resolve(id);
   };
 
   const createFeed = async (text) => {
     const ssbClient = await openSsb();
     const userId = ssbClient.id;
-    if (typeof text !== 'string' || text.length > 280) throw new Error("Text too long");
+
+    if (typeof text !== "string") throw new Error("Invalid text");
+    const cleaned = text.trim();
+    if (!cleaned) throw new Error("Text required");
+    if (cleaned.length > 280) throw new Error("Text too long");
+
     const content = {
-      type: 'feed',
-      text,
+      type: "feed",
+      text: cleaned,
       author: userId,
       createdAt: new Date().toISOString(),
-      opinions: {},
-      opinions_inhabitants: [],
-      refeeds: 0,
-      refeeds_inhabitants: []
+      tags: extractTags(cleaned)
     };
+
     return new Promise((resolve, reject) => {
-      ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg));
+      ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)));
     });
   };
 
   const createRefeed = async (contentId) => {
     const ssbClient = await openSsb();
     const userId = ssbClient.id;
-    const tipId = await resolveCurrentId(contentId);
-    const msg = await getMsg(ssbClient, tipId);
-    if (!msg || !msg.content || msg.content.type !== 'feed') throw new Error("Invalid feed");
-    if (msg.content.refeeds_inhabitants?.includes(userId)) throw new Error("Already refeeded");
-
-    const tombstone = { type: 'tombstone', target: tipId, deletedAt: new Date().toISOString(), author: userId };
-    const updated = {
-      ...msg.content,
-      refeeds: (msg.content.refeeds || 0) + 1,
-      refeeds_inhabitants: [...(msg.content.refeeds_inhabitants || []), userId],
-      updatedAt: new Date().toISOString(),
-      replaces: tipId
+
+    const idx = await buildIndex(ssbClient);
+    const tipId = idx.resolve(contentId);
+
+    let msg;
+    try {
+      msg = idx.feedsById.get(tipId) || (await getMsg(ssbClient, tipId));
+    } catch {
+      throw new Error("Invalid feed");
+    }
+
+    const c = msg?.value?.content;
+    if (!c || c.type !== "feed") throw new Error("Invalid feed");
+
+    const existing = idx.actionsByRoot.get(tipId) || [];
+    for (const a of existing) {
+      const ac = a?.value?.content || {};
+      if (ac.type === "feed-action" && ac.action === "refeed" && a.value?.author === userId) throw new Error("Already refeeded");
+    }
+
+    const action = {
+      type: "feed-action",
+      action: "refeed",
+      root: tipId,
+      createdAt: new Date().toISOString(),
+      author: userId
     };
 
-    await new Promise((res, rej) => ssbClient.publish(tombstone, err => err ? rej(err) : res()));
     return new Promise((resolve, reject) => {
-      ssbClient.publish(updated, (err2, out) => err2 ? reject(err2) : resolve(out));
+      ssbClient.publish(action, (err, out) => (err ? reject(err) : resolve(out)));
     });
   };
 
   const addOpinion = async (contentId, category) => {
-    if (!categories.includes(category)) throw new Error('Invalid voting category');
+    if (!categories.includes(category)) throw new Error("Invalid voting category");
+
     const ssbClient = await openSsb();
     const userId = ssbClient.id;
-    const tipId = await resolveCurrentId(contentId);
-    const msg = await getMsg(ssbClient, tipId);
-    if (!msg || !msg.content || msg.content.type !== 'feed') throw new Error("Invalid feed");
-    if (msg.content.opinions_inhabitants?.includes(userId)) throw new Error("Already voted");
-
-    const tombstone = { type: 'tombstone', target: tipId, deletedAt: new Date().toISOString(), author: userId };
-    const updated = {
-      ...msg.content,
-      opinions: {
-        ...msg.content.opinions,
-        [category]: (msg.content.opinions?.[category] || 0) + 1
-      },
-      opinions_inhabitants: [...(msg.content.opinions_inhabitants || []), userId],
-      updatedAt: new Date().toISOString(),
-      replaces: tipId
+
+    const idx = await buildIndex(ssbClient);
+    const tipId = idx.resolve(contentId);
+
+    let msg;
+    try {
+      msg = idx.feedsById.get(tipId) || (await getMsg(ssbClient, tipId));
+    } catch {
+      throw new Error("Invalid feed");
+    }
+
+    const c = msg?.value?.content;
+    if (!c || c.type !== "feed") throw new Error("Invalid feed");
+
+    const existing = idx.actionsByRoot.get(tipId) || [];
+    for (const a of existing) {
+      const ac = a?.value?.content || {};
+      if (ac.type === "feed-action" && ac.action === "vote" && a.value?.author === userId) throw new Error("Already voted");
+    }
+
+    const action = {
+      type: "feed-action",
+      action: "vote",
+      category,
+      root: tipId,
+      createdAt: new Date().toISOString(),
+      author: userId
     };
 
-    await new Promise((res, rej) => ssbClient.publish(tombstone, err => err ? rej(err) : res()));
     return new Promise((resolve, reject) => {
-      ssbClient.publish(updated, (err2, result) => err2 ? reject(err2) : resolve(result));
+      ssbClient.publish(action, (err, result) => (err ? reject(err) : resolve(result)));
     });
   };
 
-  const listFeeds = async (filter = 'ALL') => {
+  const listFeeds = async (filterOrOpts = "ALL") => {
     const ssbClient = await openSsb();
     const userId = ssbClient.id;
     const now = Date.now();
-    const messages = await getAllMessages(ssbClient);
 
-    const tombstoned = new Set();
-    const replaces = new Map();
-    const byId = new Map();
+    const opts = typeof filterOrOpts === "string" ? { filter: filterOrOpts } : (filterOrOpts || {});
+    const filter = String(opts.filter || "ALL").toUpperCase();
+    const q = typeof opts.q === "string" ? opts.q.trim().toLowerCase() : "";
+    const tag = typeof opts.tag === "string" ? opts.tag.trim().toLowerCase() : "";
 
-    for (const msg of messages) {
-      const c = msg.value?.content;
-      const k = msg.key;
-      if (!c) continue;
-      if (c.type === 'tombstone' && c.target) {
-        tombstoned.add(c.target);
-        continue;
+    const idx = await buildIndex(ssbClient);
+
+    let tips = Array.from(idx.feedsById.values()).filter((m) => !idx.replacedIds.has(m.key) && !idx.tombstoned.has(m.key));
+
+    const textEditedEver = (m) => {
+      const seen = new Set();
+      let cur = m;
+      let lastText = cur?.value?.content?.text;
+      while (cur?.value?.content?.replaces) {
+        const prevId = cur.value.content.replaces;
+        if (!prevId || seen.has(prevId)) break;
+        seen.add(prevId);
+        const prev = idx.feedsById.get(prevId);
+        if (!prev) break;
+        const prevText = prev?.value?.content?.text;
+        if (typeof lastText === "string" && typeof prevText === "string" && lastText !== prevText) return true;
+        cur = prev;
+        lastText = prevText;
       }
-      if (c.type === 'feed') {
-        if (tombstoned.has(k)) continue;
-        if (c.replaces) replaces.set(c.replaces, k);
-        byId.set(k, msg);
+      return false;
+    };
+
+    const materialize = (feedMsg) => {
+      const base = feedMsg || {};
+      const content = { ...(base.value?.content || {}) };
+      const root = base.key;
+
+      let refeeds = Number(content.refeeds || 0) || 0;
+      const refeedsInhabitants = new Set(Array.isArray(content.refeeds_inhabitants) ? content.refeeds_inhabitants : []);
+
+      const opinionsCounts = {};
+      const oldOpinions = content.opinions && typeof content.opinions === "object" ? content.opinions : {};
+      for (const [k, v] of Object.entries(oldOpinions)) opinionsCounts[k] = (Number(v) || 0);
+
+      const opinionsInhabitants = new Set(Array.isArray(content.opinions_inhabitants) ? content.opinions_inhabitants : []);
+
+      const actions = idx.actionsByRoot.get(root) || [];
+      for (const a of actions) {
+        const ac = a?.value?.content || {};
+        const author = a?.value?.author || ac.author;
+        if (!author) continue;
+
+        if (ac.action === "refeed") {
+          if (!refeedsInhabitants.has(author)) {
+            refeedsInhabitants.add(author);
+            refeeds += 1;
+          }
+          continue;
+        }
+
+        if (ac.action === "vote") {
+          if (!opinionsInhabitants.has(author)) {
+            opinionsInhabitants.add(author);
+            const cat = String(ac.category || "");
+            opinionsCounts[cat] = (Number(opinionsCounts[cat]) || 0) + 1;
+          }
+          continue;
+        }
       }
+
+      content.refeeds = refeeds;
+      content.refeeds_inhabitants = Array.from(refeedsInhabitants);
+      content.opinions = opinionsCounts;
+      content.opinions_inhabitants = Array.from(opinionsInhabitants);
+
+      if (!Array.isArray(content.tags)) content.tags = extractTags(content.text);
+
+      content._textEdited = textEditedEver(base);
+
+      return { ...base, value: { ...base.value, content } };
+    };
+
+    let feeds = tips.map(materialize);
+
+    if (q) {
+      const terms = q.split(/\s+/).map(s => s.trim()).filter(Boolean);
+      feeds = feeds.filter((m) => {
+        const t = String(m.value?.content?.text || "").toLowerCase();
+        return terms.every(term => t.includes(term));
+      });
     }
+    if (tag) feeds = feeds.filter((m) => Array.isArray(m.value?.content?.tags) && m.value.content.tags.includes(tag));
 
-    for (const replaced of replaces.keys()) byId.delete(replaced);
+    const getTs = (m) => m?.value?.timestamp || Date.parse(m?.value?.content?.createdAt || "") || 0;
+    const totalVotes = (m) => Object.values(m?.value?.content?.opinions || {}).reduce((s, x) => s + (Number(x) || 0), 0);
 
-    let feeds = Array.from(byId.values());
-    const seenTexts = new Map();
-    for (const feed of feeds) {
-      const text = feed.value?.content?.text;
-      if (typeof text !== 'string') continue;
-      const existing = seenTexts.get(text);
-      if (!existing || (feed.value.timestamp || 0) > (existing.value.timestamp || 0)) {
-        seenTexts.set(text, feed);
-      }
+    if (filter === "MINE") {
+      feeds = feeds.filter((m) => (m.value?.content?.author || m.value?.author) === userId);
+    } else if (filter === "TODAY") {
+      feeds = feeds.filter((m) => now - getTs(m) < 86400000);
     }
-    feeds = Array.from(seenTexts.values());
-
-    if (filter === 'MINE') {
-      feeds = feeds.filter(m => m.value?.content?.author === userId);
-    } else if (filter === 'TODAY') {
-      feeds = feeds.filter(m => now - (m.value.timestamp || 0) < 86400000);
-    } else if (filter === 'TOP') {
-      feeds = feeds.sort((a, b) => {
-        const aVotes = Object.values(a.value.content.opinions || {}).reduce((sum, x) => sum + x, 0);
-        const bVotes = Object.values(b.value.content.opinions || {}).reduce((sum, x) => sum + x, 0);
-        return bVotes - aVotes;
-      });
+
+    if (filter === "TOP") {
+      feeds.sort((a, b) => totalVotes(b) - totalVotes(a) || (b.value?.content?.refeeds || 0) - (a.value?.content?.refeeds || 0) || getTs(b) - getTs(a));
+    } else {
+      feeds.sort((a, b) => getTs(b) - getTs(a));
     }
 
     return feeds;
   };
 
-  return {
-    createFeed,
-    createRefeed,
-    addOpinion,
-    listFeeds
-  };
+  return { createFeed, createRefeed, addOpinion, listFeeds, resolveCurrentId };
 };
 

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

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

+ 1 - 1
src/server/package.json

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

+ 54 - 0
src/views/activity_view.js

@@ -148,6 +148,7 @@ function renderActionCards(actions, userId) {
       if (content.type === 'tombstone') return false;
       if (content.type === 'post' && content.private === true) return false;
       if (content.type === 'tribe' && content.isAnonymous === true) return false;
+      if (typeof content.type === 'string' && content.type.startsWith('tribe') && content.isAnonymous === true) return false;
       if (content.type === 'task' && content.isPublic === "PRIVATE") return false;
       if (content.type === 'event' && content.isPublic === "private") return false;
       if (content.type === 'market') {
@@ -193,6 +194,14 @@ function renderActionCards(actions, userId) {
       headerText = `[COURTS · ${finalSub.toUpperCase()}]`;
     } else if (type === 'taskAssignment') {
       headerText = `[${String(i18n.typeTask || 'TASK').toUpperCase()} · ASSIGNMENT]`;
+    } else if (type === 'tribeJoin') {
+      headerText = `[TRIBE · ${String(i18n.tribeActivityJoined || 'JOIN').toUpperCase()}]`;
+    } else if (type === 'tribeLeave') {
+      headerText = `[TRIBE · ${String(i18n.tribeActivityLeft || 'LEAVE').toUpperCase()}]`;
+    } else if (type === 'tribeFeedPost') {
+      headerText = `[TRIBE · FEED]`;
+    } else if (type === 'tribeFeedRefeed') {
+      headerText = `[TRIBE · REFEED]`;
     } else {
       const typeLabel = i18n[`type${capitalize(type)}`] || type;
       headerText = `[${String(typeLabel).toUpperCase()}]`;
@@ -328,6 +337,40 @@ function renderActionCards(actions, userId) {
         )
       );
     }
+    if (type === 'tribeJoin' || type === 'tribeLeave') {
+      const { tribeId, tribeTitle } = content || {};
+      cardBody.push(
+        div({ class: 'card-section tribe' },
+          h2({ class: 'tribe-title' },
+            a({ href: `/tribe/${encodeURIComponent(tribeId || '')}`, class: 'user-link' }, tribeTitle || tribeId || '')
+          )
+        )
+      );
+    }
+
+    if (type === 'tribeFeedPost') {
+      const { tribeId, tribeTitle, text } = content || {};
+      cardBody.push(
+        div({ class: 'card-section tribe' },
+          h2({ class: 'tribe-title' },
+            a({ href: `/tribe/${encodeURIComponent(tribeId || '')}`, class: 'user-link' }, tribeTitle || tribeId || '')
+          ),
+          text ? p({ class: 'post-text' }, ...renderUrl(text)) : ''
+        )
+      );
+    }
+
+    if (type === 'tribeFeedRefeed') {
+      const { tribeId, tribeTitle, text } = content || {};
+      cardBody.push(
+        div({ class: 'card-section tribe' },
+          h2({ class: 'tribe-title' },
+            a({ href: `/tribe/${encodeURIComponent(tribeId || '')}`, class: 'user-link' }, tribeTitle || tribeId || '')
+          ),
+          text ? p({ class: 'post-text' }, ...renderUrl(text)) : ''
+        )
+      );
+    }
 
     if (type === 'curriculum') {
       const { author, name, description, photo, personalSkills, oasisSkills, educationalSkills, languages, professionalSkills, status, preferences, createdAt, updatedAt} = content;
@@ -1140,6 +1183,11 @@ function getViewDetailsAction(type, action) {
     case 'courtsSettlementAccepted':return `/courts?filter=actions`;
     case 'courtsNomination':        return `/courts?filter=judges`;
     case 'courtsNominationVote':    return `/courts?filter=judges`;
+    case 'tribeJoin':
+    case 'tribeLeave':
+    case 'tribeFeedPost':
+    case 'tribeFeedRefeed':
+    return `/tribe/${encodeURIComponent(action.content?.tribeId || '')}`;
     case 'votes':      return `/votes/${id}`;
     case 'transfer':   return `/transfers/${id}`;
     case 'pixelia':    return `/pixelia`;
@@ -1213,6 +1261,12 @@ exports.activityView = (actions, filter, userId) => {
     filteredActions = actions.filter(action => action.type !== 'tombstone' && action.ts && now - action.ts < 24 * 60 * 60 * 1000);
   } else if (filter === 'banking') {
     filteredActions = actions.filter(action => action.type !== 'tombstone' && (action.type === 'bankWallet' || action.type === 'bankClaim'));
+  } else if (filter === 'tribe') {
+    filteredActions = actions.filter(action =>
+      action.type !== 'tombstone' &&
+      String(action.type || '').startsWith('tribe') &&
+      action.type !== 'tribe'
+    );
   } else if (filter === 'parliament') {
     filteredActions = actions.filter(action => ['parliamentCandidature','parliamentTerm','parliamentProposal','parliamentRevocation','parliamentLaw'].includes(action.type));
   } else if (filter === 'courts') {

+ 153 - 112
src/views/feed_view.js

@@ -1,154 +1,195 @@
 const { div, h2, p, section, button, form, a, span, textarea, br, input, h1 } = require("../server/node_modules/hyperaxe");
-const { template, i18n } = require('./main_views');
-const { config } = require('../server/SSB_server.js');
-const { renderTextWithStyles } = require('../backend/renderTextWithStyles');
-const opinionCategories = require('../backend/opinion_categories');
-
-const generateFilterButtons = (filters, currentFilter, action) => {
-  const cur = String(currentFilter || '').toUpperCase();
-  return filters.map(mode =>
-    form({ method: 'GET', action },
-      input({ type: 'hidden', name: 'filter', value: mode }),
-      button(
-        { type: 'submit', class: cur === mode ? 'filter-btn active' : 'filter-btn' },
-        i18n[mode + 'Button'] || mode
-      )
+const { template, i18n } = require("./main_views");
+const { config } = require("../server/SSB_server.js");
+const { renderTextWithStyles } = require("../backend/renderTextWithStyles");
+const opinionCategories = require("../backend/opinion_categories");
+
+const normalizeOptions = (opts) => {
+  if (typeof opts === "string") return { filter: String(opts || "ALL").toUpperCase(), q: "", tag: "" };
+  if (!opts || typeof opts !== "object") return { filter: "ALL", q: "", tag: "" };
+  return {
+    filter: String(opts.filter || "ALL").toUpperCase(),
+    q: typeof opts.q === "string" ? opts.q : "",
+    tag: typeof opts.tag === "string" ? opts.tag : ""
+  };
+};
+
+const formatDate = (feed) => {
+  const ts = feed?.value?.timestamp || Date.parse(feed?.value?.content?.createdAt || "") || 0;
+  return ts ? new Date(ts).toLocaleString() : "";
+};
+
+const extractTags = (text) => {
+  const list = (String(text || "").match(/#[A-Za-z0-9_]{1,32}/g) || []).map((t) => t.slice(1).toLowerCase());
+  return Array.from(new Set(list));
+};
+
+const generateFilterButtons = (filters, currentFilter, action, extra = {}) => {
+  const cur = String(currentFilter || "").toUpperCase();
+  const hiddenInputs = (obj) =>
+    Object.entries(obj)
+      .filter(([, v]) => v !== undefined && v !== null && String(v).length > 0)
+      .map(([k, v]) => input({ type: "hidden", name: k, value: String(v) }));
+
+  return filters.map((mode) =>
+    form(
+      { method: "GET", action },
+      input({ type: "hidden", name: "filter", value: mode }),
+      ...hiddenInputs(extra),
+      button({ type: "submit", class: cur === mode ? "filter-btn active" : "filter-btn" }, i18n[mode + "Button"] || mode)
     )
   );
 };
 
-const renderFeedCard = (feed, alreadyRefeeded, alreadyVoted) => {
-  const content = feed.value.content;
+const renderVotesSummary = (opinions = {}) => {
+  const entries = Object.entries(opinions).filter(([, v]) => Number(v) > 0);
+  if (!entries.length) return null;
+  entries.sort((a, b) => Number(b[1]) - Number(a[1]) || String(a[0]).localeCompare(String(b[0])));
+  return div(
+    { class: "votes" },
+    entries.map(([category, count]) => span({ class: "vote-category" }, `${category}: ${count}`))
+  );
+};
+
+const renderTagChips = (tags = []) => {
+  const list = Array.isArray(tags) ? tags : [];
+  if (!list.length) return null;
+  return div(
+    { class: "tag-chips" },
+    list.map((t) => a({ class: "tag-chip", href: `/feed?tag=${encodeURIComponent(t)}` }, `#${t}`))
+  );
+};
+
+const renderFeedCard = (feed) => {
+  const content = feed.value.content || {};
   const voteEntries = Object.entries(content.opinions || {});
-  const totalCount = voteEntries.reduce((sum, [, count]) => sum + count, 0);
-  const createdAt = feed.value.timestamp ? new Date(feed.value.timestamp).toLocaleString() : '';
+  const totalCount = voteEntries.reduce((sum, [, count]) => sum + (Number(count) || 0), 0);
+  const createdAt = formatDate(feed);
+  const me = config?.keys?.id;
+
+  const alreadyRefeeded = Array.isArray(content.refeeds_inhabitants) && me ? content.refeeds_inhabitants.includes(me) : false;
+  const alreadyVoted = Array.isArray(content.opinions_inhabitants) && me ? content.opinions_inhabitants.includes(me) : false;
 
-  return div({ class: 'feed-card' },
-    div({ class: 'feed-row' },
-      div({ class: 'refeed-column' },
+  const tags = Array.isArray(content.tags) && content.tags.length ? content.tags : extractTags(content.text);
+
+  const authorId = content.author || feed.value.author || "";
+
+  return div(
+    { class: "feed-card" },
+    div(
+      { class: "feed-row" },
+      div(
+        { class: "refeed-column" },
         h1(`${content.refeeds || 0}`),
-        !alreadyRefeeded
-          ? form({ method: 'POST', action: `/feed/refeed/${encodeURIComponent(feed.key)}` },
-              button({ class: 'refeed-btn' }, i18n.refeedButton)
-            )
-          : p(i18n.alreadyRefeeded)
+        form(
+          { method: "POST", action: `/feed/refeed/${encodeURIComponent(feed.key)}` },
+          button({ class: alreadyRefeeded ? "refeed-btn active" : "refeed-btn", type: "submit", disabled: !!alreadyRefeeded }, i18n.refeedButton)
+        ),
+        alreadyRefeeded ? p({ class: "muted" }, i18n.alreadyRefeeded) : null
       ),
-      div({ class: 'feed-main' },
-        div({ class: 'feed-text', innerHTML: renderTextWithStyles(content.text) }),
+      div(
+        { class: "feed-main" },
+        div({ class: "feed-text", innerHTML: renderTextWithStyles(content.text || "") }),
+        renderTagChips(tags),
         h2(`${i18n.totalOpinions}: ${totalCount}`),
-        p({ class: 'card-footer' },
-          span({ class: 'date-link' }, `${createdAt} ${i18n.performed} `),
-          a({ href: `/author/${encodeURIComponent(feed.value.author)}`, class: 'user-link' }, `${feed.value.author}`)
+        p(
+          { class: "card-footer" },
+          span({ class: "date-link" }, `${createdAt} ${i18n.performed} `),
+          a({ href: `/author/${encodeURIComponent(authorId)}`, class: "user-link" }, `${authorId}`),
+          content._textEdited ? span({ class: "edited-badge" }, ` · ${i18n.edited || "edited"}`) : null
         )
       )
     ),
-    div({ class: 'votes-wrapper' },
-      voteEntries.length > 0
-        ? div({ class: 'votes' },
-            voteEntries.map(([category, count]) =>
-              span({ class: 'vote-category' }, `${category}: ${count}`)
-            )
-          )
-        : null,
-      !alreadyVoted
-        ? div({ class: 'voting-buttons' },
-            opinionCategories.map(cat =>
-              form({ method: 'POST', action: `/feed/opinions/${encodeURIComponent(feed.key)}/${cat}` },
-                button(
-                  { class: 'vote-btn' },
-                  `${i18n['vote' + cat.charAt(0).toUpperCase() + cat.slice(1)] || cat} [${content.opinions?.[cat] || 0}]`
-                )
-              )
+    div(
+      { class: "votes-wrapper" },
+      renderVotesSummary(content.opinions || {}),
+      div(
+        { class: "voting-buttons" },
+        opinionCategories.map((cat) =>
+          form(
+            { method: "POST", action: `/feed/opinions/${encodeURIComponent(feed.key)}/${cat}` },
+            button(
+              { class: alreadyVoted ? "vote-btn disabled" : "vote-btn", type: "submit", disabled: !!alreadyVoted },
+              `${i18n["vote" + cat.charAt(0).toUpperCase() + cat.slice(1)] || cat} [${content.opinions?.[cat] || 0}]`
             )
           )
-        : p(i18n.alreadyVoted)
+        )
+      ),
+      alreadyVoted ? p({ class: "muted" }, i18n.alreadyVoted) : null
     )
   );
 };
 
-exports.feedView = (feeds, filter) => {
+exports.feedView = (feeds, opts = "ALL") => {
+  const { filter, q, tag } = normalizeOptions(opts);
+
   const title =
-    filter === 'MINE'   ? i18n.MINEButton :
-    filter === 'TODAY'  ? i18n.TODAYButton :
-    filter === 'TOP'    ? i18n.TOPButton :
-    filter === 'CREATE' ? i18n.createFeedTitle :
-    filter === 'tag'    ? i18n.filteredByTag :
-                          i18n.feedTitle;
-
-  if (filter !== 'TOP') {
-    feeds = feeds.sort((a, b) => (b.value.timestamp || 0) - (a.value.timestamp || 0));
-  } else {
-    feeds = feeds.sort((a, b) => {
-      const aRefeeds = a.value.content.refeeds || 0;
-      const bRefeeds = b.value.content.refeeds || 0;
-      return bRefeeds - aRefeeds;
-    });
-  }
-
-  const header = div({ class: 'tags-header' },
-    h2(title),
-    p(i18n.FeedshareYourOpinions)
-  );
+    filter === "MINE"
+      ? i18n.MINEButton
+      : filter === "TODAY"
+        ? i18n.TODAYButton
+        : filter === "TOP"
+          ? i18n.TOPButton
+          : filter === "CREATE"
+            ? i18n.createFeedTitle
+            : tag
+              ? `${i18n.filteredByTag || i18n.filteredByTagTitle || "Filtered by tag"}: #${tag}`
+              : q
+                ? `${i18n.searchTitle || "Search"}: “${q}”`
+                : i18n.feedTitle;
+
+  const header = div({ class: "tags-header" }, h2(title), p(i18n.FeedshareYourOpinions));
+
+  const extra = { q, tag };
 
   return template(
     title,
     section(
       header,
-      div({ class: 'mode-buttons-row' },
-        ...generateFilterButtons(['ALL', 'MINE', 'TODAY', 'TOP'], filter, '/feed'),
-        form({ method: 'GET', action: '/feed/create' },
-          button({ type: 'submit', class: 'create-button filter-btn' }, i18n.createFeedTitle || "Create Feed")
-        )
+      div(
+        { class: "mode-buttons-row" },
+        ...generateFilterButtons(["ALL", "MINE", "TODAY", "TOP"], filter, "/feed", extra),
+        form({ method: "GET", action: "/feed/create" }, button({ type: "submit", class: "create-button filter-btn" }, i18n.createFeedTitle || "Create Feed"))
       ),
+	div(
+	  { class: "feed-tools-row" },
+	  form(
+	    { method: "GET", action: "/feed", class: "feed-search-form" },
+	    input({ type: "hidden", name: "filter", value: filter }),
+	    tag ? input({ type: "hidden", name: "tag", value: tag }) : null,
+	    input({ type: "text", name: "q", value: q, placeholder: i18n.searchPlaceholder || "Search", class: "feed-search-input" }),
+	    button({ type: "submit", class: "filter-btn feed-search-btn" }, i18n.searchButton || "Search")
+	  )
+	),
       section(
-        filter === 'CREATE'
-          ? form({ method: 'POST', action: '/feed/create' },
-              textarea({
-                name: 'text',
-                placeholder: i18n.feedPlaceholder,
-                maxlength: 280,
-                rows: 4,
-                cols: 50
-              }),
+        filter === "CREATE"
+          ? form(
+              { method: "POST", action: "/feed/create" },
+              textarea({ name: "text", placeholder: i18n.feedPlaceholder, maxlength: 280, rows: 4, cols: 50 }),
               br(),
-              button({ type: 'submit', class: 'create-button' }, i18n.createFeedButton)
+              button({ type: "submit", class: "create-button" }, i18n.createFeedButton)
             )
           : feeds && feeds.length > 0
-            ? div({ class: 'feed-container' },
-                feeds.map(feed => {
-                  const content = feed.value.content;
-                  const alreadyRefeeded = content.refeeds_inhabitants?.includes(config.keys.id);
-                  const alreadyVoted = content.opinions_inhabitants?.includes(config.keys.id);
-                  return renderFeedCard(feed, alreadyRefeeded, alreadyVoted);
-                })
-              )
-            : div({ class: 'no-results' }, p(i18n.noFeedsFound))
+            ? div({ class: "feed-container" }, feeds.map((feed) => renderFeedCard(feed)))
+            : div({ class: "no-results" }, p(i18n.noFeedsFound))
       )
     )
   );
 };
 
-exports.feedCreateView = () => {
+exports.feedCreateView = (opts = {}) => {
+  const { q, tag } = normalizeOptions(opts);
+
   return template(
     i18n.createFeedTitle,
     section(
-      div({ class: 'tags-header' },
-        h2(i18n.createFeedTitle),
-        p(i18n.FeedshareYourOpinions)
-      ),
-      div({ class: 'mode-buttons-row' },
-        ...generateFilterButtons(['ALL', 'MINE', 'TODAY', 'TOP'], 'CREATE', '/feed')
-      ),
-      form({ method: 'POST', action: '/feed/create' },
-        textarea({
-          name: 'text',
-          maxlength: '280',
-          rows: 5,
-          cols: 50,
-          placeholder: i18n.feedPlaceholder
-        }),
+      div({ class: "tags-header" }, h2(i18n.createFeedTitle), p(i18n.FeedshareYourOpinions)),
+      div({ class: "mode-buttons-row" }, ...generateFilterButtons(["ALL", "MINE", "TODAY", "TOP"], "CREATE", "/feed", { q, tag })),
+      form(
+        { method: "POST", action: "/feed/create" },
+        textarea({ name: "text", maxlength: "280", rows: 5, cols: 50, placeholder: i18n.feedPlaceholder }),
         br(),
-        button({ type: 'submit', class: 'create-button' }, i18n.createFeedButton || 'Send Feed!')
+        button({ type: "submit", class: "create-button" }, i18n.createFeedButton || "Send Feed!")
       )
     )
   );

+ 4 - 4
src/views/opinions_view.js

@@ -314,7 +314,9 @@ exports.opinionsView = (items, filter) => {
               button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
             )
           )
-        ),
+        )
+      ),
+      div({ class: 'mode-buttons' },
         div({ class: 'column' },
           opinionCategories.constructive.slice(0, 5).map(mode =>
             form({ method: 'GET', action: '/opinions' },
@@ -322,9 +324,7 @@ exports.opinionsView = (items, filter) => {
               button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
             )
           )
-        )
-      ),
-      div({ class: 'mode-buttons' },
+        ),
         div({ class: 'column' },
           opinionCategories.constructive.slice(5, 11).map(mode =>
             form({ method: 'GET', action: '/opinions' },

+ 32 - 25
src/views/tribes_view.js

@@ -67,12 +67,15 @@ const renderFeedTribesView = (tribe, page, query, filter) => {
       : div({ class: 'feed-list' },
           paginatedFeed.map(m => div({ class: 'feed-item' },
             div({ class: 'feed-row' },
-              div({ class: 'refeed-column' },
-                h1(`${m.refeeds || 0}`),
-                !m.refeeds_inhabitants.includes(userId)
-                  ? form({ method: 'POST', action: `/tribes/${encodeURIComponent(tribe.id)}/refeed/${encodeURIComponent(m.id)}` }, button({ class: 'refeed-btn' }, i18n.tribeFeedRefeed))
-                  : p(i18n.alreadyRefeeded)
-              ),
+		div({ class: 'refeed-column' },
+		  h1(`${m.refeeds || 0}`),
+		  !(m.refeeds_inhabitants || []).includes(userId)
+		    ? form(
+			{ method: 'POST', action: `/tribes/${encodeURIComponent(tribe.id)}/refeed/${encodeURIComponent(m.id)}` },
+			button({ class: 'refeed-btn' }, i18n.tribeFeedRefeed)
+		      )
+		    : null
+		),
               div({ class: 'feed-main' },
                 p(`${new Date(m.date).toLocaleString()} — `, a({ class: 'user-link', href: `/author/${encodeURIComponent(m.author)}` }, m.author)),
                 br,
@@ -81,12 +84,13 @@ const renderFeedTribesView = (tribe, page, query, filter) => {
             )
           ))
         ),
-    tribe.members.includes(userId)
-      ? form({ method: 'POST', action: `/tribes/${encodeURIComponent(tribe.id)}/message` },
-          textarea({ name: 'message', rows: 4, cols: 50, maxlength: 280, placeholder: i18n.tribeFeedMessagePlaceholder }),
-          button({ type: 'submit' }, i18n.tribeFeedSend)
-        )
-      : null,
+	tribe.members.includes(userId)
+	  ? form(
+	      { class: 'tribe-feed-compose', method: 'POST', action: `/tribes/${encodeURIComponent(tribe.id)}/message` },
+	      textarea({ name: 'message', rows: 4, maxlength: 280, placeholder: i18n.tribeFeedMessagePlaceholder }),
+	      button({ type: 'submit', class: 'tribe-feed-send' }, i18n.tribeFeedSend)
+	    )
+	  : null,
     renderPaginationTribesView(page, totalPages, filter)
   );
 };
@@ -353,12 +357,15 @@ const renderFeedTribeView = async (tribe, query = {}, filter) => {
       : div({ class: 'feed-list' },
           filteredFeed.map(m => div({ class: 'feed-item' },
             div({ class: 'feed-row' },
-              div({ class: 'refeed-column' },
-                h1(`${m.refeeds || 0}`),
-                !m.refeeds_inhabitants.includes(userId)
-                  ? form({ method: 'POST', action: `/tribe/${encodeURIComponent(tribe.id)}/refeed/${encodeURIComponent(m.id)}` }, button({ class: 'refeed-btn' }, i18n.tribeFeedRefeed))
-                  : p(i18n.alreadyRefeeded)
-              ),
+		div({ class: 'refeed-column' },
+		  h1(`${m.refeeds || 0}`),
+		  !(m.refeeds_inhabitants || []).includes(userId)
+		    ? form(
+			{ method: 'POST', action: `/tribe/${encodeURIComponent(tribe.id)}/refeed/${encodeURIComponent(m.id)}` },
+			button({ class: 'refeed-btn' }, i18n.tribeFeedRefeed)
+		      )
+		    : null
+		),
               div({ class: 'feed-main' },
                 p(`${new Date(m.date).toLocaleString()} — `, a({ class: 'user-link', href: `/author/${encodeURIComponent(m.author)}` }, m.author)),
                 br,
@@ -411,13 +418,13 @@ exports.tribeView = async (tribe, userIdParam, query) => {
       ),
     ),
     div({ class: 'tribe-main' },
-      div({ class: 'tribe-feed-form' }, tribe.members.includes(config.keys.id)
-        ? form({ method: 'POST', action: `/tribe/${encodeURIComponent(tribe.id)}/message` },
-            textarea({ name: 'message', rows: 4, cols: 50, maxlength: 280, placeholder: i18n.tribeFeedMessagePlaceholder }),
-            br,
-            button({ type: 'submit' }, i18n.tribeFeedSend)
-          )
-        : null
+      div({ class: 'tribe-feed-form' }, 
+       tribe.members.includes(config.keys.id)
+       ? form( { class: 'tribe-feed-compose', method: 'POST', action: `/tribe/${encodeURIComponent(tribe.id)}/message` },
+         textarea({ name: 'message', rows: 4, maxlength: 280, placeholder: i18n.tribeFeedMessagePlaceholder }),
+         button({ type: 'submit', class: 'tribe-feed-send' }, i18n.tribeFeedSend)
+       )
+       : null
       ),
       await renderFeedTribeView(tribe, query, query.filter),
     )