const pull = require('../server/node_modules/pull-stream'); const { getConfig } = require('../configs/config-manager.js'); const logLimit = getConfig().ssbLogStream?.limit || 1000; const N = s => String(s || '').toUpperCase().replace(/\s+/g, '_'); const ORDER_MARKET = ['FOR_SALE','OPEN','RESERVED','CLOSED','SOLD']; const ORDER_PROJECT = ['CANCELLED','PAUSED','ACTIVE','COMPLETED']; const SCORE_MARKET = s => { const i = ORDER_MARKET.indexOf(N(s)); return i < 0 ? -1 : i }; const SCORE_PROJECT = s => { const i = ORDER_PROJECT.indexOf(N(s)); return i < 0 ? -1 : i }; function inferType(c = {}) { if (c.type === 'wallet' && c.coin === 'ECO' && typeof c.address === 'string') return 'bankWallet'; if (c.type === 'bankClaim') return 'bankClaim'; if (c.type === 'karmaScore') return 'karmaScore'; return c.type || ''; } module.exports = ({ cooler }) => { let ssb; const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }; const hasBlob = async (ssbClient, url) => new Promise(resolve => ssbClient.blobs.has(url, (err, has) => resolve(!err && has))); return { async listFeed(filter = 'all') { const ssbClient = await openSsb(); const userId = ssbClient.id; const results = await new Promise((resolve, reject) => { pull( ssbClient.createLogStream({ reverse: true, limit: logLimit }), pull.collect((err, msgs) => err ? reject(err) : resolve(msgs)) ); }); const tombstoned = new Set(); const parentOf = new Map(); const idToAction = new Map(); const rawById = new Map(); for (const msg of results) { const k = msg.key; const v = msg.value; const c = v?.content; if (!c?.type) continue; if (c.type === 'tombstone' && c.target) { tombstoned.add(c.target); continue } const ts = v?.timestamp || Number(c?.timestamp || 0) || (c?.updatedAt ? Date.parse(c.updatedAt) : 0) || 0; idToAction.set(k, { id: k, author: v?.author, ts, type: inferType(c), content: c }); rawById.set(k, msg); if (c.replaces) parentOf.set(k, c.replaces); } const rootOf = (id) => { let cur = id; while (parentOf.has(cur)) cur = parentOf.get(cur); return cur }; const groups = new Map(); for (const [id, action] of idToAction.entries()) { const root = rootOf(id); if (!groups.has(root)) groups.set(root, []); groups.get(root).push(action); } const idToTipId = new Map(); for (const [root, arr] of groups.entries()) { if (!arr.length) continue; const type = arr[0].type; if (type !== 'project') { const tip = arr.reduce((best, a) => (a.ts > best.ts ? a : best), arr[0]); for (const a of arr) idToTipId.set(a.id, tip.id); continue; } let tip = arr[0]; let bestScore = SCORE_PROJECT(tip.content.status); for (const a of arr) { const s = SCORE_PROJECT(a.content.status); if (s > bestScore || (s === bestScore && a.ts > tip.ts)) { tip = a; bestScore = s } } for (const a of arr) idToTipId.set(a.id, tip.id); const baseTitle = (tip.content && tip.content.title) || ''; const overlays = arr .filter(a => a.type === 'project' && (a.content.followersOp || a.content.backerPledge)) .sort((a, b) => (a.ts || 0) - (b.ts || 0)); for (const ev of overlays) { if (tombstoned.has(ev.id)) continue; let kind = null; let amount = null; if (ev.content.followersOp === 'follow') kind = 'follow'; else if (ev.content.followersOp === 'unfollow') kind = 'unfollow'; if (ev.content.backerPledge && typeof ev.content.backerPledge.amount !== 'undefined') { const amt = Math.max(0, parseFloat(ev.content.backerPledge.amount || 0) || 0); if (amt > 0) { kind = kind || 'pledge'; amount = amt } } if (!kind) continue; const augmented = { ...ev, type: 'project', content: { ...ev.content, title: baseTitle, projectId: tip.id, activity: { kind, amount }, activityActor: ev.author } }; idToAction.set(ev.id, augmented); idToTipId.set(ev.id, ev.id); } } const latest = []; for (const a of idToAction.values()) { if (tombstoned.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; if (c.key && tombstoned.has(c.key)) continue; if (c.branch && tombstoned.has(c.branch)) continue; if (c.target && tombstoned.has(c.target)) continue; if (a.type === 'document') { const url = c.url; const ok = await hasBlob(ssbClient, url); if (!ok) continue; } if (a.type === 'forum' && c.root) { const rootId = typeof c.root === 'string' ? c.root : (c.root?.key || c.root?.id || ''); const rootAction = idToAction.get(rootId); a.content.rootTitle = rootAction?.content?.title || a.content.rootTitle || ''; a.content.rootKey = rootId || a.content.rootKey || ''; } latest.push({ ...a, tipId: idToTipId.get(a.id) || a.id }); } let deduped = latest.filter(a => !a.tipId || a.tipId === a.id); const mediaTypes = new Set(['image','video','audio','document','bookmark']); const perAuthorUnique = new Set(['karmaScore']); const byKey = new Map(); for (const a of deduped) { if (mediaTypes.has(a.type)) { const u = a.content?.url || a.content?.title || `${a.type}:${a.id}`; const key = `${a.type}:${u}`; const prev = byKey.get(key); if (!prev || a.ts > prev.ts) byKey.set(key, a); } else if (perAuthorUnique.has(a.type)) { const key = `${a.type}:${a.author}`; const prev = byKey.get(key); if (!prev || a.ts > prev.ts) byKey.set(key, a); } else { const key = `id:${a.id}`; byKey.set(key, a); } } deduped = Array.from(byKey.values()); let out; if (filter === 'mine') out = deduped.filter(a => a.author === userId); else if (filter === 'recent') { const cutoff = Date.now() - 24 * 60 * 60 * 1000; out = deduped.filter(a => (a.ts || 0) >= cutoff) } 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 out = deduped.filter(a => a.type === filter); out.sort((a, b) => (b.ts || 0) - (a.ts || 0)); return out; } }; };