Oasis 0.8.7 mobile (nodejs-project baseline)
This commit is contained in:
commit
e563b86ede
226 changed files with 141968 additions and 0 deletions
686
src/models/activity_model.js
Normal file
686
src/models/activity_model.js
Normal file
|
|
@ -0,0 +1,686 @@
|
|||
const pull = require('../server/node_modules/pull-stream');
|
||||
const ssbRef = require('../server/node_modules/ssb-ref');
|
||||
const { getConfig } = require('../configs/config-manager.js');
|
||||
const { buildVoteTally } = require('../backend/vote_tally');
|
||||
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
|
||||
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 === '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-keys',
|
||||
'tribe-invite-msg',
|
||||
'tribe-invite-tombstone',
|
||||
'aiExchangeVote',
|
||||
'event-invite',
|
||||
'forum-invite',
|
||||
'larpJoinHouse',
|
||||
'larpLeaveLarp',
|
||||
'larpTestAttempt',
|
||||
'larpHousePost',
|
||||
'larpHouseInvite',
|
||||
'larpHouseInviteRedeem',
|
||||
'larpHouseTribeAnchor',
|
||||
'larpHouseTribeAnchorTombstone',
|
||||
'larpAutoInvite',
|
||||
'karmaScore',
|
||||
'pubAvailability',
|
||||
'calendarReminderSent',
|
||||
'taskReminderSent',
|
||||
'ubiClaimResult',
|
||||
'ubiAllocation',
|
||||
'courts-key'
|
||||
]);
|
||||
|
||||
module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel }) => {
|
||||
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 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 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(/<br\s*\/?>/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))
|
||||
);
|
||||
});
|
||||
|
||||
if (typeof ssbClient.createUserStream === 'function') {
|
||||
const ownResults = await new Promise((resolve) => {
|
||||
pull(
|
||||
ssbClient.createUserStream({ id: userId, reverse: true }),
|
||||
pull.collect((err, msgs) => resolve(err ? [] : msgs))
|
||||
);
|
||||
});
|
||||
const seenKeys = new Set(results.map(m => m && m.key));
|
||||
for (const m of ownResults) {
|
||||
if (m && m.key && !seenKeys.has(m.key)) results.push(m);
|
||||
}
|
||||
}
|
||||
|
||||
const tombstoned = buildValidatedTombstoneSet(results);
|
||||
const parentOf = new Map();
|
||||
const idToAction = new Map();
|
||||
const rawById = new Map();
|
||||
const voteTally = buildVoteTally(results);
|
||||
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 (typeof c.type === 'string' && /e2ee/i.test(c.type)) continue;
|
||||
if (c.type === 'contact') 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') continue;
|
||||
if (c.encryptedPayload && tribeCrypto && !c.tribeId) {
|
||||
const pubKey = tryDecryptPublicInviteKey(c.invites);
|
||||
if (pubKey) {
|
||||
try {
|
||||
const dec = tribeCrypto.decryptContent(c, [[pubKey]]);
|
||||
if (dec && !dec._undecryptable) c = dec;
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
if (c.type === 'pad' && c.encrypted === true && !c.tribeId && padsModel && typeof padsModel.decryptContentPublicSync === 'function') {
|
||||
try {
|
||||
const dec = padsModel.decryptContentPublicSync(c);
|
||||
if (dec) c = { ...c, title: dec.title, deadline: dec.deadline, tags: dec.tags, encrypted: false };
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!isContentSane(c)) 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 forgedParentOf = new Map();
|
||||
const forgedFeedContent = new Map();
|
||||
for (const [child, parent] of Array.from(parentOf.entries())) {
|
||||
const ca = idToAction.get(child);
|
||||
const pa = idToAction.get(parent);
|
||||
if (!pa) { parentOf.delete(child); continue; }
|
||||
if (!ca || String(ca.author) !== String(pa.author)) {
|
||||
forgedParentOf.set(child, parent);
|
||||
if (ca && ca.type === 'feed') forgedFeedContent.set(child, ca.content || {});
|
||||
parentOf.delete(child);
|
||||
idToAction.delete(child);
|
||||
rawById.delete(child);
|
||||
}
|
||||
}
|
||||
|
||||
const replacedIds = new Set(parentOf.values());
|
||||
const spreadVoteState = new Map();
|
||||
const aiHelpfulCounts = new Map();
|
||||
const aiHelpfulSeen = new Map();
|
||||
for (const msg of results) {
|
||||
const v = msg.value;
|
||||
const c = v && v.content;
|
||||
if (!c || c.type !== 'aiExchangeVote') continue;
|
||||
const target = String(c.target || '');
|
||||
const author = v.author;
|
||||
if (!target || !author) continue;
|
||||
const key = `${target} | ||||