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

@ -1,6 +1,8 @@
const pull = require('../server/node_modules/pull-stream');
const { getConfig } = require('../configs/config-manager.js');
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const { readTyped, memoIndex } = require('./typed_log');
const { versionGroups } = require('../backend/dedupe');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
@ -37,13 +39,9 @@ module.exports = ({ cooler, isPublic = false, tribeCrypto = null, chatsModel = n
let ssb;
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
const getAllMessages = async (ssbClient) =>
new Promise((resolve, reject) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs)))
);
});
const POLLS_TYPES = [POLL_TYPE, VOTE_TYPE, OPINION_TYPE, 'pollClose', 'post', 'tombstone'];
const getAllMessages = async (ssbClient) => readTyped(ssbClient, POLLS_TYPES, { limit: logLimit });
const decryptField = (value, keys) => {
if (!tribeCrypto || typeof value !== 'string' || !value) return null;
@ -65,82 +63,61 @@ module.exports = ({ cooler, isPublic = false, tribeCrypto = null, chatsModel = n
const buildIndex = (messages) => {
const tomb = buildValidatedTombstoneSet(messages);
const nodes = new Map();
const replaced = new Map();
const votesByRoot = new Map();
const opinionsByRoot = new Map();
const closedRoots = new Set();
const commentsByRoot = new Map();
const authorByKey = new Map();
const others = [];
for (const m of messages) {
const v = m && m.value;
const c = v && v.content;
if (!c || typeof c !== 'object') continue;
authorByKey.set(m.key, v.author);
if (c.type === POLL_TYPE) nodes.set(m.key, { key: m.key, author: v.author, seq: v.sequence || 0, ts: v.timestamp || m.timestamp || 0, c });
else others.push(m);
}
if (c.type === POLL_TYPE) {
nodes.set(m.key, { key: m.key, author: v.author, ts: v.timestamp || m.timestamp || 0, c });
if (typeof c.replaces === 'string') replaced.set(c.replaces, m.key);
continue;
}
const vg = versionGroups(Array.from(nodes.values()).map(n => ({ key: n.key, author: n.author, seq: n.seq, ts: n.ts, replaces: typeof n.c.replaces === 'string' ? n.c.replaces : null, claimedAuthor: typeof n.c.author === 'string' ? n.c.author : null })));
const rootOf = vg.rootOf;
const tipOf = vg.tipOf;
for (const m of others) {
const v = m.value;
const c = v.content;
if (c.type === VOTE_TYPE && typeof c.target === 'string') {
const entry = votesByRoot.get(c.target) || new Map();
const prev = entry.get(v.author);
const ts = v.timestamp || m.timestamp || 0;
if (!prev || ts >= prev.ts) {
entry.set(v.author, {
ts,
choices: Array.isArray(c.choices) ? c.choices : [],
encryptedChoices: typeof c.encryptedChoices === 'string' ? c.encryptedChoices : null
});
}
votesByRoot.set(c.target, entry);
const target = rootOf(c.target);
const entry = votesByRoot.get(target) || new Map();
const list = entry.get(v.author) || [];
list.push({
ts: v.timestamp || m.timestamp || 0,
choices: Array.isArray(c.choices) ? c.choices : [],
encryptedChoices: typeof c.encryptedChoices === 'string' ? c.encryptedChoices : null
});
entry.set(v.author, list);
votesByRoot.set(target, entry);
continue;
}
if (c.type === OPINION_TYPE && typeof c.target === 'string') {
const entry = opinionsByRoot.get(c.target) || { counts: {}, voters: [] };
const target = rootOf(c.target);
const entry = opinionsByRoot.get(target) || { counts: {}, voters: [] };
if (!entry.voters.includes(v.author)) {
entry.voters.push(v.author);
entry.counts[c.category] = (entry.counts[c.category] || 0) + 1;
}
opinionsByRoot.set(c.target, entry);
opinionsByRoot.set(target, entry);
continue;
}
if (c.type === 'pollClose' && typeof c.target === 'string') {
closedRoots.add(`${v.author}|${c.target}`);
closedRoots.add(`${v.author}|${rootOf(c.target)}`);
continue;
}
if (c.type === 'post' && typeof c.root === 'string') {
commentsByRoot.set(c.root, (commentsByRoot.get(c.root) || 0) + 1);
const target = rootOf(c.root);
commentsByRoot.set(target, (commentsByRoot.get(target) || 0) + 1);
}
}
const rootOf = (key) => {
let cur = key;
const seen = new Set();
while (nodes.has(cur) && nodes.get(cur).c.replaces && !seen.has(cur)) {
seen.add(cur);
const next = nodes.get(cur).c.replaces;
if (!nodes.has(next)) break;
cur = next;
}
return cur;
};
const tipOf = (root) => {
let cur = root;
const seen = new Set();
while (replaced.has(cur) && !seen.has(cur)) {
seen.add(cur);
const next = replaced.get(cur);
const node = nodes.get(next);
if (!node || node.author !== nodes.get(cur).author) break;
cur = next;
}
return cur;
};
return { tomb, nodes, replaced, votesByRoot, opinionsByRoot, closedRoots, commentsByRoot, rootOf, tipOf };
return { tomb, nodes, vg, votesByRoot, opinionsByRoot, closedRoots, commentsByRoot, rootOf, tipOf };
};
const isExpired = (c) => {
@ -165,7 +142,13 @@ module.exports = ({ cooler, isPublic = false, tribeCrypto = null, chatsModel = n
try { rawOptions = o ? JSON.parse(o) : []; } catch (_) { rawOptions = []; }
}
const options = normalizeOptions(rawOptions);
const voteMap = idx.votesByRoot.get(rootId) || new Map();
const opensAt = (idx.nodes.get(rootId) || {}).ts || 0;
const closesAt = c.deadline ? Date.parse(c.deadline) : NaN;
const voteMap = new Map();
for (const [author, list] of (idx.votesByRoot.get(rootId) || new Map()).entries()) {
const inWindow = list.filter(b => b.ts >= opensAt && (!Number.isFinite(closesAt) || b.ts <= closesAt));
if (inWindow.length) voteMap.set(author, inWindow.reduce((a, b) => (b.ts >= a.ts ? b : a)));
}
const counts = {};
for (const o of options) counts[o] = 0;
@ -223,9 +206,7 @@ module.exports = ({ cooler, isPublic = false, tribeCrypto = null, chatsModel = n
const collect = async (viewerId) => {
const ssbClient = await openSsb();
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
const roots = new Set();
for (const key of idx.nodes.keys()) roots.add(idx.rootOf(key));
const idx = memoIndex('polls', messages, buildIndex);
const keyCache = new Map();
const keysFor = async (c) => {
@ -239,7 +220,7 @@ module.exports = ({ cooler, isPublic = false, tribeCrypto = null, chatsModel = n
};
const list = [];
for (const rootId of roots) {
for (const rootId of idx.vg.roots) {
const tipId = idx.tipOf(rootId);
if (idx.tomb.has(tipId) || idx.tomb.has(rootId)) continue;
const node = idx.nodes.get(tipId);
@ -275,6 +256,7 @@ module.exports = ({ cooler, isPublic = false, tribeCrypto = null, chatsModel = n
else if (f === 'OPEN') out = out.filter(p => p.status === 'OPEN');
else if (f === 'CLOSED') out = out.filter(p => p.status === 'CLOSED');
else if (f === 'VOTED') out = out.filter(p => p.hasVoted);
else if (f === 'RECENT') out = out.filter(p => (Date.parse(p.createdAt || '') || 0) >= Date.now() - 86400000);
else if (f === 'FAVORITES') {
const fav = new Set((opts.favorites || []).map(String));
out = out.filter(p => fav.has(String(p.id)));