const pull = require('../server/node_modules/pull-stream'); const ssbRef = require('../server/node_modules/ssb-ref'); const { getConfig } = require('../configs/config-manager.js'); const logLimit = getConfig().ssbLogStream?.limit || 1000; const safeFeedId = (v) => { if (typeof v === 'string' && ssbRef.isFeed(v)) return v; if (v && typeof v === 'object' && typeof v.link === 'string' && ssbRef.isFeed(v.link)) return v.link; return null; }; const isContentSane = (c) => { if (!c || typeof c !== 'object') return false; if (c.type === 'contact') return !!safeFeedId(c.contact); if (c.type === 'about') { if (c.about === undefined) return true; if (typeof c.about === 'string' && ssbRef.isFeed(c.about)) return true; return false; } if (c.type === 'pub') { const addr = c.address; if (!addr || typeof addr !== 'object') return false; return typeof addr.key === 'string' && ssbRef.isFeed(addr.key); } return true; }; 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'; if (c.type === 'courts_case') return 'courtsCase'; if (c.type === 'courts_evidence') return 'courtsEvidence'; if (c.type === 'courts_answer') return 'courtsAnswer'; if (c.type === 'courts_verdict') return 'courtsVerdict'; if (c.type === 'courts_settlement') return 'courtsSettlement'; if (c.type === 'courts_nomination') return 'courtsNomination'; if (c.type === 'courts_nom_vote') return 'courtsNominationVote'; if (c.type === 'courts_public_pref') return 'courtsPublicPref'; if (c.type === 'courts_mediators') return 'courtsMediators'; if (c.type === 'map') return 'map'; if (c.type === 'mapMarker') return 'mapMarker'; if (c.type === 'chat') return 'chat'; if (c.type === 'chatMessage') return 'chatMessage'; if (c.type === 'vote' && c.vote && typeof c.vote.link === 'string') { const br = Array.isArray(c.branch) ? c.branch : []; if (br.includes(c.vote.link) && Number(c.vote.value) === 1) return 'spread'; } return c.type || ''; } const HIDDEN_ENVELOPE_TYPES = new Set([ 'tribe-keys-distrib', 'tribe-invite-msg', 'tribe-invite-tombstone' ]); module.exports = ({ cooler, tribeCrypto, tribesModel }) => { let ssb; const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }; let _feedCache = null; let _feedCacheInflight = null; const FEED_CACHE_MS = 15 * 1000; const buildAccessibleTribeIds = async () => { const set = new Set(); if (!tribesModel) return set; try { const list = await tribesModel.listAll(); for (const t of list) { if (!t || !t.id) continue; set.add(t.id); try { const chain = await tribesModel.getChainIds(t.id); for (const cid of chain) set.add(cid); } catch (_) {} } } catch (_) {} return set; }; const hasBlob = async (ssbClient, url) => new Promise(resolve => ssbClient.blobs.has(url, (err, has) => resolve(!err && has))); const getMsg = async (ssbClient, key) => new Promise(resolve => ssbClient.get(key, (err, msg) => resolve(err ? null : msg))); const normNL = (s) => String(s || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n'); const stripHtml = (s) => normNL(s) .replace(//gi, '\n') .replace(/<\/p\s*>/gi, '\n\n') .replace(/<[^>]*>/g, '') .replace(/[ \t]+\n/g, '\n') .replace(/\n{3,}/g, '\n\n') .trim(); const excerpt = (s, max = 900) => { const t = stripHtml(s); if (!t) return ''; return t.length > max ? t.slice(0, max - 1) + '…' : t; }; return { invalidateCache() { _feedCache = null; _feedCacheInflight = null; }, async listFeed(filter = 'all') { const cacheKey = filter || 'all'; const now = Date.now(); if (!_feedCache) _feedCache = new Map(); const entry = _feedCache.get(cacheKey); if (entry && now - entry.ts < FEED_CACHE_MS) return entry.value; if (!_feedCacheInflight) _feedCacheInflight = new Map(); if (_feedCacheInflight.has(cacheKey)) return _feedCacheInflight.get(cacheKey); const promise = (async () => { 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(); const fpIdx = tribeCrypto ? tribeCrypto.buildFingerprintIndex() : null; const accessibleTribeIds = await buildAccessibleTribeIds(); for (const msg of results) { const k = msg.key; const v = msg.value; let c = v?.content; if (!c) continue; if (typeof c === 'string' && c.endsWith('.box')) continue; if (c.type && HIDDEN_ENVELOPE_TYPES.has(c.type)) continue; if (tribeCrypto && tribeCrypto.isTribeMsg(c)) { const r = fpIdx ? tribeCrypto.unwrapMsg(c, fpIdx) : null; if (!r || !r.body) continue; const inner = r.body; if (inner.k !== 'tribe' || inner.op !== 'create') continue; c = { ...inner, type: 'tribe', _decrypted: true, _rootId: r.rootId }; } else if (c.tribeId && !accessibleTribeIds.has(c.tribeId)) { continue; } if (!c.type) continue; if (c.type === 'tombstone' && c.target) { tombstoned.add(c.target); continue } if (!isContentSane(c)) continue; const ts = v?.timestamp || Number(c?.timestamp || 0) || (c?.updatedAt ? Date.parse(c.updatedAt) : 0) || 0; const normalized = c.type === 'contact' ? { ...c, contact: safeFeedId(c.contact) } : c; idToAction.set(k, { id: k, author: v?.author, ts, type: inferType(c), content: normalized }); rawById.set(k, msg); if (c.replaces) parentOf.set(k, c.replaces); } const replacedIds = new Set(parentOf.values()); const spreadVoteState = new Map(); for (const a of idToAction.values()) { const c = a.content || {}; if (c.type !== 'vote' || !c.vote || typeof c.vote.link !== 'string') continue; const link = c.vote.link; const br = Array.isArray(c.branch) ? c.branch : []; if (!br.includes(link)) continue; if (tombstoned.has(a.id)) continue; if (replacedIds.has(a.id)) continue; if (tombstoned.has(link)) continue; const author = a.author; if (!author) continue; const value = Number(c.vote.value); const key = `${link}:${author}`; const prev = spreadVoteState.get(key); const curTs = a.ts || 0; if (!prev || curTs > prev.ts || (curTs === prev.ts && String(a.id || '').localeCompare(String(prev.id || '')) > 0)) { spreadVoteState.set(key, { ts: curTs, id: a.id, value, link }); } } const spreadCountByTarget = new Map(); for (const v of spreadVoteState.values()) { if (Number(v.value) !== 1) continue; spreadCountByTarget.set(v.link, (spreadCountByTarget.get(v.link) || 0) + 1); } const fetchedTargetCache = new Map(); for (const a of idToAction.values()) { if (a.type !== 'spread') continue; const c = a.content || {}; const link = c.vote?.link || ''; const totalSpreads = link ? (spreadCountByTarget.get(link) || 0) : 0; let targetMsg = link ? rawById.get(link) : null; if (!targetMsg && link) { if (fetchedTargetCache.has(link)) targetMsg = fetchedTargetCache.get(link); else { const got = await getMsg(ssbClient, link); if (got) { const wrapped = { key: link, value: got }; fetchedTargetCache.set(link, wrapped); targetMsg = wrapped; } else { fetchedTargetCache.set(link, null); targetMsg = null; } } } const targetContent = targetMsg?.value?.content || null; const title = (typeof targetContent?.title === 'string' && targetContent.title.trim()) ? targetContent.title.trim() : (typeof targetContent?.name === 'string' && targetContent.name.trim()) ? targetContent.name.trim() : ''; const rawText = (typeof targetContent?.text === 'string' && targetContent.text.trim()) ? targetContent.text : (typeof targetContent?.description === 'string' && targetContent.description.trim()) ? targetContent.description : ''; const text = rawText ? excerpt(rawText, 700) : ''; const cw = (typeof targetContent?.contentWarning === 'string' && targetContent.contentWarning.trim()) ? targetContent.contentWarning : ''; a.content = { ...c, spreadTargetId: link, spreadTotalSpreads: totalSpreads, spreadOriginalAuthor: targetMsg?.value?.author || '', spreadTitle: title, spreadContentWarning: cw, spreadText: text }; } 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); if (type === 'task' && tip && tip.content && tip.content.isPublic !== 'PRIVATE') { const uniq = (xs) => Array.from(new Set((Array.isArray(xs) ? xs : []).filter(x => typeof x === 'string' && x.trim().length))); const sorted = arr .filter(a => a.type === 'task' && a.content && typeof a.content === 'object') .sort((a, b) => (a.ts || 0) - (b.ts || 0)); let prev = null; for (const ev of sorted) { const cur = uniq(ev.content.assignees); if (prev) { const prevSet = new Set(prev); const curSet = new Set(cur); const added = cur.filter(x => !prevSet.has(x)); const removed = prev.filter(x => !curSet.has(x)); if (added.length || removed.length) { const overlayId = `${ev.id}:assignees:${added.join(',')}:${removed.join(',')}`; idToAction.set(overlayId, { id: overlayId, author: ev.author, ts: ev.ts, type: 'taskAssignment', content: { taskId: tip.id, title: tip.content.title || ev.content.title || '', added, removed, isPublic: tip.content.isPublic } }); idToTipId.set(overlayId, overlayId); } } prev = cur; } } 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; 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; if (a.type === 'spread' && (c.spreadTargetId || c.vote?.link) && tombstoned.has(c.spreadTargetId || 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 || ''; } const actionRoot = rootOf(a.id); latest.push({ ...a, tipId: idToTipId.get(a.id) || a.id, rootId: actionRoot !== a.id ? actionRoot : null }); } 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','map']); const perAuthorUnique = new Set(['karmaScore']); const byKey = new Map(); const norm = s => String(s || '').trim().toLowerCase(); for (const a of deduped) { const c = a.content || {}; const effTs = (c.updatedAt && Date.parse(c.updatedAt)) || (c.createdAt && Date.parse(c.createdAt)) || (a.ts || 0); if (mediaTypes.has(a.type)) { const u = c.url || c.title || `${a.type}:${a.id}`; const key = `${a.type}:${u}`; const prev = byKey.get(key); if (!prev || effTs > prev.__effTs) byKey.set(key, { ...a, __effTs: effTs }); } else if (perAuthorUnique.has(a.type)) { const key = `${a.type}:${a.author}`; const prev = byKey.get(key); if (!prev || effTs > prev.__effTs) byKey.set(key, { ...a, __effTs: effTs }); } else if (a.type === 'about') { const target = c.about || a.author; const key = `about:${target}`; const prev = byKey.get(key); const prevContent = prev && (prev.content || {}); const prevHasImage = !!(prevContent && prevContent.image); const newHasImage = !!c.image; if (!prev) { byKey.set(key, { ...a, __effTs: effTs, __hasImage: newHasImage }); } else if (!prevHasImage && newHasImage) { byKey.set(key, { ...a, __effTs: effTs, __hasImage: newHasImage }); } else if (prevHasImage === newHasImage && effTs > prev.__effTs) { byKey.set(key, { ...a, __effTs: effTs, __hasImage: newHasImage }); } } else if (a.type === 'tribe') { const t = norm(c.title); if (t) { const key = `tribe:${t}::${a.author}`; const prev = byKey.get(key); if (!prev || effTs > prev.__effTs) byKey.set(key, { ...a, __effTs: effTs }); } else { const key = `id:${a.id}`; byKey.set(key, { ...a, __effTs: effTs }); } } else { const key = `id:${a.id}`; byKey.set(key, { ...a, __effTs: effTs }); } } deduped = Array.from(byKey.values()).map(x => { delete x.__effTs; delete x.__hasImage; return x }); const tribeInternalTypes = new Set(['tribe-content', 'tribeParliamentCandidature', 'tribeParliamentTerm', 'tribeParliamentProposal', 'tribeParliamentRule', 'tribeParliamentLaw', 'tribeParliamentRevocation']); const hiddenTypes = new Set(['padEntry', 'chatMessage', 'calendarDate', 'calendarNote', 'calendarReminderSent', 'taskReminderSent', 'feed-action', 'pubBalance', 'pubAvailability', 'log']); const isAllowedTribeActivity = (a) => { if (tribeInternalTypes.has(a.type)) return false; const c = a.content || {}; if (c.tribeId) return false; if (a.type === 'tribe') { const isInitial = !c.replaces; if (!isInitial) return false; if (c.isAnonymous === true && !c._decrypted) return false; } return true; }; const isVisible = (a) => { if (hiddenTypes.has(a.type)) return false; if (a.type === 'pad' && (a.content || {}).status !== 'OPEN') return false; if (a.type === 'chat' && (a.content || {}).status !== 'OPEN') return false; if (a.type === 'calendar' && (a.content || {}).status !== 'OPEN') return false; if (a.type === 'event' && String((a.content || {}).isPublic || '').toLowerCase() === 'private' && (a.content || {}).organizer !== userId && !(Array.isArray((a.content || {}).attendees) && (a.content || {}).attendees.includes(userId))) return false; if (a.type === 'task' && String((a.content || {}).isPublic || '').toUpperCase() === 'PRIVATE' && a.author !== userId && !(Array.isArray((a.content || {}).assignees) && (a.content || {}).assignees.includes(userId))) return false; return true; }; let out; if (filter === 'mine') out = deduped.filter(a => a.author === userId && isAllowedTribeActivity(a) && isVisible(a)); else if (filter === 'recent') { const cutoff = Date.now() - 24 * 60 * 60 * 1000; out = deduped.filter(a => (a.ts || 0) >= cutoff && isAllowedTribeActivity(a) && isVisible(a)) } else if (filter === 'all') out = deduped.filter(a => isAllowedTribeActivity(a) && isVisible(a)); else if (filter === 'banking') out = deduped.filter(a => a.type === 'bankWallet' || a.type === 'bankClaim' || a.type === 'ubiClaim' || a.type === 'ubiclaimresult'); 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 === 'spread') out = deduped.filter(a => a.type === 'spread'); else if (filter === 'parliament') out = deduped.filter(a => ['parliamentCandidature','parliamentTerm','parliamentProposal','parliamentRevocation','parliamentLaw'].includes(a.type) ); else if (filter === 'courts') out = deduped.filter(a => { const t = String(a.type || '').toLowerCase(); return t === 'courtscase' || t === 'courtsnomination' || t === 'courtsnominationvote'; }); else if (filter === 'task') out = deduped.filter(a => a.type === 'task' || a.type === 'taskAssignment'); else if (filter === 'gameScore') out = deduped.filter(a => a.type === 'gameScore'); else if (filter === 'pad') out = deduped.filter(a => a.type === 'pad' && (a.content || {}).status === 'OPEN'); else if (filter === 'chat') out = deduped.filter(a => a.type === 'chat' && (a.content || {}).status === 'OPEN'); else if (filter === 'calendar') out = deduped.filter(a => a.type === 'calendar' && (a.content || {}).status === 'OPEN'); else out = deduped.filter(a => a.type === filter); out.sort((a, b) => (b.ts || 0) - (a.ts || 0)); return out; })(); _feedCacheInflight.set(cacheKey, promise); try { const value = await promise; _feedCache.set(cacheKey, { value, ts: Date.now() }); return value; } finally { _feedCacheInflight.delete(cacheKey); } } }; };