upstream: integrar Oasis 1.3.1

Base del merge a tres bandas: git 0.9.5 de epsylon, esta rama y el src de la
APK oficial 1.3.1 (la variante movil: OASIS_MOBILE, regex sin \p{}, audio y
video de Android). Corre en el mismo nodejs-mobile 18.20.4 que ya usabamos.

Se conserva de la rama: Karvan, identidades y multicuenta, topbar y barra
inferior, borrado de EXIF sin sharp, filtro hot del foro, RTC sin STUN y los
modulos apagados en movil (wallet, banking, torrents).

Se toma de upstream lo que ya hace de serie: respuesta citada en chats, ssb-lan
en movil, configuracion en ~/.ssb/oasis (sobrevive a reinstalar el backend).
El asistente de bienvenida deja elegir workflow tambien en movil.
This commit is contained in:
oasis-bot 2026-10-18 02:52:17 +02:00
parent 8d838dd8b1
commit c332afd6ea
265 changed files with 80970 additions and 29167 deletions

View file

@ -3,7 +3,9 @@ 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 { readTyped, CONTENT_TYPES, requestScope } = require('./typed_log');
const { buildVoteTally } = require('../backend/vote_tally');
const { versionGroups, versionEntry } = require('../backend/dedupe');
module.exports = ({ cooler }) => {
let ssb;
@ -20,27 +22,19 @@ module.exports = ({ cooler }) => {
const types = [
'bookmark', 'votes', 'poll', 'feed',
'image', 'audio', 'video', 'document', 'torrent', 'transfer',
'industry', 'project', 'report', 'task', 'event', 'shopProduct', 'housing', 'market'
'image', 'audio', 'video', 'document', 'torrent', 'file', 'transfer',
'industry', 'project', 'report', 'task', 'event', 'shopProduct', 'housing', 'market', 'schoolCourse',
'podcast', 'podcastEpisode', 'campaign', 'logisticsRoute'
];
const categories = opinionCategories;
const listTrending = async (filter = 'ALL') => {
const computeBase = async () => {
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 messages = await readTyped(ssbClient, CONTENT_TYPES, { limit: logLimit, withWindow: true });
const tombstoned = buildValidatedTombstoneSet(messages);
const replaces = new Map();
const itemsById = new Map();
const authorOf = new Map();
const opinionMsgs = [];
for (const m of messages) {
@ -48,54 +42,26 @@ module.exports = ({ cooler }) => {
const c = m.value?.content;
if (!c) continue;
if (c.type === 'tombstone' && c.target) {
tombstoned.add(c.target);
itemsById.delete(c.target);
continue;
}
if (k) authorOf.set(k, m.value?.author);
if (c.type === 'tombstone') continue;
if (typeof c.type === 'string' && c.type.endsWith('Opinion') && c.target) {
opinionMsgs.push({ target: c.target, author: m.value?.author, category: c.category });
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.opinions && !['task', 'event', 'report'].includes(c.type)) 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);
}
if (c.type === 'feed' && !itemsById.has(k)) itemsById.set(k, m);
}
for (const [replacedId, newId] of Array.from(replaces.entries())) {
const oldAuthor = authorOf.get(replacedId);
const newMsg = itemsById.get(newId);
if (oldAuthor === undefined) { replaces.delete(replacedId); continue; }
if (!newMsg || String(newMsg.value?.author) !== String(oldAuthor)) {
replaces.delete(replacedId);
itemsById.delete(newId);
continue;
}
itemsById.delete(replacedId);
const vg = versionGroups(Array.from(itemsById.values()).map(versionEntry));
for (const k of Array.from(itemsById.keys())) {
if (!vg.roots.has(vg.rootOf(k)) || vg.tipOf(k) !== k || tombstoned.has(k)) itemsById.delete(k);
}
const tipToChain = new Map();
for (const [oldId, newId] of replaces.entries()) {
let tip = newId; let g = 0;
while (replaces.has(tip) && g++ < 100000) tip = replaces.get(tip);
if (!tipToChain.has(tip)) tipToChain.set(tip, new Set([tip]));
tipToChain.get(tip).add(oldId); tipToChain.get(tip).add(newId);
}
const idToTip = new Map();
for (const [tip, chain] of tipToChain.entries()) for (const id of chain) idToTip.set(id, tip);
const opinionByTip = new Map();
for (const op of opinionMsgs) {
const tip = idToTip.get(op.target) || op.target;
const tip = vg.tipOf(op.target);
if (!opinionByTip.has(tip)) opinionByTip.set(tip, { opinions: {}, voters: new Set() });
const agg = opinionByTip.get(tip);
if (agg.voters.has(op.author)) continue;
@ -114,6 +80,12 @@ module.exports = ({ cooler }) => {
}
let rawItems = Array.from(itemsById.values()).filter(m => types.includes(m.value?.content?.type));
rawItems = rawItems.filter(m => {
const c = m.value?.content;
if (c?.type !== 'schoolCourse') return true;
if (c.encryptedPayload) return false;
return String(c.visibility || 'PUBLIC').toUpperCase() !== 'INVITE';
});
const blobTypes = ['document', 'image', 'audio', 'video'];
let items = await Promise.all(
@ -176,7 +148,21 @@ module.exports = ({ cooler }) => {
bySig.set(sig, m);
}
}
items = Array.from(bySig.values());
return Array.from(bySig.values());
};
const baseItems = () => {
const store = requestScope.getStore();
if (store && store.trendingBase) return store.trendingBase;
const pending = computeBase();
if (store) store.trendingBase = pending;
return pending;
};
const listTrending = async (filter = 'ALL') => {
const ssbClient = await openSsb();
const userId = ssbClient.id;
let items = (await baseItems()).slice();
if (filter === 'MINE') {
items = items.filter(m => m.value.author === userId);
@ -232,31 +218,12 @@ module.exports = ({ cooler }) => {
const inhabitants = Array.isArray(msg.content.opinions_inhabitants) ? msg.content.opinions_inhabitants : [];
if (inhabitants.includes(userId)) throw new Error('Already voted');
const otype = categories.messageTypes[type];
if (!otype) throw new Error('Voting not allowed on this content type');
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());
});
const content = { type: otype, target: contentId, category, createdAt: new Date().toISOString() };
return new Promise((res, rej) => {
ssbClient.publish(updated, (err, result) => err ? rej(err) : res(result));
ssbClient.publish(content, (err, result) => err ? rej(err) : res(result));
});
};