activity_model.js 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543
  1. const pull = require('../server/node_modules/pull-stream');
  2. const ssbRef = require('../server/node_modules/ssb-ref');
  3. const { getConfig } = require('../configs/config-manager.js');
  4. const logLimit = getConfig().ssbLogStream?.limit || 1000;
  5. const safeFeedId = (v) => {
  6. if (typeof v === 'string' && ssbRef.isFeed(v)) return v;
  7. if (v && typeof v === 'object' && typeof v.link === 'string' && ssbRef.isFeed(v.link)) return v.link;
  8. return null;
  9. };
  10. const isContentSane = (c) => {
  11. if (!c || typeof c !== 'object') return false;
  12. if (c.type === 'contact') return !!safeFeedId(c.contact);
  13. if (c.type === 'about') {
  14. if (c.about === undefined) return true;
  15. if (typeof c.about === 'string' && ssbRef.isFeed(c.about)) return true;
  16. return false;
  17. }
  18. if (c.type === 'pub') {
  19. const addr = c.address;
  20. if (!addr || typeof addr !== 'object') return false;
  21. return typeof addr.key === 'string' && ssbRef.isFeed(addr.key);
  22. }
  23. return true;
  24. };
  25. const N = s => String(s || '').toUpperCase().replace(/\s+/g, '_');
  26. const ORDER_MARKET = ['FOR_SALE','OPEN','RESERVED','CLOSED','SOLD'];
  27. const ORDER_PROJECT = ['CANCELLED','PAUSED','ACTIVE','COMPLETED'];
  28. const SCORE_MARKET = s => { const i = ORDER_MARKET.indexOf(N(s)); return i < 0 ? -1 : i };
  29. const SCORE_PROJECT = s => { const i = ORDER_PROJECT.indexOf(N(s)); return i < 0 ? -1 : i };
  30. function inferType(c = {}) {
  31. if (c.type === 'wallet' && c.coin === 'ECO' && typeof c.address === 'string') return 'bankWallet';
  32. if (c.type === 'bankClaim') return 'bankClaim';
  33. if (c.type === 'karmaScore') return 'karmaScore';
  34. if (c.type === 'courts_case') return 'courtsCase';
  35. if (c.type === 'courts_evidence') return 'courtsEvidence';
  36. if (c.type === 'courts_answer') return 'courtsAnswer';
  37. if (c.type === 'courts_verdict') return 'courtsVerdict';
  38. if (c.type === 'courts_settlement') return 'courtsSettlement';
  39. if (c.type === 'courts_nomination') return 'courtsNomination';
  40. if (c.type === 'courts_nom_vote') return 'courtsNominationVote';
  41. if (c.type === 'courts_public_pref') return 'courtsPublicPref';
  42. if (c.type === 'courts_mediators') return 'courtsMediators';
  43. if (c.type === 'map') return 'map';
  44. if (c.type === 'mapMarker') return 'mapMarker';
  45. if (c.type === 'chat') return 'chat';
  46. if (c.type === 'chatMessage') return 'chatMessage';
  47. if (c.type === 'vote' && c.vote && typeof c.vote.link === 'string') {
  48. const br = Array.isArray(c.branch) ? c.branch : [];
  49. if (br.includes(c.vote.link) && Number(c.vote.value) === 1) return 'spread';
  50. }
  51. return c.type || '';
  52. }
  53. const HIDDEN_ENVELOPE_TYPES = new Set([
  54. 'tribe-keys-distrib',
  55. 'tribe-keys',
  56. 'tribe-invite-msg',
  57. 'tribe-invite-tombstone',
  58. 'aiExchangeVote',
  59. 'event-invite',
  60. 'forum-invite',
  61. 'larpJoinHouse',
  62. 'larpLeaveLarp',
  63. 'larpTestAttempt',
  64. 'larpHousePost',
  65. 'larpHouseInvite',
  66. 'larpHouseInviteRedeem',
  67. 'larpHouseTribeAnchor',
  68. 'larpHouseTribeAnchorTombstone',
  69. 'larpAutoInvite',
  70. 'karmaScore',
  71. 'pubAvailability',
  72. 'calendarReminderSent',
  73. 'taskReminderSent',
  74. 'ubiClaimResult',
  75. 'ubiAllocation',
  76. 'courts-key'
  77. ]);
  78. module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
  79. let ssb;
  80. const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb };
  81. let _feedCache = null;
  82. let _feedCacheInflight = null;
  83. const FEED_CACHE_MS = 15 * 1000;
  84. const buildAccessibleTribeIds = async () => {
  85. const set = new Set();
  86. if (!tribesModel) return set;
  87. try {
  88. const list = await tribesModel.listAll();
  89. for (const t of list) {
  90. if (!t || !t.id) continue;
  91. set.add(t.id);
  92. try {
  93. const chain = await tribesModel.getChainIds(t.id);
  94. for (const cid of chain) set.add(cid);
  95. } catch (_) {}
  96. }
  97. } catch (_) {}
  98. return set;
  99. };
  100. const hasBlob = async (ssbClient, url) => new Promise(resolve => ssbClient.blobs.has(url, (err, has) => resolve(!err && has)));
  101. const getMsg = async (ssbClient, key) => new Promise(resolve => ssbClient.get(key, (err, msg) => resolve(err ? null : msg)));
  102. const normNL = (s) => String(s || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
  103. const stripHtml = (s) => normNL(s)
  104. .replace(/<br\s*\/?>/gi, '\n')
  105. .replace(/<\/p\s*>/gi, '\n\n')
  106. .replace(/<[^>]*>/g, '')
  107. .replace(/[ \t]+\n/g, '\n')
  108. .replace(/\n{3,}/g, '\n\n')
  109. .trim();
  110. const excerpt = (s, max = 900) => {
  111. const t = stripHtml(s);
  112. if (!t) return '';
  113. return t.length > max ? t.slice(0, max - 1) + '…' : t;
  114. };
  115. return {
  116. invalidateCache() {
  117. _feedCache = null;
  118. _feedCacheInflight = null;
  119. },
  120. async listFeed(filter = 'all') {
  121. const cacheKey = filter || 'all';
  122. const now = Date.now();
  123. if (!_feedCache) _feedCache = new Map();
  124. const entry = _feedCache.get(cacheKey);
  125. if (entry && now - entry.ts < FEED_CACHE_MS) return entry.value;
  126. if (!_feedCacheInflight) _feedCacheInflight = new Map();
  127. if (_feedCacheInflight.has(cacheKey)) return _feedCacheInflight.get(cacheKey);
  128. const promise = (async () => {
  129. const ssbClient = await openSsb();
  130. const userId = ssbClient.id;
  131. const results = await new Promise((resolve, reject) => {
  132. pull(
  133. ssbClient.createLogStream({ reverse: true, limit: logLimit }),
  134. pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
  135. );
  136. });
  137. const tombstoned = new Set();
  138. const parentOf = new Map();
  139. const idToAction = new Map();
  140. const rawById = new Map();
  141. const fpIdx = tribeCrypto ? tribeCrypto.buildFingerprintIndex() : null;
  142. const accessibleTribeIds = await buildAccessibleTribeIds();
  143. for (const msg of results) {
  144. const k = msg.key;
  145. const v = msg.value;
  146. let c = v?.content;
  147. if (!c) continue;
  148. if (typeof c === 'string' && c.endsWith('.box')) continue;
  149. if (c.type && HIDDEN_ENVELOPE_TYPES.has(c.type)) continue;
  150. if (tribeCrypto && tribeCrypto.isTribeMsg(c)) {
  151. const r = fpIdx ? tribeCrypto.unwrapMsg(c, fpIdx) : null;
  152. if (!r || !r.body) continue;
  153. const inner = r.body;
  154. if (inner.k !== 'tribe' || inner.op !== 'create') continue;
  155. c = { ...inner, type: 'tribe', _decrypted: true, _rootId: r.rootId };
  156. } else if (c.tribeId && !accessibleTribeIds.has(c.tribeId)) {
  157. continue;
  158. }
  159. if (!c.type) continue;
  160. if (c.type === 'tombstone' && c.target) { tombstoned.add(c.target); continue }
  161. if (!isContentSane(c)) continue;
  162. const ts = v?.timestamp || Number(c?.timestamp || 0) || (c?.updatedAt ? Date.parse(c.updatedAt) : 0) || 0;
  163. const normalized = c.type === 'contact' ? { ...c, contact: safeFeedId(c.contact) } : c;
  164. idToAction.set(k, { id: k, author: v?.author, ts, type: inferType(c), content: normalized });
  165. rawById.set(k, msg);
  166. if (c.replaces) parentOf.set(k, c.replaces);
  167. }
  168. const replacedIds = new Set(parentOf.values());
  169. const spreadVoteState = new Map();
  170. const aiHelpfulCounts = new Map();
  171. const aiHelpfulSeen = new Map();
  172. for (const msg of results) {
  173. const v = msg.value;
  174. const c = v && v.content;
  175. if (!c || c.type !== 'aiExchangeVote') continue;
  176. const target = String(c.target || '');
  177. const author = v.author;
  178. if (!target || !author) continue;
  179. const key = `${target}�${author}`;
  180. const prev = aiHelpfulSeen.get(key);
  181. const curTs = v.timestamp || 0;
  182. if (!prev || curTs > prev.ts) aiHelpfulSeen.set(key, { target, ts: curTs, helpful: c.helpful !== false });
  183. }
  184. for (const { target, helpful } of aiHelpfulSeen.values()) {
  185. if (!helpful) continue;
  186. aiHelpfulCounts.set(target, (aiHelpfulCounts.get(target) || 0) + 1);
  187. }
  188. for (const a of idToAction.values()) {
  189. const c = a.content || {};
  190. if (c.type !== 'vote' || !c.vote || typeof c.vote.link !== 'string') continue;
  191. const link = c.vote.link;
  192. const br = Array.isArray(c.branch) ? c.branch : [];
  193. if (!br.includes(link)) continue;
  194. if (tombstoned.has(a.id)) continue;
  195. if (replacedIds.has(a.id)) continue;
  196. if (tombstoned.has(link)) continue;
  197. const author = a.author;
  198. if (!author) continue;
  199. const value = Number(c.vote.value);
  200. const key = `${link}:${author}`;
  201. const prev = spreadVoteState.get(key);
  202. const curTs = a.ts || 0;
  203. if (!prev || curTs > prev.ts || (curTs === prev.ts && String(a.id || '').localeCompare(String(prev.id || '')) > 0)) {
  204. spreadVoteState.set(key, { ts: curTs, id: a.id, value, link });
  205. }
  206. }
  207. const spreadCountByTarget = new Map();
  208. for (const v of spreadVoteState.values()) {
  209. if (Number(v.value) !== 1) continue;
  210. spreadCountByTarget.set(v.link, (spreadCountByTarget.get(v.link) || 0) + 1);
  211. }
  212. const fetchedTargetCache = new Map();
  213. for (const a of idToAction.values()) {
  214. if (a.type !== 'spread') continue;
  215. const c = a.content || {};
  216. const link = c.vote?.link || '';
  217. const totalSpreads = link ? (spreadCountByTarget.get(link) || 0) : 0;
  218. let targetMsg = link ? rawById.get(link) : null;
  219. if (!targetMsg && link) {
  220. if (fetchedTargetCache.has(link)) targetMsg = fetchedTargetCache.get(link);
  221. else {
  222. const got = await getMsg(ssbClient, link);
  223. if (got) {
  224. const wrapped = { key: link, value: got };
  225. fetchedTargetCache.set(link, wrapped);
  226. targetMsg = wrapped;
  227. } else {
  228. fetchedTargetCache.set(link, null);
  229. targetMsg = null;
  230. }
  231. }
  232. }
  233. const targetContent = targetMsg?.value?.content || null;
  234. const title =
  235. (typeof targetContent?.title === 'string' && targetContent.title.trim())
  236. ? targetContent.title.trim()
  237. : (typeof targetContent?.name === 'string' && targetContent.name.trim())
  238. ? targetContent.name.trim()
  239. : '';
  240. const rawText =
  241. (typeof targetContent?.text === 'string' && targetContent.text.trim())
  242. ? targetContent.text
  243. : (typeof targetContent?.description === 'string' && targetContent.description.trim())
  244. ? targetContent.description
  245. : '';
  246. const text = rawText ? excerpt(rawText, 700) : '';
  247. const cw =
  248. (typeof targetContent?.contentWarning === 'string' && targetContent.contentWarning.trim())
  249. ? targetContent.contentWarning
  250. : '';
  251. a.content = {
  252. ...c,
  253. spreadTargetId: link,
  254. spreadTotalSpreads: totalSpreads,
  255. spreadOriginalAuthor: targetMsg?.value?.author || '',
  256. spreadTitle: title,
  257. spreadContentWarning: cw,
  258. spreadText: text
  259. };
  260. }
  261. const rootOf = (id) => { let cur = id; while (parentOf.has(cur)) cur = parentOf.get(cur); return cur };
  262. const groups = new Map();
  263. for (const [id, action] of idToAction.entries()) {
  264. const root = rootOf(id);
  265. if (!groups.has(root)) groups.set(root, []);
  266. groups.get(root).push(action);
  267. }
  268. const idToTipId = new Map();
  269. for (const [root, arr] of groups.entries()) {
  270. if (!arr.length) continue;
  271. const type = arr[0].type;
  272. if (type !== 'project') {
  273. const tip = arr.reduce((best, a) => (a.ts > best.ts ? a : best), arr[0]);
  274. for (const a of arr) idToTipId.set(a.id, tip.id);
  275. if (type === 'task' && tip && tip.content && tip.content.isPublic !== 'PRIVATE') {
  276. const uniq = (xs) => Array.from(new Set((Array.isArray(xs) ? xs : []).filter(x => typeof x === 'string' && x.trim().length)));
  277. const sorted = arr
  278. .filter(a => a.type === 'task' && a.content && typeof a.content === 'object')
  279. .sort((a, b) => (a.ts || 0) - (b.ts || 0));
  280. let prev = null;
  281. for (const ev of sorted) {
  282. const cur = uniq(ev.content.assignees);
  283. if (prev) {
  284. const prevSet = new Set(prev);
  285. const curSet = new Set(cur);
  286. const added = cur.filter(x => !prevSet.has(x));
  287. const removed = prev.filter(x => !curSet.has(x));
  288. if (added.length || removed.length) {
  289. const overlayId = `${ev.id}:assignees:${added.join(',')}:${removed.join(',')}`;
  290. idToAction.set(overlayId, {
  291. id: overlayId,
  292. author: ev.author,
  293. ts: ev.ts,
  294. type: 'taskAssignment',
  295. content: {
  296. taskId: tip.id,
  297. title: tip.content.title || ev.content.title || '',
  298. added,
  299. removed,
  300. isPublic: tip.content.isPublic
  301. }
  302. });
  303. idToTipId.set(overlayId, overlayId);
  304. }
  305. }
  306. prev = cur;
  307. }
  308. }
  309. continue;
  310. }
  311. let tip = arr[0];
  312. let bestScore = SCORE_PROJECT(tip.content.status);
  313. for (const a of arr) {
  314. const s = SCORE_PROJECT(a.content.status);
  315. if (s > bestScore || (s === bestScore && a.ts > tip.ts)) { tip = a; bestScore = s }
  316. }
  317. for (const a of arr) idToTipId.set(a.id, tip.id);
  318. const baseTitle = (tip.content && tip.content.title) || '';
  319. const overlays = arr
  320. .filter(a => a.type === 'project' && (a.content.followersOp || a.content.backerPledge))
  321. .sort((a, b) => (a.ts || 0) - (b.ts || 0));
  322. for (const ev of overlays) {
  323. if (tombstoned.has(ev.id)) continue;
  324. let kind = null;
  325. let amount = null;
  326. if (ev.content.followersOp === 'follow') kind = 'follow';
  327. else if (ev.content.followersOp === 'unfollow') kind = 'unfollow';
  328. if (ev.content.backerPledge && typeof ev.content.backerPledge.amount !== 'undefined') {
  329. const amt = Math.max(0, parseFloat(ev.content.backerPledge.amount || 0) || 0);
  330. if (amt > 0) { kind = kind || 'pledge'; amount = amt }
  331. }
  332. if (!kind) continue;
  333. const augmented = {
  334. ...ev,
  335. type: 'project',
  336. content: {
  337. ...ev.content,
  338. title: baseTitle,
  339. projectId: tip.id,
  340. activity: { kind, amount },
  341. activityActor: ev.author
  342. }
  343. };
  344. idToAction.set(ev.id, augmented);
  345. idToTipId.set(ev.id, ev.id);
  346. }
  347. }
  348. const latest = [];
  349. for (const a of idToAction.values()) {
  350. if (tombstoned.has(a.id)) continue;
  351. if (a.type === 'tribe' && parentOf.has(a.id)) continue;
  352. const c = a.content || {};
  353. if (c.root && tombstoned.has(c.root)) continue;
  354. if (a.type === 'vote' && tombstoned.has(c.vote?.link)) continue;
  355. if (a.type === 'spread' && (c.spreadTargetId || c.vote?.link) && tombstoned.has(c.spreadTargetId || c.vote?.link)) continue;
  356. if (c.key && tombstoned.has(c.key)) continue;
  357. if (c.branch && tombstoned.has(c.branch)) continue;
  358. if (c.target && tombstoned.has(c.target)) continue;
  359. if (a.type === 'document') {
  360. const url = c.url;
  361. const ok = await hasBlob(ssbClient, url);
  362. if (!ok) continue;
  363. }
  364. if (a.type === 'forum' && c.root) {
  365. const rootId = typeof c.root === 'string' ? c.root : (c.root?.key || c.root?.id || '');
  366. const rootAction = idToAction.get(rootId);
  367. a.content.rootTitle = rootAction?.content?.title || a.content.rootTitle || '';
  368. a.content.rootKey = rootId || a.content.rootKey || '';
  369. }
  370. const actionRoot = rootOf(a.id);
  371. const extra = a.type === 'aiExchange' ? { helpfulVotes: aiHelpfulCounts.get(a.id) || 0 } : {};
  372. latest.push({ ...a, ...extra, tipId: idToTipId.get(a.id) || a.id, rootId: actionRoot !== a.id ? actionRoot : null });
  373. }
  374. let deduped = latest.filter(a => !a.tipId || a.tipId === a.id || (a.type === 'tribe' && !parentOf.has(a.id)));
  375. const mediaTypes = new Set(['image','video','audio','document','bookmark','map']);
  376. const perAuthorUnique = new Set();
  377. const byKey = new Map();
  378. const norm = s => String(s || '').trim().toLowerCase();
  379. for (const a of deduped) {
  380. const c = a.content || {};
  381. const effTs =
  382. (c.updatedAt && Date.parse(c.updatedAt)) ||
  383. (c.createdAt && Date.parse(c.createdAt)) ||
  384. (a.ts || 0);
  385. if (mediaTypes.has(a.type)) {
  386. const u = c.url || c.title || `${a.type}:${a.id}`;
  387. const key = `${a.type}:${u}`;
  388. const prev = byKey.get(key);
  389. if (!prev || effTs > prev.__effTs) byKey.set(key, { ...a, __effTs: effTs });
  390. } else if (perAuthorUnique.has(a.type)) {
  391. const key = `${a.type}:${a.author}`;
  392. const prev = byKey.get(key);
  393. if (!prev || effTs > prev.__effTs) byKey.set(key, { ...a, __effTs: effTs });
  394. } else if (a.type === 'about') {
  395. const target = c.about || a.author;
  396. const key = `about:${target}`;
  397. const prev = byKey.get(key);
  398. const prevContent = prev && (prev.content || {});
  399. const prevHasImage = !!(prevContent && prevContent.image);
  400. const newHasImage = !!c.image;
  401. if (!prev) {
  402. byKey.set(key, { ...a, __effTs: effTs, __hasImage: newHasImage });
  403. } else if (!prevHasImage && newHasImage) {
  404. byKey.set(key, { ...a, __effTs: effTs, __hasImage: newHasImage });
  405. } else if (prevHasImage === newHasImage && effTs > prev.__effTs) {
  406. byKey.set(key, { ...a, __effTs: effTs, __hasImage: newHasImage });
  407. }
  408. } else if (a.type === 'tribe') {
  409. const t = norm(c.title);
  410. if (t) {
  411. const key = `tribe:${t}::${a.author}`;
  412. const prev = byKey.get(key);
  413. if (!prev || effTs > prev.__effTs) byKey.set(key, { ...a, __effTs: effTs });
  414. } else {
  415. const key = `id:${a.id}`;
  416. byKey.set(key, { ...a, __effTs: effTs });
  417. }
  418. } else {
  419. const key = `id:${a.id}`;
  420. byKey.set(key, { ...a, __effTs: effTs });
  421. }
  422. }
  423. deduped = Array.from(byKey.values()).map(x => { delete x.__effTs; delete x.__hasImage; return x });
  424. const tribeInternalTypes = new Set(['tribe-content', 'tribeParliamentCandidature', 'tribeParliamentTerm', 'tribeParliamentProposal', 'tribeParliamentRule', 'tribeParliamentLaw', 'tribeParliamentRevocation']);
  425. const hiddenTypes = new Set(['padEntry', 'chatMessage', 'calendarDate', 'calendarNote', 'calendarReminderSent', 'taskReminderSent', 'feed-action', 'pubBalance', 'pubAvailability', 'log']);
  426. const isAllowedTribeActivity = (a) => {
  427. if (tribeInternalTypes.has(a.type)) return false;
  428. const c = a.content || {};
  429. if (c.tribeId) return false;
  430. if (a.type === 'tribe') {
  431. const isInitial = !c.replaces;
  432. if (!isInitial) return false;
  433. if (c.isAnonymous === true && !c._decrypted) return false;
  434. const st = String(c.status || '').toUpperCase();
  435. if ((st === 'PRIVATE' || st === 'INVITE-ONLY') && !(Array.isArray(c.members) && c.members.includes(userId))) return false;
  436. }
  437. return true;
  438. };
  439. const isVisible = (a) => {
  440. if (hiddenTypes.has(a.type)) return false;
  441. const c = a.content || {};
  442. if (c.encryptedPayload) return false;
  443. if (a.type === 'pad' && c.status !== 'OPEN') return false;
  444. if (a.type === 'chat' && c.status !== 'OPEN') return false;
  445. if (a.type === 'calendar' && c.status !== 'OPEN') return false;
  446. if (a.type === 'event' && String(c.isPublic || '').toLowerCase() === 'private' && c.organizer !== userId && !(Array.isArray(c.attendees) && c.attendees.includes(userId))) return false;
  447. if (a.type === 'task' && String(c.isPublic || '').toUpperCase() === 'PRIVATE' && a.author !== userId && !(Array.isArray(c.assignees) && c.assignees.includes(userId))) return false;
  448. if (a.type === 'forum' && c.isPrivate === true && a.author !== userId) return false;
  449. if (a.type === 'job' && String(c.visibility || '').toUpperCase() === 'HIDDEN' && a.author !== userId && !(Array.isArray(c.subscribers) && c.subscribers.includes(userId))) return false;
  450. if (a.type === 'market' && String(c.visibility || '').toUpperCase() === 'HIDDEN' && c.seller !== userId) return false;
  451. if (a.type === 'shop' && String(c.visibility || '').toUpperCase() === 'CLOSED' && a.author !== userId) return false;
  452. if (a.type === 'curriculum' && String(c.visibility || '').toUpperCase() === 'HIDDEN' && a.author !== userId) return false;
  453. if (a.type === 'shopProduct' && c.shopVisibility && String(c.shopVisibility).toUpperCase() === 'CLOSED' && a.author !== userId) return false;
  454. return true;
  455. };
  456. let out;
  457. if (filter === 'mine') out = deduped.filter(a => a.author === userId && isAllowedTribeActivity(a) && isVisible(a));
  458. else if (filter === 'recent') { const cutoff = Date.now() - 24 * 60 * 60 * 1000; out = deduped.filter(a => (a.ts || 0) >= cutoff && isAllowedTribeActivity(a) && isVisible(a)) }
  459. else if (filter === 'all') out = deduped.filter(a => isAllowedTribeActivity(a) && isVisible(a));
  460. else if (filter === 'banking') out = deduped.filter(a => a.type === 'bankWallet' || a.type === 'bankClaim' || a.type === 'ubiClaim');
  461. else if (filter === 'tribe') out = deduped.filter(a => a.type === 'tribe' || String(a.type || '').startsWith('tribe'));
  462. else if (filter === 'spread') out = deduped.filter(a => a.type === 'spread');
  463. else if (filter === 'parliament')
  464. out = deduped.filter(a =>
  465. ['parliamentCandidature','parliamentTerm','parliamentProposal','parliamentRevocation','parliamentLaw'].includes(a.type)
  466. );
  467. else if (filter === 'courts')
  468. out = deduped.filter(a => {
  469. const t = String(a.type || '').toLowerCase();
  470. return t === 'courtscase' || t === 'courtsnomination' || t === 'courtsnominationvote';
  471. });
  472. else if (filter === 'task')
  473. out = deduped.filter(a => a.type === 'task' || a.type === 'taskAssignment');
  474. else if (filter === 'gameScore') out = deduped.filter(a => a.type === 'gameScore');
  475. else if (filter === 'pad') out = deduped.filter(a => a.type === 'pad' && (a.content || {}).status === 'OPEN');
  476. else if (filter === 'chat') out = deduped.filter(a => a.type === 'chat' && (a.content || {}).status === 'OPEN');
  477. else if (filter === 'calendar') out = deduped.filter(a => a.type === 'calendar' && (a.content || {}).status === 'OPEN');
  478. else out = deduped.filter(a => a.type === filter);
  479. out.sort((a, b) => (b.ts || 0) - (a.ts || 0));
  480. return out;
  481. })();
  482. _feedCacheInflight.set(cacheKey, promise);
  483. try {
  484. const value = await promise;
  485. _feedCache.set(cacheKey, { value, ts: Date.now() });
  486. return value;
  487. } finally {
  488. _feedCacheInflight.delete(cacheKey);
  489. }
  490. }
  491. };
  492. };