Ver código fonte

Oasis release 0.8.0

psy 6 horas atrás
pai
commit
46acb4cf47

+ 27 - 14
src/backend/backend.js

@@ -1339,8 +1339,9 @@ router
     if (!checkMod(ctx, 'popularMod')) return ctx.redirect('/modules');
     const i18n = require("../client/assets/translations/i18n"), lang = ctx.cookies.get('language') || getConfig().language || 'en', t = i18n[lang] || i18n['en'];
     const messages = sanitizeMessages(await post.popular({ period: ctx.params.period }));
-    ctx.body = await popularView({ messages, prefix: nav(div({ class: "filters" }, ul(['day','week','month','year'].map(p => li(form({ method: "GET", action: `/public/popular/${p}` }, button({ type: "submit", class: "filter-btn" }, t[p]))))))) });
-  }) 
+    const spreadMap = await spreads.forMessages((messages || []).map(m => m && m.key)).catch(() => new Map());
+    ctx.body = await popularView({ messages, prefix: nav(div({ class: "filters" }, ul(['day','week','month','year'].map(p => li(form({ method: "GET", action: `/public/popular/${p}` }, button({ type: "submit", class: "filter-btn" }, t[p]))))))), spreadMap });
+  })
   .get("/modules", async (ctx) => {
     const modules = ['popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'invites', 'wallet', 'legacy', 'cipher', 'bookmarks', 'calendars', 'chats', 'videos', 'docs', 'audios', 'tags', 'images', 'maps', 'trending', 'events', 'tasks', 'market', 'tribes', 'larp', 'votes', 'reports', 'opinions', 'pads', 'transfers', 'feed', 'pixelia', 'melody', 'agenda', 'favorites', 'ai', 'forum', 'games', 'jobs', 'projects', 'shops', 'banking', 'parliament', 'courts'];
     const cfg = getConfig().modules;
@@ -1541,12 +1542,14 @@ router
   .get("/public/latest", async (ctx) => {
     if (!checkMod(ctx, 'latestMod')) { ctx.redirect('/modules'); return; }
     const messages = sanitizeMessages(await post.latest());
-    ctx.body = await latestView({ messages });
+    const spreadMap = await spreads.forMessages((messages || []).map(m => m && m.key)).catch(() => new Map());
+    ctx.body = await latestView({ messages, spreadMap });
   })
   .get("/public/latest/extended", async (ctx) => {
     if (!checkMod(ctx, 'extendedMod')) { ctx.redirect('/modules'); return; }
     const messages = sanitizeMessages(await post.latestExtended());
-    ctx.body = await extendedView({ messages });
+    const spreadMap = await spreads.forMessages((messages || []).map(m => m && m.key)).catch(() => new Map());
+    ctx.body = await extendedView({ messages, spreadMap });
   })
   .get("/public/latest/topics", async (ctx) => {
     if (!checkMod(ctx, 'topicsMod')) { ctx.redirect('/modules'); return; }
@@ -1556,17 +1559,20 @@ router
       return li(a({ href: `/hashtag/${c}` }, `#${c}`));
     });
     const prefix = nav(ul(list));
-    ctx.body = await topicsView({ messages, prefix });
+    const spreadMap = await spreads.forMessages((messages || []).map(m => m && m.key)).catch(() => new Map());
+    ctx.body = await topicsView({ messages, prefix, spreadMap });
   })
   .get("/public/latest/summaries", async (ctx) => {
     if (!checkMod(ctx, 'summariesMod')) { ctx.redirect('/modules'); return; }
     const messages = sanitizeMessages(await post.latestSummaries());
-    ctx.body = await summaryView({ messages });
+    const spreadMap = await spreads.forMessages((messages || []).map(m => m && m.key)).catch(() => new Map());
+    ctx.body = await summaryView({ messages, spreadMap });
   })
   .get("/public/latest/threads", async (ctx) => {
     if (!checkMod(ctx, 'threadsMod')) { ctx.redirect('/modules'); return; }
     const messages = sanitizeMessages(await post.latestThreads());
-    ctx.body = await threadsView({ messages });
+    const spreadMap = await spreads.forMessages((messages || []).map(m => m && m.key)).catch(() => new Map());
+    ctx.body = await threadsView({ messages, spreadMap });
   })
   .get('/author/:feed', async (ctx) => {
     const feedId = decodeURIComponent(ctx.params.feed || ''), gt = Number(ctx.request.query.gt || -1), lt = Number(ctx.request.query.lt || -1);
@@ -3114,7 +3120,9 @@ router
   })
   .get("/likes/:feed", async (ctx) => {
     const { feed } = ctx.params;
-    ctx.body = await likesView({ messages: await post.likes({ feed }), feed, name: await about.name(feed) });
+    const messages = await post.likes({ feed });
+    const spreadMap = await spreads.forMessages((messages || []).map(m => m && m.key)).catch(() => new Map());
+    ctx.body = await likesView({ messages, feed, name: await about.name(feed), spreadMap });
   })
   .get("/mentions", async (ctx) => {
     const { messages, myFeedId } = await post.mentionsMe();
@@ -4237,7 +4245,8 @@ router
     const { message } = ctx.params;
     const thread = async (message) => {
       const messages = await post.fromThread(message);
-      return threadView({ messages });
+      const spreadMap = await spreads.forMessages((messages || []).map(m => m && m.key)).catch(() => new Map());
+      return threadView({ messages, spreadMap });
     };
     ctx.body = await thread(message);
   })
@@ -4247,7 +4256,8 @@ router
     const myFeedId = await meta.myFeedId();
     debug("%O", rootMessage);
     const messages = [rootMessage];
-    ctx.body = await subtopicView({ messages, myFeedId });
+    const spreadMap = await spreads.forMessages(messages.map(m => m && m.key)).catch(() => new Map());
+    ctx.body = await subtopicView({ messages, myFeedId, spreadMap });
   })
   .get("/publish", async (ctx) => {
     ctx.body = await publishView();
@@ -4255,7 +4265,9 @@ router
   .get("/comment/:message", async (ctx) => {
     const { messages, myFeedId, parentMessage } =
       await resolveCommentComponents(ctx);
-    ctx.body = await commentView({ messages, myFeedId, parentMessage });
+    const allKeys = [parentMessage && parentMessage.key, ...(messages || []).map(m => m && m.key)].filter(Boolean);
+    const spreadMap = await spreads.forMessages(allKeys).catch(() => new Map());
+    ctx.body = await commentView({ messages, myFeedId, parentMessage, spreadMap });
   })
   .get("/wallet", async (ctx) => {
     const { url, user, pass } = getConfig().wallet;
@@ -6562,12 +6574,13 @@ router
   })
   .post("/update", koaBody(), async (ctx) => {
     const exec = require("node:util").promisify(require("node:child_process").exec);
-    const { stdout, stderr } = await exec("git reset --hard && git pull");
+    const repoRoot = path.resolve(__dirname, '..', '..');
+    const { stdout, stderr } = await exec("git reset --hard && git pull", { cwd: repoRoot });
     console.log("oasis@version: updating Oasis...", stdout, stderr);
-    const { stdout: shOut, stderr: shErr } = await exec("sh install.sh");
+    const { stdout: shOut, stderr: shErr } = await exec("sh install.sh", { cwd: repoRoot });
     console.log("oasis@version: running install.sh...", shOut, shErr);
     safeRefererRedirect(ctx, '/settings');
-  })  
+  })
   .post("/settings/theme", koaBody(), async (ctx) => {
     const theme = String(ctx.request.body.theme || "").trim(), cfg = getConfig();
     cfg.themes.current = theme || "Dark-SNH";

+ 39 - 6
src/models/chats_model.js

@@ -136,6 +136,27 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
     return keys.map(k => [k])
   }
 
+  const tryDecryptPublicInviteKey = (invites) => {
+    if (!tribeCrypto || !Array.isArray(invites)) return null
+    for (const inv of invites) {
+      if (!inv || typeof inv !== "object" || inv.public !== true) continue
+      if (typeof inv.code !== "string") continue
+      if (typeof inv.ek === "string") {
+        try {
+          const k = tribeCrypto.decryptFromInvite(inv.ek, inv.code, inv.salt)
+          if (k) return k
+        } catch (_) {}
+      }
+      if (typeof inv.ekChain === "string") {
+        try {
+          const chain = tribeCrypto.decryptChainFromInvite(inv.ekChain, inv.code, inv.salt)
+          if (Array.isArray(chain) && chain.length && chain[0].key) return chain[0].key
+        } catch (_) {}
+      }
+    }
+    return null
+  }
+
   const buildChat = (node, rootId) => {
     const rawC = node.c || {}
     if (rawC.type !== "chat") return null
@@ -146,6 +167,16 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
       const keyChainSets = resolveKeyChainSets(rootId)
       c = tribeCrypto.decryptContent(c, keyChainSets)
       undecryptable = !!c._undecryptable
+      if (undecryptable) {
+        const pubKey = tryDecryptPublicInviteKey(rawC.invites)
+        if (pubKey) {
+          const retry = tribeCrypto.decryptContent(rawC, [[pubKey]])
+          if (retry && !retry._undecryptable) {
+            c = retry
+            undecryptable = false
+          }
+        }
+      }
     }
 
     const invites = safeArr(c.invites)
@@ -274,12 +305,13 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
         })
       }
 
-      const chatKey = ownCrypto.generateTribeKey()
       if (st === "OPEN") {
-        const code = crypto.randomBytes(INVITE_CODE_BYTES).toString("hex")
-        const ek = tribeCrypto.encryptForInvite(chatKey, code)
-        content.invites = [{ code, ek, gen: 1, public: true }]
+        return new Promise((resolve, reject) => {
+          ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
+        })
       }
+
+      const chatKey = ownCrypto.generateTribeKey()
       content = tribeCrypto.encryptContent(content, [chatKey], true)
       const result = await new Promise((resolve, reject) => {
         ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
@@ -661,7 +693,8 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
       }
       if (image) content.image = image
 
-      if (tribeCrypto) {
+      const chatIsEncrypted = !!(chat.tribeId) || !!lookupKey(chat.rootId)
+      if (chatIsEncrypted && tribeCrypto) {
         let encKey = null
         if (chat.tribeId) encKey = await getTribeFirstKeyFor(chat.tribeId)
         if (!encKey) encKey = lookupKey(chat.rootId)
@@ -669,7 +702,7 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
         content.encryptedText = tribeCrypto.encryptWithKey(safeText(text), encKey)
         if (chat.tribeId) content.tribeId = chat.tribeId
       } else {
-        throw new Error('Chat crypto unavailable — cannot send message')
+        content.text = safeText(text)
       }
 
       return new Promise((resolve, reject) => {

+ 24 - 10
src/models/larp_model.js

@@ -196,29 +196,43 @@ module.exports = ({ cooler, tribesModel, tribeCrypto }) => {
     const client = await openSsb();
     return new Promise((resolve) => {
       const anchors = [];
-      const tombstones = [];
+      const anchorTombstones = [];
+      const tribeTombstones = new Map();
+      const msgAuthorByKey = new Map();
       pull(
         client.createLogStream(),
         pull.drain((m) => {
-          const c = m && m.value && m.value.content;
-          if (!c) return;
+          if (!m || !m.value) return;
+          const author = m.value.author;
+          if (m.key && author) msgAuthorByKey.set(m.key, author);
+          const c = m.value.content;
+          if (!c || typeof c !== 'object') return;
           if (c.type === 'larpHouseTribeAnchor') {
             if (c.house !== houseKey) return;
             if (typeof c.tribeRootId !== 'string') return;
             const tribeTs = Number(Date.parse(c.tribeCreatedAt || '')) || m.value.timestamp || 0;
-            anchors.push({ tribeRootId: c.tribeRootId, anchorAuthor: m.value.author, tribeTs });
+            anchors.push({ tribeRootId: c.tribeRootId, anchorAuthor: author, tribeTs });
           } else if (c.type === 'larpHouseTribeAnchorTombstone') {
             if (c.house !== houseKey) return;
             if (typeof c.tribeRootId !== 'string') return;
-            tombstones.push({ tribeRootId: c.tribeRootId, tombstoneAuthor: m.value.author });
+            anchorTombstones.push({ tribeRootId: c.tribeRootId, tombstoneAuthor: author });
+          } else if (c.type === 'tombstone' && typeof c.target === 'string') {
+            tribeTombstones.set(c.target, author);
           }
         }, () => {
-          const validKills = new Set();
-          for (const t of tombstones) {
-            const a = anchors.find(x => x.tribeRootId === t.tribeRootId);
-            if (a && a.anchorAuthor === t.tombstoneAuthor) validKills.add(t.tribeRootId);
+          const killedAnchorPairs = new Set();
+          for (const t of anchorTombstones) {
+            killedAnchorPairs.add(`${t.tribeRootId}|${t.tombstoneAuthor}`);
+          }
+          const deadTribes = new Set();
+          for (const [target, tombAuthor] of tribeTombstones) {
+            const tribeAuthor = msgAuthorByKey.get(target);
+            if (tribeAuthor && tribeAuthor === tombAuthor) deadTribes.add(target);
           }
-          const live = anchors.filter(a => !validKills.has(a.tribeRootId));
+          const live = anchors.filter(a =>
+            !killedAnchorPairs.has(`${a.tribeRootId}|${a.anchorAuthor}`) &&
+            !deadTribes.has(a.tribeRootId)
+          );
           if (!live.length) return resolve(null);
           live.sort((a, b) => a.tribeTs - b.tribeTs);
           const first = live[0];

+ 29 - 5
src/models/maps_model.js

@@ -90,12 +90,36 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
     return tribeCrypto.encryptContent(content, [key], true);
   };
 
+  const tryDecryptPublicInviteKey = (invites) => {
+    if (!tribeCrypto || !Array.isArray(invites)) return null;
+    for (const inv of invites) {
+      if (!inv || typeof inv !== "object" || inv.public !== true) continue;
+      if (typeof inv.code !== "string") continue;
+      if (typeof inv.ek === "string") {
+        try {
+          const k = tribeCrypto.decryptFromInvite(inv.ek, inv.code, inv.salt);
+          if (k) return k;
+        } catch (_) {}
+      }
+      if (typeof inv.ekChain === "string") {
+        try {
+          const chain = tribeCrypto.decryptChainFromInvite(inv.ekChain, inv.code, inv.salt);
+          if (Array.isArray(chain) && chain.length && chain[0].key) return chain[0].key;
+        } catch (_) {}
+      }
+    }
+    return null;
+  };
+
   const decryptMapRoot = (content, rootId) => {
     if (!content || !content.encryptedPayload) return content;
     if (!tribeCrypto) return content;
     const keys = lookupKeys(rootId);
-    if (!keys || !keys.length) return { ...content, _undecryptable: true };
-    return tribeCrypto.decryptContent(content, keys.map(k => [k]));
+    let candidateChains = (keys || []).map(k => [k]);
+    const pubKey = tryDecryptPublicInviteKey(content.invites);
+    if (pubKey) candidateChains.push([pubKey]);
+    if (!candidateChains.length) return { ...content, _undecryptable: true };
+    return tribeCrypto.decryptContent(content, candidateChains);
   };
 
   const decryptIndexNodes = async (idx) => {
@@ -383,7 +407,8 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
         updatedAt: now
       };
 
-      const shouldEncryptStandalone = !tribeId && tribeCrypto;
+      const isPublicOpen = mType === "OPEN" && !tribeId;
+      const shouldEncryptStandalone = !tribeId && !isPublicOpen && tribeCrypto;
       let mapKey = null;
       let content = plainContent;
       if (tribeId) {
@@ -551,8 +576,7 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
         content = await encryptIfTribe(content);
       } else if (tribeCrypto) {
         const mapKey = lookupKey(rootId);
-        if (!mapKey) throw new Error(`Missing map key for ${rootId} — cannot publish marker`);
-        content = tribeCrypto.encryptContent(content, [mapKey], true);
+        if (mapKey) content = tribeCrypto.encryptContent(content, [mapKey], true);
       }
 
       return new Promise((resolve, reject) => {

+ 81 - 27
src/models/pads_model.js

@@ -133,6 +133,20 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, padCrypto, tribesModel })
     return decryptField(encryptedKey, derived.toString("hex"))
   }
 
+  const tryDecryptPublicInviteKey = (invites) => {
+    if (!Array.isArray(invites)) return null
+    for (const inv of invites) {
+      if (!inv || typeof inv !== "object") continue
+      if (inv.public !== true) continue
+      if (typeof inv.code !== "string" || typeof inv.ek !== "string") continue
+      try {
+        const key = decryptFromInvite(inv.ek, inv.code, inv.salt)
+        if (key) return key
+      } catch (_) {}
+    }
+    return null
+  }
+
   const generateInviteSalt = () => crypto.randomBytes(16).toString("hex")
 
   const rotatePadKey = async (rootId, remainingMembers) => {
@@ -228,7 +242,8 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, padCrypto, tribesModel })
       const viaTribe = decryptWithKeys(c, tribeKeys)
       if (viaTribe) return viaTribe
     }
-    const keyHex = getPadKey(rootId)
+    let keyHex = getPadKey(rootId)
+    if (!keyHex) keyHex = tryDecryptPublicInviteKey(c.invites)
     if (!keyHex) return { title: "", deadline: "", tags: [] }
     const title = c.title ? decryptField(c.title, keyHex) : ""
     const deadline = c.deadline ? decryptField(c.deadline, keyHex) : ""
@@ -253,7 +268,8 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, padCrypto, tribesModel })
       invites: Array.isArray(c.invites) ? c.invites : [],
       createdAt: c.createdAt || new Date(node.ts).toISOString(),
       updatedAt: c.updatedAt || null,
-      tribeId: c.tribeId || null
+      tribeId: c.tribeId || null,
+      encrypted: c.encrypted === true
     }
   }
 
@@ -297,6 +313,28 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, padCrypto, tribesModel })
       const ssbClient = await openSsb()
       const now = new Date().toISOString()
       const validStatus = ["OPEN", "INVITE-ONLY"].includes(String(status).toUpperCase()) ? String(status).toUpperCase() : "OPEN"
+      const userId = ssbClient.id
+      const tagsArr = normalizeTags(tagsRaw)
+
+      const isPublicOpen = validStatus === "OPEN" && !tribeId
+      if (isPublicOpen) {
+        const content = {
+          type: "pad",
+          title: safeText(title),
+          status: validStatus,
+          deadline: deadline ? String(deadline) : "",
+          tags: tagsArr,
+          author: userId,
+          members: [userId],
+          invites: [],
+          createdAt: now,
+          updatedAt: now,
+          encrypted: false
+        }
+        return new Promise((resolve, reject) => {
+          ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
+        })
+      }
 
       let keyHex = null
       let usesTribeKey = false
@@ -307,30 +345,21 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, padCrypto, tribesModel })
       if (!keyHex) keyHex = crypto.randomBytes(32).toString("hex")
       const enc = (text) => encryptField(text, keyHex)
 
-      const initialInvites = []
-      if (validStatus === "OPEN" && !usesTribeKey) {
-        const pubCode = crypto.randomBytes(INVITE_BYTES).toString("hex")
-        const inviteSalt = generateInviteSalt()
-        const ek = encryptForInvite(keyHex, pubCode, inviteSalt)
-        initialInvites.push({ code: pubCode, ek, salt: inviteSalt, gen: 1, public: true })
-      }
-
       const content = {
         type: "pad",
         title: enc(safeText(title)),
         status: validStatus,
         deadline: deadline ? enc(String(deadline)) : "",
-        tags: enc(normalizeTags(tagsRaw).join(",")),
-        author: ssbClient.id,
-        members: [ssbClient.id],
-        invites: initialInvites,
+        tags: enc(tagsArr.join(",")),
+        author: userId,
+        members: [userId],
+        invites: [],
         createdAt: now,
         updatedAt: now,
         encrypted: true,
         ...(tribeId ? { tribeId } : {})
       }
 
-      const userId = ssbClient.id
       return new Promise((resolve, reject) => {
         ssbClient.publish(content, (err, msg) => {
           if (err) return reject(err)
@@ -361,21 +390,25 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, padCrypto, tribesModel })
           if (err || !item?.content) return reject(new Error("Pad not found"))
           if (item.content.author !== userId) return reject(new Error("Not the author"))
           const c = item.content
+          const isEncrypted = c.encrypted === true
           let keyHex = null
           let usesTribeKey = false
-          if (c.tribeId) {
-            const tKeys = await getTribeKeysFor(c.tribeId)
-            if (tKeys.length) { keyHex = tKeys[0]; usesTribeKey = true }
+          if (isEncrypted) {
+            if (c.tribeId) {
+              const tKeys = await getTribeKeysFor(c.tribeId)
+              if (tKeys.length) { keyHex = tKeys[0]; usesTribeKey = true }
+            }
+            if (!keyHex) keyHex = getPadKey(rootId)
+            if (!keyHex) return reject(new Error(`Missing pad key for ${rootId} — cannot update pad`))
           }
-          if (!keyHex) keyHex = getPadKey(rootId)
-          if (!keyHex) throw new Error(`Missing pad key for ${rootId} — cannot update pad`)
-          const enc = (text) => encryptField(text, keyHex)
+          const enc = (text) => isEncrypted ? encryptField(text, keyHex) : text
+          const tagsField = (raw) => isEncrypted ? encryptField(normalizeTags(raw).join(","), keyHex) : normalizeTags(raw)
           const updated = {
             ...c,
             title: data.title !== undefined ? enc(safeText(data.title)) : c.title,
             status: data.status !== undefined ? (["OPEN","INVITE-ONLY"].includes(String(data.status).toUpperCase()) ? String(data.status).toUpperCase() : c.status) : c.status,
-            deadline: data.deadline !== undefined ? enc(String(data.deadline)) : c.deadline,
-            tags: data.tags !== undefined ? enc(normalizeTags(data.tags).join(",")) : c.tags,
+            deadline: data.deadline !== undefined ? (isEncrypted ? enc(String(data.deadline)) : String(data.deadline)) : c.deadline,
+            tags: data.tags !== undefined ? tagsField(data.tags) : c.tags,
             updatedAt: new Date().toISOString(),
             replaces: tipId
           }
@@ -488,13 +521,17 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, padCrypto, tribesModel })
           const c = item.content
           const members = Array.isArray(c.members) ? c.members : []
           if (members.includes(feedId)) return resolve()
+          if (c.encrypted === true && !c.tribeId && !getPadKey(rootId)) {
+            const key = tryDecryptPublicInviteKey(c.invites)
+            if (key) setPadKey(rootId, key)
+          }
           const updated = { ...c, members: [...members, feedId], updatedAt: new Date().toISOString(), replaces: tipId }
           const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: ssbClient.id }
           ssbClient.publish(tombstone, (e1) => {
             if (e1) return reject(e1)
             ssbClient.publish(updated, (e2, res) => {
               if (e2) return reject(e2)
-              if (!c.tribeId) {
+              if (c.encrypted === true && !c.tribeId) {
                 const keyHex = getPadKey(rootId)
                 if (keyHex) setPadKey(res.key, keyHex)
               }
@@ -651,6 +688,25 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, padCrypto, tribesModel })
       const ssbClient = await openSsb()
       const rootId = await this.resolveRootId(padId)
       const pad = await this.getPadById(rootId)
+      const padIsEncrypted = !!(pad && pad.encrypted)
+      const now = new Date().toISOString()
+      const safeBody = safeText(text)
+
+      if (!padIsEncrypted) {
+        const content = {
+          type: "padEntry",
+          padId: rootId,
+          text: safeBody,
+          author: ssbClient.id,
+          createdAt: now,
+          encrypted: false,
+          ...(pad && pad.tribeId ? { tribeId: pad.tribeId } : {})
+        }
+        return new Promise((resolve, reject) => {
+          ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
+        })
+      }
+
       let keyHex = null
       if (pad && pad.tribeId) {
         const tKeys = await getTribeKeysFor(pad.tribeId)
@@ -658,12 +714,10 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, padCrypto, tribesModel })
       }
       if (!keyHex) keyHex = getPadKey(rootId)
       if (!keyHex) throw new Error(`Missing pad key for ${rootId} — cannot publish pad entry`)
-      const now = new Date().toISOString()
-      const encText = encryptField(safeText(text), keyHex)
       const content = {
         type: "padEntry",
         padId: rootId,
-        text: encText,
+        text: encryptField(safeBody, keyHex),
         author: ssbClient.id,
         createdAt: now,
         encrypted: true,

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

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

+ 1 - 1
src/server/package.json

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

+ 70 - 48
src/views/main_views.js

@@ -1436,7 +1436,7 @@ exports.inviteRequiredView = (kind, tribe) => {
   );
 };
 
-const thread = (messages) => {
+const thread = (messages, spreadMap = null) => {
   let lookingForTarget = true;
   let shallowest = Infinity;
 
@@ -1459,6 +1459,8 @@ const thread = (messages) => {
     }
   }
 
+  const getSpread = (key) => (spreadMap instanceof Map ? spreadMap.get(key) : null) || null;
+
   const msgList = [];
   for (let i = 0; i < messages.length; i++) {
     const j = i + 1;
@@ -1470,7 +1472,7 @@ const thread = (messages) => {
       return lodash.get(msg, "value.meta.thread.depth", 0);
     };
 
-    msgList.push(post({ msg: currentMsg }));
+    msgList.push(post({ msg: currentMsg, spreadInfo: getSpread(currentMsg.key) }));
 
     if (depth(currentMsg) < depth(nextMsg)) {
       const isAncestor = Boolean(
@@ -1564,7 +1566,7 @@ const postAside = ({ key, value }) => {
   return fragments;
 };
 
-const post = ({ msg, aside = false, preview = false }) => {
+const post = ({ msg, aside = false, preview = false, spreadInfo = null }) => {
     const encoded = {
         key: encodeURIComponent(msg.key),
         author: encodeURIComponent(msg.value?.author),
@@ -1573,7 +1575,7 @@ const post = ({ msg, aside = false, preview = false }) => {
 
     const url = {
         author: `/author/${encoded.author}`,
-        likeForm: `/like/${encoded.key}`,
+        spreadForm: `/spread/${encoded.key}`,
         link: `/thread/${encoded.key}#${encoded.key}`,
         parent: `/thread/${encoded.parent}#${encoded.parent}`,
         avatar: msg.value?.meta?.author?.avatar?.url || '/assets/images/default-avatar.png',
@@ -1869,25 +1871,36 @@ const post = ({ msg, aside = false, preview = false }) => {
     const timeAgo = validTimestamp.fromNow();
     const timeAbsolute = validTimestamp.toISOString().split(".")[0].replace("T", " ");
 
-    const likeButton = msg.value?.meta?.voted
-        ? { value: 0, class: "liked" }
-        : { value: 1, class: null };
-
-    const likeCount = msg.value?.meta?.votes?.length || 0;
-    const maxLikedNameLength = 16;
-    const maxLikedNames = 16;
-
-    const likedByNames = msg.value?.meta?.votes
-        .slice(0, maxLikedNames)
+    const fallbackVoted = !!msg.value?.meta?.voted;
+    const fallbackVoteCount = msg.value?.meta?.votes?.length || 0;
+    const fallbackVoteNames = (msg.value?.meta?.votes || [])
         .map((person) => person.name)
-        .map((n) => n.slice(0, maxLikedNameLength))
+        .filter(Boolean);
+
+    const spreadInfoObj = (spreadInfo && typeof spreadInfo === 'object') ? spreadInfo : null;
+    const spreadCount = spreadInfoObj && typeof spreadInfoObj.count === 'number'
+        ? spreadInfoObj.count
+        : fallbackVoteCount;
+    const alreadySpread = spreadInfoObj && typeof spreadInfoObj.alreadySpread === 'boolean'
+        ? spreadInfoObj.alreadySpread
+        : fallbackVoted;
+    const spreadVoters = (spreadInfoObj && Array.isArray(spreadInfoObj.voters))
+        ? spreadInfoObj.voters
+              .map(v => (v && typeof v === 'object') ? (v.name || v.key || '') : String(v || ''))
+              .filter(Boolean)
+        : fallbackVoteNames;
+
+    const maxSpreadNameLength = 16;
+    const maxSpreadNames = 16;
+    const spreadByNames = spreadVoters
+        .slice(0, maxSpreadNames)
+        .map((n) => String(n).slice(0, maxSpreadNameLength))
         .join(", ");
-
-    const additionalLikesMessage =
-        likeCount > maxLikedNames ? `+${likeCount - maxLikedNames} more` : ``;
-
-    const likedByMessage =
-        likeCount > 0 ? `${likedByNames} ${additionalLikesMessage}` : null;
+    const additionalSpreadsMessage =
+        spreadCount > maxSpreadNames ? `+${spreadCount - maxSpreadNames} more` : ``;
+    const spreadByMessage =
+        spreadCount > 0 ? `${spreadByNames} ${additionalSpreadsMessage}`.trim() : (i18n.spreadHint || 'Spread this to your supporters (replicates via your feed).');
+    const spreadButtonClass = alreadySpread ? 'liked' : null;
 
     const messageClasses = ["post"];
     const recps = [];
@@ -1956,16 +1969,14 @@ const post = ({ msg, aside = false, preview = false }) => {
         footer(
             div(
                 form(
-                    { action: url.likeForm, method: "post" },
+                    { action: url.spreadForm, method: "post" },
                     button(
                         {
-                            name: "voteValue",
                             type: "submit",
-                            value: likeButton.value,
-                            class: likeButton.class,
-                            title: likedByMessage,
+                            class: spreadButtonClass,
+                            title: spreadByMessage,
                         },
-                        `☉ ${likeCount}`
+                        `☉ ${spreadCount}`
                     )
                 ),
                 a({ href: url.comment }, i18n.comment),
@@ -2608,7 +2619,7 @@ exports.previewCommentView = async ({
 };
 
 exports.commentView = async (
-  { messages, myFeedId, parentMessage },
+  { messages, myFeedId, parentMessage, spreadMap = null },
   preview,
   text,
   contentWarning
@@ -2666,7 +2677,8 @@ exports.commentView = async (
     markdownMention = `[@${parentAuthorName}](${parentAuthorFeedId})\n\n`;
   }
 
-  const messageElements = threadMessages.map((m) => post({ msg: m }));
+  const getSpread = (key) => (spreadMap instanceof Map ? spreadMap.get(key) : null) || null;
+  const messageElements = threadMessages.map((m) => post({ msg: m, spreadInfo: getSpread(m.key) }));
 
   const action = `/comment/preview/${encodeURIComponent(parentKey)}`;
   const method = "post";
@@ -3280,7 +3292,7 @@ exports.publishCustomView = async () => {
   );
 };
 
-exports.threadView = ({ messages }) => {
+exports.threadView = ({ messages, spreadMap = null }) => {
   const rootMessage = messages[0];
   const rootAuthorName = rootMessage.value.meta.author.name;
 
@@ -3291,7 +3303,7 @@ exports.threadView = ({ messages }) => {
 
   const tpl = template(
     [`@${rootAuthorName}`],
-    div(thread(messages))
+    div(thread(messages, spreadMap))
   );
 
   return `${tpl}${
@@ -3587,11 +3599,12 @@ const viewInfoBox = ({ viewTitle = null, viewDescription = null }) => {
 }
 //generate preview
 
-exports.likesView = async ({ messages, feed, name }) => {
+exports.likesView = async ({ messages, feed, name, spreadMap = null }) => {
   const authorLink = a(
     { href: `/author/${encodeURIComponent(feed)}` },
     "@" + name
   );
+  const getSpread = (key) => (spreadMap instanceof Map ? spreadMap.get(key) : null) || null;
 
   return template(
     ["@", name],
@@ -3599,7 +3612,7 @@ exports.likesView = async ({ messages, feed, name }) => {
       viewTitle: span(authorLink),
       viewDescription: span(i18n.spreadedDescription)
     }),
-    messages.map((msg) => post({ msg }))
+    messages.map((msg) => post({ msg, spreadInfo: getSpread(msg.key) }))
   );
 };
 
@@ -3609,6 +3622,7 @@ const messageListView = ({
   viewDescription = null,
   viewElements = null,
   aside = null,
+  spreadMap = null,
 }) => {
   const hasHeader = !!viewElements;
   const titleBlock = hasHeader
@@ -3617,14 +3631,15 @@ const messageListView = ({
         h2(viewTitle),
         p(viewDescription)
       );
+  const getSpread = (key) => (spreadMap instanceof Map ? spreadMap.get(key) : null) || null;
   return template(
     viewTitle,
     section(titleBlock),
-    messages.map((msg) => post({ msg, aside }))
+    messages.map((msg) => post({ msg, aside, spreadInfo: getSpread(msg.key) }))
   );
 };
 
-exports.popularView = ({ messages, prefix }) => {
+exports.popularView = ({ messages, prefix, spreadMap = null }) => {
   const header = div({ class: "tags-header" },
     h2(i18n.popular),
     p(i18n.popularDescription)
@@ -3632,11 +3647,12 @@ exports.popularView = ({ messages, prefix }) => {
   return messageListView({
     messages,
     viewTitle: i18n.popular,
-    viewElements: [header, prefix]
+    viewElements: [header, prefix],
+    spreadMap
   });
 };
 
-exports.extendedView = ({ messages }) => {
+exports.extendedView = ({ messages, spreadMap = null }) => {
   const header = div({ class: "tags-header" },
     h2(i18n.extended),
     p(i18n.extendedDescription)
@@ -3644,11 +3660,12 @@ exports.extendedView = ({ messages }) => {
   return messageListView({
     messages,
     viewTitle: i18n.extended,
-    viewElements: header
+    viewElements: header,
+    spreadMap
   });
 };
 
-exports.latestView = ({ messages }) => {
+exports.latestView = ({ messages, spreadMap = null }) => {
   const header = div({ class: "tags-header" },
     h2(i18n.latest),
     p(i18n.latestDescription)
@@ -3656,11 +3673,12 @@ exports.latestView = ({ messages }) => {
   return messageListView({
     messages,
     viewTitle: i18n.latest,
-    viewElements: header
+    viewElements: header,
+    spreadMap
   });
 };
 
-exports.topicsView = ({ messages, prefix }) => {
+exports.topicsView = ({ messages, prefix, spreadMap = null }) => {
   const header = div({ class: "tags-header" },
     h2(i18n.topics),
     p(i18n.topicsDescription)
@@ -3668,11 +3686,12 @@ exports.topicsView = ({ messages, prefix }) => {
   return messageListView({
     messages,
     viewTitle: i18n.topics,
-    viewElements: [header, prefix]
+    viewElements: [header, prefix],
+    spreadMap
   });
 };
 
-exports.summaryView = ({ messages }) => {
+exports.summaryView = ({ messages, spreadMap = null }) => {
   const header = div({ class: "tags-header" },
     h2(i18n.summaries),
     p(i18n.summariesDescription)
@@ -3681,7 +3700,8 @@ exports.summaryView = ({ messages }) => {
     messages,
     viewTitle: i18n.summaries,
     viewElements: header,
-    aside: true
+    aside: true,
+    spreadMap
   });
 };
 
@@ -3697,7 +3717,7 @@ exports.spreadedView = ({ messages }) => {
   });
 };
 
-exports.threadsView = ({ messages }) => {
+exports.threadsView = ({ messages, spreadMap = null }) => {
   const header = div({ class: "tags-header" },
     h2(i18n.threads),
     p(i18n.threadsDescription)
@@ -3706,7 +3726,8 @@ exports.threadsView = ({ messages }) => {
     messages,
     viewTitle: i18n.threads,
     viewElements: header,
-    aside: true
+    aside: true,
+    spreadMap
   });
 };
 
@@ -3731,7 +3752,7 @@ exports.previewSubtopicView = async ({
 };
 
 exports.subtopicView = async (
-  { messages, myFeedId },
+  { messages, myFeedId, spreadMap = null },
   preview,
   text,
   contentWarning
@@ -3741,6 +3762,7 @@ exports.subtopicView = async (
   )}`;
 
   let markdownMention;
+  const getSpread = (key) => (spreadMap instanceof Map ? spreadMap.get(key) : null) || null;
 
   const messageElements = await Promise.all(
     messages.reverse().map((message) => {
@@ -3753,7 +3775,7 @@ exports.subtopicView = async (
           markdownMention = x;
         }
       }
-      return post({ msg: message });
+      return post({ msg: message, spreadInfo: getSpread(message.key) });
     })
   );