Oasis 0.8.7 mobile (nodejs-project baseline)
This commit is contained in:
commit
e563b86ede
226 changed files with 141968 additions and 0 deletions
226
src/models/trending_model.js
Normal file
226
src/models/trending_model.js
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
const pull = require('../server/node_modules/pull-stream');
|
||||
const { getConfig } = require('../configs/config-manager.js');
|
||||
const logLimit = getConfig().ssbLogStream?.limit || 1000;
|
||||
const opinionCategories = require('../backend/opinion_categories');
|
||||
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
|
||||
const { buildVoteTally } = require('../backend/vote_tally');
|
||||
|
||||
module.exports = ({ cooler }) => {
|
||||
let ssb;
|
||||
const openSsb = async () => {
|
||||
if (!ssb) ssb = await cooler.open();
|
||||
return ssb;
|
||||
};
|
||||
|
||||
const hasBlob = async (ssbClient, url) => {
|
||||
return new Promise(resolve => {
|
||||
ssbClient.blobs.has(url, (err, has) => resolve(!err && has));
|
||||
});
|
||||
};
|
||||
|
||||
const types = [
|
||||
'bookmark', 'votes', 'feed',
|
||||
'image', 'audio', 'video', 'document', 'torrent', 'transfer'
|
||||
];
|
||||
|
||||
const categories = opinionCategories;
|
||||
|
||||
const listTrending = async (filter = 'ALL') => {
|
||||
const ssbClient = await openSsb();
|
||||
const userId = ssbClient.id;
|
||||
|
||||
const messages = await new Promise((res, rej) => {
|
||||
pull(
|
||||
ssbClient.createLogStream({ limit: logLimit }),
|
||||
pull.collect((err, xs) => err ? rej(err) : res(xs))
|
||||
);
|
||||
});
|
||||
|
||||
const tombstoned = buildValidatedTombstoneSet(messages);
|
||||
const replaces = new Map();
|
||||
const itemsById = new Map();
|
||||
|
||||
for (const m of messages) {
|
||||
const k = m.key;
|
||||
const c = m.value?.content;
|
||||
if (!c) continue;
|
||||
|
||||
if (c.type === 'tombstone' && c.target) {
|
||||
tombstoned.add(c.target);
|
||||
itemsById.delete(c.target);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c.opinions && !tombstoned.has(k) && !['task', 'event', 'report'].includes(c.type)) {
|
||||
if (c.replaces) replaces.set(c.replaces, k);
|
||||
itemsById.set(k, m);
|
||||
}
|
||||
|
||||
if (c.type === 'feed' && !tombstoned.has(k) && !itemsById.has(k)) {
|
||||
if (c.replaces) replaces.set(c.replaces, k);
|
||||
itemsById.set(k, m);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [replacedId, newId] of replaces.entries()) {
|
||||
const oldMsg = itemsById.get(replacedId);
|
||||
const newMsg = itemsById.get(newId);
|
||||
if (!oldMsg) continue;
|
||||
if (!newMsg || String(newMsg.value?.author) !== String(oldMsg.value?.author)) {
|
||||
itemsById.delete(newId);
|
||||
continue;
|
||||
}
|
||||
itemsById.delete(replacedId);
|
||||
}
|
||||
|
||||
let rawItems = Array.from(itemsById.values()).filter(m => types.includes(m.value?.content?.type));
|
||||
const blobTypes = ['document', 'image', 'audio', 'video'];
|
||||
|
||||
let items = await Promise.all(
|
||||
rawItems.map(async m => {
|
||||
const c = m.value?.content;
|
||||
if (blobTypes.includes(c.type) && c.url) {
|
||||
const valid = await hasBlob(ssbClient, c.url);
|
||||
if (!valid) return null;
|
||||
}
|
||||
return m;
|
||||
})
|
||||
);
|
||||
items = items.filter(Boolean);
|
||||
|
||||
const signatureOf = (m) => {
|
||||
const c = m.value?.content || {};
|
||||
switch (c.type) {
|
||||
case 'document':
|
||||
case 'image':
|
||||
case 'audio':
|
||||
case 'video':
|
||||
return `${c.type}::${(c.url || '').trim()}`;
|
||||
case 'bookmark':
|
||||
return `bookmark::${(c.url || '').trim().toLowerCase()}`;
|
||||
case 'feed':
|
||||
return `feed::${(c.text || '').replace(/\s+/g, ' ').trim()}`;
|
||||
case 'votes':
|
||||
return `votes::${(c.question || '').replace(/\s+/g, ' ').trim()}`;
|
||||
case 'transfer':
|
||||
return `transfer::${(c.concept || '')}|${c.amount || ''}|${c.from || ''}|${c.to || ''}|${c.deadline || ''}`;
|
||||
default:
|
||||
return `key::${m.key}`;
|
||||
}
|
||||
};
|
||||
|
||||
const voteTally = buildVoteTally(messages);
|
||||
const votesPrev = new Map();
|
||||
for (const m of messages) {
|
||||
const c = m.value?.content;
|
||||
if (c && c.type === 'votes' && typeof c.replaces === 'string') votesPrev.set(m.key, c.replaces);
|
||||
}
|
||||
const resolveTallyKey = (k) => {
|
||||
let cur = k;
|
||||
let g = 0;
|
||||
while (g++ < 1000 && !voteTally.has(cur) && votesPrev.has(cur)) cur = votesPrev.get(cur);
|
||||
return cur;
|
||||
};
|
||||
items = items.map(m => {
|
||||
if (m.value?.content?.type !== 'votes') return m;
|
||||
const t = voteTally.get(resolveTallyKey(m.key));
|
||||
return t ? { ...m, value: { ...m.value, content: { ...m.value.content, ...t } } } : m;
|
||||
});
|
||||
|
||||
const bySig = new Map();
|
||||
for (const m of items) {
|
||||
const sig = signatureOf(m);
|
||||
const prev = bySig.get(sig);
|
||||
if (!prev || (m.value?.timestamp || 0) > (prev.value?.timestamp || 0)) {
|
||||
bySig.set(sig, m);
|
||||
}
|
||||
}
|
||||
items = Array.from(bySig.values());
|
||||
|
||||
if (filter === 'MINE') {
|
||||
items = items.filter(m => m.value.author === userId);
|
||||
} else if (filter === 'RECENT') {
|
||||
const now = Date.now();
|
||||
items = items.filter(m => now - m.value.timestamp < 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
if (types.includes(filter)) {
|
||||
items = items.filter(m => m.value.content.type === filter);
|
||||
}
|
||||
|
||||
if (filter !== 'ALL' && !types.includes(filter)) {
|
||||
items = items.filter(m => (m.value.content.opinions_inhabitants || []).length > 0);
|
||||
}
|
||||
|
||||
if (filter === 'TOP') {
|
||||
items.sort((a, b) => {
|
||||
const aLen = (a.value.content.opinions_inhabitants || []).length;
|
||||
const bLen = (b.value.content.opinions_inhabitants || []).length;
|
||||
if (bLen !== aLen) return bLen - aLen;
|
||||
return b.value.timestamp - a.value.timestamp;
|
||||
});
|
||||
} else {
|
||||
items.sort((a, b) => {
|
||||
const aLen = (a.value.content.opinions_inhabitants || []).length;
|
||||
const bLen = (b.value.content.opinions_inhabitants || []).length;
|
||||
return bLen - aLen;
|
||||
});
|
||||
}
|
||||
|
||||
return { filtered: items };
|
||||
};
|
||||
|
||||
const getMessageById = async (id) => {
|
||||
const ssbClient = await openSsb();
|
||||
return new Promise((res, rej) => {
|
||||
ssbClient.get(id, (err, msg) => err ? rej(err) : res(msg));
|
||||
});
|
||||
};
|
||||
|
||||
const createVote = async (contentId, category) => {
|
||||
const ssbClient = await openSsb();
|
||||
const userId = ssbClient.id;
|
||||
|
||||
if (!categories.includes(category)) throw new Error('Invalid voting category');
|
||||
|
||||
const msg = await getMessageById(contentId);
|
||||
if (!msg || !msg.content) throw new Error('Content not found');
|
||||
|
||||
const type = msg.content.type;
|
||||
if (!types.includes(type) || ['task', 'event', 'report'].includes(type)) {
|
||||
throw new Error('Voting not allowed on this content type');
|
||||
}
|
||||
|
||||
const inhabitants = Array.isArray(msg.content.opinions_inhabitants) ? msg.content.opinions_inhabitants : [];
|
||||
if (inhabitants.includes(userId)) throw new Error('Already voted');
|
||||
|
||||
const tombstone = {
|
||||
type: 'tombstone',
|
||||
target: contentId,
|
||||
deletedAt: new Date().toISOString(),
|
||||
author: userId
|
||||
};
|
||||
|
||||
const updated = {
|
||||
...msg.content,
|
||||
opinions: {
|
||||
...(msg.content.opinions || {}),
|
||||
[category]: ((msg.content.opinions && msg.content.opinions[category]) || 0) + 1
|
||||
},
|
||||
opinions_inhabitants: inhabitants.concat(userId),
|
||||
updatedAt: new Date().toISOString(),
|
||||
replaces: contentId
|
||||
};
|
||||
|
||||
await new Promise((res, rej) => {
|
||||
ssbClient.publish(tombstone, err => err ? rej(err) : res());
|
||||
});
|
||||
|
||||
return new Promise((res, rej) => {
|
||||
ssbClient.publish(updated, (err, result) => err ? rej(err) : res(result));
|
||||
});
|
||||
};
|
||||
|
||||
return { listTrending, getMessageById, createVote, types, categories };
|
||||
};
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue