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

@ -2,7 +2,9 @@ const pull = require('../server/node_modules/pull-stream');
const { getConfig } = require('../configs/config-manager.js');
const categories = 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');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
module.exports = ({ cooler }) => {
@ -22,8 +24,9 @@ module.exports = ({ cooler }) => {
const validTypes = [
'bookmark', 'votes', 'transfer',
'feed', 'image', 'audio', 'video', 'document', 'torrent',
'industry', 'project', 'report', 'task', 'event', 'shopProduct', 'housing', 'market'
'feed', 'image', 'audio', 'video', 'document', 'torrent', 'file',
'industry', 'project', 'report', 'task', 'event', 'shopProduct', 'housing', 'market', 'schoolCourse',
'podcast', 'podcastEpisode', 'campaign', 'logisticsRoute'
];
const getPreview = c => {
@ -31,25 +34,7 @@ module.exports = ({ cooler }) => {
return c.text || c.description || c.title || c.name || '';
};
const OPINION_MSG_TYPE = {
bookmark: 'bookmarkOpinion',
votes: 'votesOpinion',
transfer: 'transferOpinion',
feed: 'feedOpinion',
image: 'imageOpinion',
audio: 'audioOpinion',
video: 'videoOpinion',
document: 'documentOpinion',
torrent: 'torrentOpinion',
industry: 'industryOpinion',
project: 'projectOpinion',
report: 'reportOpinion',
task: 'taskOpinion',
event: 'eventOpinion',
shopProduct: 'shopOpinion',
housing: 'housingOpinion',
market: 'marketOpinion'
};
const OPINION_MSG_TYPE = categories.messageTypes;
const createVote = async (contentId, category) => {
const ssbClient = await openSsb();
@ -69,36 +54,23 @@ module.exports = ({ cooler }) => {
);
};
const listOpinions = async (filter = 'ALL', category = '') => {
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, msgs) => err ? rej(err) : res(msgs))
);
});
const messages = await readTyped(ssbClient, CONTENT_TYPES, { limit: logLimit, withWindow: true });
const tombstoned = buildValidatedTombstoneSet(messages);
const replaces = new Map();
const byId = new Map();
const authorOf = new Map();
const opinionMsgs = [];
for (const msg of messages) {
const key = msg.key;
const c = msg.value?.content;
if (!c) continue;
if (c.type === 'tombstone') {
if (tombstoned.has(c.target)) byId.delete(c.target);
continue;
}
if (key) authorOf.set(key, msg.value.author);
if (c.type === 'tombstone') continue;
if (typeof c.type === 'string' && c.type.endsWith('Opinion') && c.target) {
opinionMsgs.push({ target: c.target, author: msg.value.author, category: c.category });
continue;
}
if (c.opinions && !tombstoned.has(key) && !['task', 'event', 'report'].includes(c.type)) {
if (c.replaces) replaces.set(c.replaces, key);
if (c.opinions && !['task', 'event', 'report'].includes(c.type)) {
byId.set(key, {
key,
value: {
@ -108,38 +80,19 @@ module.exports = ({ cooler }) => {
}
});
}
if (c.type === 'feed' && !tombstoned.has(key) && !byId.has(key)) {
if (c.replaces) replaces.set(c.replaces, key);
if (c.type === 'feed' && !byId.has(key)) {
byId.set(key, { key, value: { ...msg.value, content: c, preview: getPreview(c) } });
}
}
for (const [oldId, newId] of Array.from(replaces.entries())) {
const oldAuthor = authorOf.get(oldId);
const newM = byId.get(newId);
if (oldAuthor === undefined) { replaces.delete(oldId); continue; }
if (!newM || String(newM.value.author) !== String(oldAuthor)) {
replaces.delete(oldId);
byId.delete(newId);
}
const vg = versionGroups(Array.from(byId.values()).map(versionEntry));
for (const key of Array.from(byId.keys())) {
if (!vg.roots.has(vg.rootOf(key)) || vg.tipOf(key) !== key || tombstoned.has(key)) byId.delete(key);
}
for (const replacedId of replaces.keys()) {
byId.delete(replacedId);
}
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;
@ -158,6 +111,12 @@ module.exports = ({ cooler }) => {
}
let filtered = Array.from(byId.values()).filter(m => validTypes.includes(m.value?.content?.type));
filtered = filtered.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'];
const blobCheckCache = new Map();
@ -237,10 +196,24 @@ module.exports = ({ cooler }) => {
}
filtered = Array.from(bySig.values());
filtered = filtered.filter(m => {
return filtered.filter(m => {
const ops = m.value?.content?.opinions || {};
return Object.values(ops).reduce((acc, x) => acc + (Number(x) || 0), 0) > 0;
});
};
const baseItems = () => {
const store = requestScope.getStore();
if (store && store.opinionsBase) return store.opinionsBase;
const pending = computeBase();
if (store) store.opinionsBase = pending;
return pending;
};
const listOpinions = async (filter = 'ALL', category = '') => {
const ssbClient = await openSsb();
const userId = ssbClient.id;
let filtered = (await baseItems()).slice();
if (filter === 'MINE') {
filtered = filtered.filter(m => m.value.author === userId);