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,4 +1,5 @@
const pull = require('../server/node_modules/pull-stream');
const { readTyped, requestScope } = require('./typed_log');
const fs = require('fs');
const path = require('path');
@ -10,6 +11,7 @@ const HOUSE_KEYS = ['academia','solaris','arrakis','terraverde','unsystem','dogm
const VALID_KEY = (k) => HOUSE_KEYS.includes(String(k || '').toLowerCase());
const TEST_COOLDOWN_MS = 30 * 24 * 60 * 60 * 1000;
const ACADEMIA_GRACE_MS = 10 * 60 * 1000;
const PROFILE_QUESTIONS = [
{
@ -173,6 +175,12 @@ function getGoverningPeriodId(now = new Date()) {
module.exports = ({ cooler, tribesModel, tribeCrypto }) => {
let ssb;
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
const startedAt = Date.now();
let academiaRetry = null;
const followsSomeone = async (client, me) => {
const contacts = await readTyped(client, ['contact']).catch(() => []);
return contacts.some((m) => m && m.value && m.value.author === me && m.value.content && m.value.content.following === true);
};
const houseTribeTag = (houseKey) => {
const h = HOUSES[houseKey];
@ -192,7 +200,7 @@ module.exports = ({ cooler, tribesModel, tribeCrypto }) => {
return tags.includes(tag) && members.includes(me);
});
if (!candidates.length) return null;
candidates.sort((a, b) => new Date(a.createdAt || 0).getTime() - new Date(b.createdAt || 0).getTime());
candidates.sort((a, b) => (new Date(a.createdAt || 0).getTime() - new Date(b.createdAt || 0).getTime()) || String(a.id || '').localeCompare(String(b.id || '')));
return candidates[0];
}
@ -206,78 +214,59 @@ module.exports = ({ cooler, tribesModel, tribeCrypto }) => {
return tags.includes(tag);
});
if (!candidates.length) return null;
candidates.sort((a, b) => new Date(a.createdAt || 0).getTime() - new Date(b.createdAt || 0).getTime());
candidates.sort((a, b) => (new Date(a.createdAt || 0).getTime() - new Date(b.createdAt || 0).getTime()) || String(a.id || '').localeCompare(String(b.id || '')));
return candidates[0];
}
const ANCHOR_TYPES = ['larpHouseTribeAnchor', 'larpHouseTribeAnchorTombstone', 'tombstone'];
const authorOfMsg = (client, key) => new Promise((resolve) => client.get(key, (err, v) => resolve(err || !v ? null : v.author || null)));
async function findEarliestHouseAnchor(houseKey) {
if (!VALID_KEY(houseKey)) return null;
const client = await openSsb();
return new Promise((resolve) => {
const anchors = [];
const anchorTombstones = [];
const tribeTombstones = new Map();
const msgAuthorByKey = new Map();
pull(
client.createLogStream(),
pull.drain((m) => {
if (!m || !m.value) return;
const author = m.value.author;
if (m.key && author) msgAuthorByKey.set(m.key, author);
const c = m.value.content;
if (!c || typeof c !== 'object') return;
if (c.type === 'larpHouseTribeAnchor') {
if (c.house !== houseKey) return;
if (typeof c.tribeRootId !== 'string') return;
const tribeTs = Number(Date.parse(c.tribeCreatedAt || '')) || m.value.timestamp || 0;
anchors.push({ tribeRootId: c.tribeRootId, anchorAuthor: author, tribeTs });
} else if (c.type === 'larpHouseTribeAnchorTombstone') {
if (c.house !== houseKey) return;
if (typeof c.tribeRootId !== 'string') return;
anchorTombstones.push({ tribeRootId: c.tribeRootId, tombstoneAuthor: author });
} else if (c.type === 'tombstone' && typeof c.target === 'string') {
tribeTombstones.set(c.target, author);
}
}, () => {
const killedAnchorPairs = new Set();
for (const t of anchorTombstones) {
killedAnchorPairs.add(`${t.tribeRootId}|${t.tombstoneAuthor}`);
}
const deadTribes = new Set();
for (const [target, tombAuthor] of tribeTombstones) {
const tribeAuthor = msgAuthorByKey.get(target);
if (tribeAuthor && tribeAuthor === tombAuthor) deadTribes.add(target);
}
const live = anchors.filter(a =>
!killedAnchorPairs.has(`${a.tribeRootId}|${a.anchorAuthor}`) &&
!deadTribes.has(a.tribeRootId)
);
if (!live.length) return resolve(null);
live.sort((a, b) => a.tribeTs - b.tribeTs);
const first = live[0];
resolve({ tribeRootId: first.tribeRootId, author: first.anchorAuthor, tribeTs: first.tribeTs });
})
);
});
const msgs = await readTyped(client, ANCHOR_TYPES);
const anchors = [];
const killedAnchorPairs = new Set();
const tombAuthors = new Map();
for (const m of msgs) {
const c = m && m.value && m.value.content;
if (!c || typeof c !== 'object') continue;
const author = m.value.author;
if (c.type === 'larpHouseTribeAnchor') {
if (c.house !== houseKey || typeof c.tribeRootId !== 'string') continue;
const tribeTs = Number(Date.parse(c.tribeCreatedAt || '')) || m.value.timestamp || 0;
anchors.push({ tribeRootId: c.tribeRootId, anchorAuthor: author, tribeTs });
} else if (c.type === 'larpHouseTribeAnchorTombstone') {
if (c.house !== houseKey || typeof c.tribeRootId !== 'string') continue;
killedAnchorPairs.add(`${c.tribeRootId}|${author}`);
} else if (c.type === 'tombstone' && typeof c.target === 'string') {
if (!tombAuthors.has(c.target)) tombAuthors.set(c.target, new Set());
tombAuthors.get(c.target).add(author);
}
}
const candidates = anchors.filter(a => !killedAnchorPairs.has(`${a.tribeRootId}|${a.anchorAuthor}`));
const deadTribes = new Set();
await Promise.all([...new Set(candidates.map(a => a.tribeRootId).filter(t => tombAuthors.has(t)))].map(async (t) => {
const tribeAuthor = await authorOfMsg(client, t);
if (tribeAuthor && tombAuthors.get(t).has(tribeAuthor)) deadTribes.add(t);
}));
const live = candidates.filter(a => !deadTribes.has(a.tribeRootId));
if (!live.length) return null;
live.sort((a, b) => a.tribeTs - b.tribeTs);
const first = live[0];
return { tribeRootId: first.tribeRootId, author: first.anchorAuthor, tribeTs: first.tribeTs };
}
async function findHouseAnchorByTribe(houseKey, tribeRootId) {
if (!VALID_KEY(houseKey) || !tribeRootId) return null;
const client = await openSsb();
return new Promise((resolve) => {
let hit = null;
pull(
client.createLogStream(),
pull.drain((m) => {
if (hit) return;
const c = m && m.value && m.value.content;
if (!c || c.type !== 'larpHouseTribeAnchor') return;
if (c.house !== houseKey) return;
if (c.tribeRootId !== tribeRootId) return;
hit = { author: m.value.author, ts: m.value.timestamp || 0 };
}, () => resolve(hit))
);
});
const msgs = await readTyped(client, ANCHOR_TYPES);
for (const m of msgs) {
const c = m && m.value && m.value.content;
if (!c || c.type !== 'larpHouseTribeAnchor' || c.house !== houseKey || c.tribeRootId !== tribeRootId) continue;
return { author: m.value.author, ts: m.value.timestamp || 0 };
}
return null;
}
async function publishHouseTribeAnchor(houseKey, tribeRootId, tribeCreatedAt) {
@ -332,7 +321,7 @@ module.exports = ({ cooler, tribesModel, tribeCrypto }) => {
const canonical = await findAnyHouseTribe(houseKey).catch(() => null);
const canonRoot = canonical ? await tribesModel.getRootId(canonical.id).catch(() => canonical.id) : null;
const myTribes = await listMyHouseTribes(houseKey);
myTribes.sort((a, b) => a.createdAtTs - b.createdAtTs);
myTribes.sort((a, b) => (a.createdAtTs - b.createdAtTs) || String(a.rootId || '').localeCompare(String(b.rootId || '')));
const keepRoot = canonRoot || (myTribes[0] && myTribes[0].rootId);
for (const t of myTribes) {
if (t.rootId === keepRoot) continue;
@ -345,6 +334,13 @@ module.exports = ({ cooler, tribesModel, tribeCrypto }) => {
await redeemPendingAutoInvites().catch(() => {});
return await findMyHouseTribe(houseKey) || canonical;
}
if (Date.now() - startedAt < ACADEMIA_GRACE_MS && await followsSomeone(client, me)) {
if (!academiaRetry) {
academiaRetry = setTimeout(() => { academiaRetry = null; enqueue(runCatchup); }, ACADEMIA_GRACE_MS - (Date.now() - startedAt));
if (academiaRetry.unref) academiaRetry.unref();
}
return null;
}
const house = HOUSES[houseKey] || {};
const tag = houseTribeTag(houseKey);
try {
@ -460,6 +456,7 @@ module.exports = ({ cooler, tribesModel, tribeCrypto }) => {
joinedAt: new Date().toISOString()
}, (err, msg) => err ? reject(err) : resolve(msg));
});
forgetMemberships();
if (previousHouse && previousHouse !== houseKey) {
await leaveMyHouseTribe(previousHouse).catch(() => {});
}
@ -471,53 +468,45 @@ module.exports = ({ cooler, tribesModel, tribeCrypto }) => {
async function getUserHouse(feedId) {
const client = await openSsb();
const target = feedId || client.id;
return new Promise((resolve) => {
let latest = null;
let latestTs = 0;
pull(
client.createUserStream({ id: target, reverse: true }),
pull.drain((m) => {
const c = m && m.value && m.value.content;
if (!c) return;
const ts = m.value.timestamp || 0;
if (c.type === 'larpJoinHouse' && VALID_KEY(c.house)) {
if (ts > latestTs) { latestTs = ts; latest = c.house; }
} else if (c.type === 'larpLeaveLarp') {
if (ts > latestTs) { latestTs = ts; latest = null; }
}
}, () => resolve(latest))
);
});
return (await listAllMemberships()).get(target) || null;
}
async function listAllMemberships() {
async function readMemberships() {
const client = await openSsb();
return new Promise((resolve) => {
const byAuthor = new Map();
pull(
client.createLogStream({ reverse: true }),
pull.drain((m) => {
const author = m && m.value && m.value.author;
if (!author) return;
const c = m.value.content;
if (!c) return;
const ts = m.value.timestamp || 0;
if (c.type === 'larpJoinHouse' && VALID_KEY(c.house)) {
const prev = byAuthor.get(author);
if (!prev || ts > prev.ts) byAuthor.set(author, { house: c.house, ts });
} else if (c.type === 'larpLeaveLarp') {
const prev = byAuthor.get(author);
if (!prev || ts > prev.ts) byAuthor.set(author, { house: null, ts });
}
}, () => {
const result = new Map();
for (const [a, v] of byAuthor.entries()) {
if (v.house) result.set(a, v.house);
}
resolve(result);
})
);
});
const msgs = await readTyped(client, ['larpJoinHouse', 'larpLeaveLarp'], { limit: 0 });
const byAuthor = new Map();
for (const m of msgs) {
const author = m && m.value && m.value.author;
if (!author) continue;
const c = m.value.content;
if (!c) continue;
const ts = m.value.timestamp || 0;
const seq = Number(m.value.sequence) || 0;
const prev = byAuthor.get(author);
const newer = !prev || ts > prev.ts || (ts === prev.ts && seq > prev.seq);
if (c.type === 'larpJoinHouse' && VALID_KEY(c.house)) {
if (newer) byAuthor.set(author, { house: c.house, ts, seq });
} else if (c.type === 'larpLeaveLarp') {
if (newer) byAuthor.set(author, { house: null, ts, seq });
}
}
const result = new Map();
for (const [a, v] of byAuthor.entries()) {
if (v.house) result.set(a, v.house);
}
return result;
}
const forgetMemberships = () => {
const store = requestScope.getStore();
if (store) delete store.larpMemberships;
};
async function listAllMemberships() {
const store = requestScope.getStore();
if (!store) return readMemberships();
if (!store.larpMemberships) store.larpMemberships = readMemberships();
return store.larpMemberships;
}
async function publishLeaveLarp() {
@ -530,6 +519,7 @@ module.exports = ({ cooler, tribesModel, tribeCrypto }) => {
leftAt: new Date().toISOString()
}, (err, msg) => err ? reject(err) : resolve(msg));
});
forgetMemberships();
if (previousHouse) {
await leaveMyHouseTribe(previousHouse).catch(() => {});
}
@ -585,10 +575,16 @@ module.exports = ({ cooler, tribesModel, tribeCrypto }) => {
const memberships = await listAllMemberships();
return new Promise((resolve) => {
const posts = [];
const tombs = new Map();
pull(
client.createLogStream({ reverse: true }),
pull.drain((m) => {
const c = m && m.value && m.value.content;
if (c && c.type === 'tombstone' && typeof c.target === 'string') {
if (!tombs.has(c.target)) tombs.set(c.target, new Set());
tombs.get(c.target).add(m.value.author);
return;
}
if (!c || c.type !== 'larpHousePost') return;
if (c.house !== houseKey) return;
const author = m.value.author;
@ -602,8 +598,9 @@ module.exports = ({ cooler, tribesModel, tribeCrypto }) => {
ts: m.value.timestamp || 0
});
}, () => {
posts.sort((a, b) => b.ts - a.ts);
resolve(posts);
const shown = posts.filter(post => !(tombs.get(post.id) && tombs.get(post.id).has(post.author)));
shown.sort((a, b) => b.ts - a.ts);
resolve(shown);
})
);
});
@ -770,14 +767,20 @@ module.exports = ({ cooler, tribesModel, tribeCrypto }) => {
const ssbKeys = require('../server/node_modules/ssb-keys');
const config = require('../server/ssb_config');
const codes = [];
const sealedOnly = client.private && typeof client.private.read === 'function';
await new Promise((resolve) => {
pull(
client.createLogStream({ reverse: true, limit: 2000 }),
sealedOnly ? client.private.read({ reverse: true }) : client.createLogStream({ reverse: true, limit: 2000 }),
pull.drain((m) => {
const c = m && m.value && m.value.content;
if (typeof c !== 'string' || !c.endsWith('.box')) return;
let decoded;
try { decoded = ssbKeys.unbox(c, config.keys); } catch (_) { return; }
if (typeof c === 'string') {
if (!c.endsWith('.box')) return;
try { decoded = ssbKeys.unbox(c, config.keys); } catch (_) { return; }
} else {
if (!m.value.private) return;
decoded = c;
}
if (!decoded) return;
if (typeof decoded === 'string') {
try { decoded = JSON.parse(decoded); } catch (_) { return; }
@ -832,6 +835,13 @@ module.exports = ({ cooler, tribesModel, tribeCrypto }) => {
await issueAutoInvitesForMyHouse().catch(() => {});
return;
}
if (typeof c === 'object' && c.type === 'tribe' && Array.isArray(c.tags) && c.tags.includes(houseTribeTag('academia'))) {
if (m.value.author === me) return;
const myHouse = await getUserHouse(me).catch(() => null);
if (myHouse !== 'academia') return;
await ensureHouseTribe('academia').catch(() => {});
return;
}
if (typeof c === 'object' && c.type === 'larpHouseTribeAnchor' && VALID_KEY(c.house)) {
if (m.value.author === me) return;
const myHouse = await getUserHouse(me).catch(() => null);
@ -931,6 +941,7 @@ module.exports = ({ cooler, tribesModel, tribeCrypto }) => {
publishJoin,
publishLeaveLarp,
getUserHouse,
listAllMemberships,
listHousesWithCounts,
getMembersOfHouse,
publishHousePost,