activity_model.js 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  1. const pull = require('../server/node_modules/pull-stream');
  2. const { getConfig } = require('../configs/config-manager.js');
  3. const logLimit = getConfig().ssbLogStream?.limit || 1000;
  4. const N = s => String(s || '').toUpperCase().replace(/\s+/g, '_');
  5. const ORDER_MARKET = ['FOR_SALE','OPEN','RESERVED','CLOSED','SOLD'];
  6. const ORDER_PROJECT = ['CANCELLED','PAUSED','ACTIVE','COMPLETED'];
  7. const SCORE_MARKET = s => { const i = ORDER_MARKET.indexOf(N(s)); return i < 0 ? -1 : i };
  8. const SCORE_PROJECT = s => { const i = ORDER_PROJECT.indexOf(N(s)); return i < 0 ? -1 : i };
  9. function inferType(c = {}) {
  10. if (c.type === 'wallet' && c.coin === 'ECO' && typeof c.address === 'string') return 'bankWallet';
  11. if (c.type === 'bankClaim') return 'bankClaim';
  12. if (c.type === 'karmaScore') return 'karmaScore';
  13. return c.type || '';
  14. }
  15. module.exports = ({ cooler }) => {
  16. let ssb;
  17. const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb };
  18. const hasBlob = async (ssbClient, url) => new Promise(resolve => ssbClient.blobs.has(url, (err, has) => resolve(!err && has)));
  19. return {
  20. async listFeed(filter = 'all') {
  21. const ssbClient = await openSsb();
  22. const userId = ssbClient.id;
  23. const results = await new Promise((resolve, reject) => {
  24. pull(
  25. ssbClient.createLogStream({ reverse: true, limit: logLimit }),
  26. pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
  27. );
  28. });
  29. const tombstoned = new Set();
  30. const parentOf = new Map();
  31. const idToAction = new Map();
  32. const rawById = new Map();
  33. for (const msg of results) {
  34. const k = msg.key;
  35. const v = msg.value;
  36. const c = v?.content;
  37. if (!c?.type) continue;
  38. if (c.type === 'tombstone' && c.target) { tombstoned.add(c.target); continue }
  39. const ts = v?.timestamp || Number(c?.timestamp || 0) || (c?.updatedAt ? Date.parse(c.updatedAt) : 0) || 0;
  40. idToAction.set(k, { id: k, author: v?.author, ts, type: inferType(c), content: c });
  41. rawById.set(k, msg);
  42. if (c.replaces) parentOf.set(k, c.replaces);
  43. }
  44. const rootOf = (id) => { let cur = id; while (parentOf.has(cur)) cur = parentOf.get(cur); return cur };
  45. const groups = new Map();
  46. for (const [id, action] of idToAction.entries()) {
  47. const root = rootOf(id);
  48. if (!groups.has(root)) groups.set(root, []);
  49. groups.get(root).push(action);
  50. }
  51. const idToTipId = new Map();
  52. for (const [root, arr] of groups.entries()) {
  53. if (!arr.length) continue;
  54. const type = arr[0].type;
  55. if (type !== 'project') {
  56. const tip = arr.reduce((best, a) => (a.ts > best.ts ? a : best), arr[0]);
  57. for (const a of arr) idToTipId.set(a.id, tip.id);
  58. continue;
  59. }
  60. let tip = arr[0];
  61. let bestScore = SCORE_PROJECT(tip.content.status);
  62. for (const a of arr) {
  63. const s = SCORE_PROJECT(a.content.status);
  64. if (s > bestScore || (s === bestScore && a.ts > tip.ts)) { tip = a; bestScore = s }
  65. }
  66. for (const a of arr) idToTipId.set(a.id, tip.id);
  67. const baseTitle = (tip.content && tip.content.title) || '';
  68. const overlays = arr
  69. .filter(a => a.type === 'project' && (a.content.followersOp || a.content.backerPledge))
  70. .sort((a, b) => (a.ts || 0) - (b.ts || 0));
  71. for (const ev of overlays) {
  72. if (tombstoned.has(ev.id)) continue;
  73. let kind = null;
  74. let amount = null;
  75. if (ev.content.followersOp === 'follow') kind = 'follow';
  76. else if (ev.content.followersOp === 'unfollow') kind = 'unfollow';
  77. if (ev.content.backerPledge && typeof ev.content.backerPledge.amount !== 'undefined') {
  78. const amt = Math.max(0, parseFloat(ev.content.backerPledge.amount || 0) || 0);
  79. if (amt > 0) { kind = kind || 'pledge'; amount = amt }
  80. }
  81. if (!kind) continue;
  82. const augmented = {
  83. ...ev,
  84. type: 'project',
  85. content: {
  86. ...ev.content,
  87. title: baseTitle,
  88. projectId: tip.id,
  89. activity: { kind, amount },
  90. activityActor: ev.author
  91. }
  92. };
  93. idToAction.set(ev.id, augmented);
  94. idToTipId.set(ev.id, ev.id);
  95. }
  96. }
  97. const latest = [];
  98. for (const a of idToAction.values()) {
  99. if (tombstoned.has(a.id)) continue;
  100. const c = a.content || {};
  101. if (c.root && tombstoned.has(c.root)) continue;
  102. if (a.type === 'vote' && tombstoned.has(c.vote?.link)) continue;
  103. if (c.key && tombstoned.has(c.key)) continue;
  104. if (c.branch && tombstoned.has(c.branch)) continue;
  105. if (c.target && tombstoned.has(c.target)) continue;
  106. if (a.type === 'document') {
  107. const url = c.url;
  108. const ok = await hasBlob(ssbClient, url);
  109. if (!ok) continue;
  110. }
  111. if (a.type === 'forum' && c.root) {
  112. const rootId = typeof c.root === 'string' ? c.root : (c.root?.key || c.root?.id || '');
  113. const rootAction = idToAction.get(rootId);
  114. a.content.rootTitle = rootAction?.content?.title || a.content.rootTitle || '';
  115. a.content.rootKey = rootId || a.content.rootKey || '';
  116. }
  117. latest.push({ ...a, tipId: idToTipId.get(a.id) || a.id });
  118. }
  119. let deduped = latest.filter(a => !a.tipId || a.tipId === a.id);
  120. const mediaTypes = new Set(['image','video','audio','document','bookmark']);
  121. const perAuthorUnique = new Set(['karmaScore']);
  122. const byKey = new Map();
  123. for (const a of deduped) {
  124. if (mediaTypes.has(a.type)) {
  125. const u = a.content?.url || a.content?.title || `${a.type}:${a.id}`;
  126. const key = `${a.type}:${u}`;
  127. const prev = byKey.get(key);
  128. if (!prev || a.ts > prev.ts) byKey.set(key, a);
  129. } else if (perAuthorUnique.has(a.type)) {
  130. const key = `${a.type}:${a.author}`;
  131. const prev = byKey.get(key);
  132. if (!prev || a.ts > prev.ts) byKey.set(key, a);
  133. } else {
  134. const key = `id:${a.id}`;
  135. byKey.set(key, a);
  136. }
  137. }
  138. deduped = Array.from(byKey.values());
  139. let out;
  140. if (filter === 'mine') out = deduped.filter(a => a.author === userId);
  141. else if (filter === 'recent') { const cutoff = Date.now() - 24 * 60 * 60 * 1000; out = deduped.filter(a => (a.ts || 0) >= cutoff) }
  142. else if (filter === 'all') out = deduped;
  143. else if (filter === 'banking') out = deduped.filter(a => a.type === 'bankWallet' || a.type === 'bankClaim');
  144. else if (filter === 'karma') out = deduped.filter(a => a.type === 'karmaScore');
  145. else out = deduped.filter(a => a.type === filter);
  146. out.sort((a, b) => (b.ts || 0) - (a.ts || 0));
  147. return out;
  148. }
  149. };
  150. };