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,11 +1,7 @@
const pull = require('../server/node_modules/pull-stream');
const ssbClientGUI = require("../client/gui");
const coolerInstance = ssbClientGUI({ offline: require('../server/ssb_config').offline });
const { readTyped, CONTENT_TYPES, requestScope, authorActivity } = require('./typed_log');
const { isContentVisibleTo } = require('./content_visibility');
const models = require("../models/main_models");
const { about, friend } = models({
cooler: coolerInstance,
isPublic: require('../server/ssb_config').public,
});
const { getConfig } = require('../configs/config-manager.js');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
@ -15,36 +11,48 @@ function toImageUrl(imgId, size=256){
return `/image/${size}/${encodeURIComponent(imgId)}`;
}
module.exports = ({ cooler }) => {
const MIN_SUGGESTION_AFFINITY = 0.1;
const MAX_SUGGESTED = 12;
module.exports = ({ cooler, tribesModel = null, dataModel = null }) => {
const { about, friend } = models({ cooler, isPublic: require('../server/ssb_config').public });
let ssb;
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
async function getLastKarmaScore(feedId) {
const bytesPerAuthor = (ssbClient) => readTyped(ssbClient, CONTENT_TYPES, { limit: logLimit, withWindow: true }).then((msgs) => {
const acc = {};
for (const m of msgs) {
const author = m && m.value && m.value.author;
if (!author) continue;
try { acc[author] = (acc[author] || 0) + Buffer.byteLength(JSON.stringify(m.value), 'utf8'); } catch (_) {}
}
return acc;
}).catch(() => ({}));
async function latestKarmaByAuthor() {
const ssbClient = await openSsb();
return new Promise(resolve => {
const out = new Map();
const src = ssbClient.messagesByType
? ssbClient.messagesByType({ type: "karmaScore", reverse: true })
: ssbClient.createLogStream && ssbClient.createLogStream({ reverse: true });
if (!src) return resolve(0);
if (!src) return resolve(out);
pull(
src,
pull.filter(msg => {
pull.drain(msg => {
const v = msg.value || msg;
const c = v.content || {};
return v.author === feedId && c.type === "karmaScore" && typeof c.karmaScore !== "undefined";
}),
pull.take(1),
pull.collect((err, arr) => {
if (err || !arr || !arr.length) return resolve(0);
const v = arr[0].value || arr[0];
resolve(v.content.karmaScore || 0);
})
if (c.type !== "karmaScore" || typeof c.karmaScore === "undefined" || out.has(v.author)) return;
out.set(v.author, c.karmaScore || 0);
}, () => resolve(out))
);
});
}
async function getLastActivityTimestamp(feedId) {
const ssbClient = await openSsb();
const activity = await authorActivity(ssbClient);
if (activity) return activity.get(feedId) || null;
const norm = (t) => (t && t < 1e12 ? t * 1000 : t || 0);
return new Promise((resolve) => {
pull(
@ -82,21 +90,34 @@ module.exports = ({ cooler }) => {
}
};
async function listAllBase(ssbClient) {
const authorsMsgs = await new Promise((res, rej) => {
pull(
ssbClient.createLogStream({ limit: logLimit, reverse: true }),
pull.filter(msg => !!msg.value?.author && msg.value?.content?.type !== 'tombstone'),
pull.collect((err, msgs) => err ? rej(err) : res(msgs))
);
});
const uniqueFeedIds = Array.from(new Set(authorsMsgs.map(r => r.value.author).filter(Boolean)));
const publicMode = require('../server/ssb_config').public;
const imageUrlFrom = (raw, size = 256) => {
const img = raw == null || raw.link == null ? `&${"0".repeat(43)}=.sha256` : (typeof raw.link === 'string' ? raw.link : raw);
const id = typeof img === 'string' ? img : (img && (img.link || img.url));
return toImageUrl(id, size);
};
async function listAllBase(ssbClient, onlyIds = null) {
const store = requestScope.getStore();
const memoKey = onlyIds ? null : 'inhabitantsBase';
if (memoKey && store && store[memoKey]) return store[memoKey];
const pending = buildBase(ssbClient, onlyIds);
if (memoKey && store) store[memoKey] = pending;
return pending;
}
async function buildBase(ssbClient, onlyIds) {
const only = onlyIds ? new Set(onlyIds) : null;
const authorsMsgs = (await readTyped(ssbClient, CONTENT_TYPES, { limit: logLimit, withWindow: true })).filter(msg => !!msg.value?.author && msg.value?.content?.type !== 'tombstone').reverse();
const uniqueFeedIds = Array.from(new Set(authorsMsgs.map(r => r.value.author).filter(Boolean))).filter(id => !only || only.has(id));
const profiles = publicMode ? null : await about.profiles().catch(() => null);
const users = await Promise.all(
uniqueFeedIds.map(async (feedId) => {
const rawName = await about.name(feedId);
const p = profiles ? (profiles.get(feedId) || {}) : null;
const rawName = p ? (p.name || feedId.slice(1, 9)) : await about.name(feedId);
const name = rawName || feedId.slice(0, 10);
const description = await about.description(feedId);
const photo = await fetchUserImageUrl(feedId, 256);
const description = p ? (p.description || "") : await about.description(feedId);
const photo = p ? imageUrlFrom(p.image, 256) : await fetchUserImageUrl(feedId, 256);
const lastActivityTs = await getLastActivityTimestamp(feedId);
const { bucket, range } = bucketLastActivity(lastActivityTs);
return { id: feedId, name, description, photo, lastActivityTs, lastActivityBucket: bucket, lastActivityRange: range };
@ -115,8 +136,14 @@ module.exports = ({ cooler }) => {
}
return {
async countInhabitants() {
const ssbClient = await openSsb();
const msgs = await readTyped(ssbClient, CONTENT_TYPES, { limit: logLimit, withWindow: true });
return new Set(msgs.filter(msg => !!msg.value?.author && msg.value?.content?.type !== 'tombstone').map(msg => msg.value.author)).size;
},
async listInhabitants(options = {}) {
const { filter = 'all', search = '', location = '', language = '', skills = '', includeInactive = false } = options;
const { filter = 'all', search = '', location = '', language = '', skills = '', includeInactive = false, ids = null } = options;
const ssbClient = await openSsb();
const userId = ssbClient.id;
@ -131,7 +158,7 @@ module.exports = ({ cooler }) => {
}
if (filter === 'all' || filter === 'TOP KARMA' || filter === 'TOP ACTIVITY' || filter === 'TOP INACTIVITY' || filter === 'TOP ECO') {
let users = await listAllBase(ssbClient);
let users = (await listAllBase(ssbClient, ids)).slice();
if (filter !== 'TOP ACTIVITY' && filter !== 'TOP INACTIVITY') {
users = filterInactive(users);
}
@ -143,23 +170,12 @@ module.exports = ({ cooler }) => {
(u.id || '').toLowerCase().includes(q)
);
}
const bytesByAuthor = await new Promise((res) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, msgs) => {
if (err || !Array.isArray(msgs)) return res({});
const acc = {};
for (const m of msgs) {
const author = m && m.value && m.value.author;
if (!author) continue;
try { acc[author] = (acc[author] || 0) + Buffer.byteLength(JSON.stringify(m.value), 'utf8'); } catch (_) {}
}
res(acc);
})
);
});
const store = requestScope.getStore();
if (store && !store.inhabitantBytes) store.inhabitantBytes = bytesPerAuthor(ssbClient);
const bytesByAuthor = await (store ? store.inhabitantBytes : bytesPerAuthor(ssbClient));
const karmaOf = await latestKarmaByAuthor();
const withMetrics = await Promise.all(users.map(async u => {
const karmaScore = await getLastKarmaScore(u.id);
const karmaScore = karmaOf.get(u.id) || 0;
const bytes = (bytesByAuthor && bytesByAuthor[u.id]) || 0;
const carbonGrams = (bytes / (1024 * 1024)) * 0.095;
if (filter === 'TOP ECO') {
@ -199,80 +215,54 @@ module.exports = ({ cooler }) => {
if (filter === 'SUGGESTED') {
const base = await listAllBase(ssbClient);
const active = filterInactive(base);
const cvRecords = await new Promise((res) => {
pull(
ssbClient.createLogStream({ limit: logLimit, reverse: true }),
pull.filter(msg => msg && msg.value && msg.value.content && msg.value.content.type === 'curriculum'),
pull.collect((err, msgs) => err ? res([]) : res(msgs))
);
});
const cvByAuthor = new Map();
for (const r of cvRecords) {
const c = r.value && r.value.content;
if (c && c.author && !cvByAuthor.has(c.author)) cvByAuthor.set(c.author, c);
}
const extractSkills = (cv) => cv ? [
...(cv.personalSkills || []),
...(cv.oasisSkills || []),
...(cv.educationalSkills || []),
...(cv.professionalSkills || [])
].map(s => String(s || '').toLowerCase()).filter(Boolean) : [];
const mecv = await this.getCVByUserId().catch(() => null);
const mySkills = extractSkills(mecv);
const affinities = dataModel ? await dataModel.authorAffinities().catch(() => null) : null;
const byAuthor = affinities ? affinities.byAuthor : new Map();
const karmaOf = await latestKarmaByAuthor();
const rels = await Promise.all(
active.map(async u => {
if (u.id === userId) return null;
const rel = await friend.getRelationship(u.id).catch(() => ({}));
const n = normalizeRel(rel);
if (n.iFollow || n.blocking || n.blockedBy) return null;
const karmaScore = await getLastKarmaScore(u.id);
const theirSkills = extractSkills(cvByAuthor.get(u.id));
const commonSkills = mySkills.length && theirSkills.length
? Array.from(new Set(mySkills.filter(s => theirSkills.includes(s))))
: [];
const followsMeBonus = n.followsMe ? 20 : 0;
const karmaBonus = Math.min(20, Math.log10(1 + Math.max(0, karmaScore)) * 5);
const skillBonus = commonSkills.length * 4;
const activityBonus = u.lastActivityBucket === 'green' ? 5 : (u.lastActivityBucket === 'orange' ? 2 : 0);
const suggestionScore = followsMeBonus + karmaBonus + skillBonus + activityBonus;
return { user: u, rel: n, karmaScore, commonSkills, suggestionScore };
const aff = byAuthor.get(u.id) || { score: 0, common: [], reasons: [] };
const tribeMate = aff.reasons.includes('tribe');
if (aff.score < MIN_SUGGESTION_AFFINITY && !n.followsMe && !tribeMate) return null;
const social = (n.followsMe ? 0.15 : 0) + (tribeMate ? 0.1 : 0);
const activityBonus = u.lastActivityBucket === 'green' ? 0.05 : (u.lastActivityBucket === 'orange' ? 0.02 : 0);
const suggestionScore = Math.min(1, aff.score + social + activityBonus);
const karmaScore = karmaOf.get(u.id) || 0;
return { user: u, rel: n, karmaScore, commonSkills: aff.common, reasons: aff.reasons, suggestionScore };
})
);
const candidates = rels.filter(Boolean).filter(x => x.suggestionScore > 0);
const candidates = rels.filter(Boolean);
const enriched = candidates.map(x => ({
...x.user,
karmaScore: x.karmaScore,
followsYou: x.rel.followsMe,
commonSkills: x.commonSkills,
reasons: x.reasons,
mutualCount: x.rel.followsMe ? 1 : 0,
suggestionScore: x.suggestionScore
suggestionScore: x.suggestionScore,
affinity: x.suggestionScore
}));
const unique = Array.from(new Map(enriched.map(u => [u.id, u])).values());
return unique.sort((a, b) =>
(b.suggestionScore || 0) - (a.suggestionScore || 0) ||
(b.karmaScore || 0) - (a.karmaScore || 0) ||
(b.lastActivityTs || 0) - (a.lastActivityTs || 0)
);
).slice(0, MAX_SUGGESTED);
}
if (filter === 'CVs') {
const records = await new Promise((res, rej) => {
pull(
ssbClient.createLogStream({ limit: logLimit, reverse: true}),
pull.filter(msg =>
msg.value.content?.type === 'curriculum' &&
msg.value.content?.type !== 'tombstone'
),
pull.collect((err, msgs) => err ? rej(err) : res(msgs))
);
});
const records = (await readTyped(ssbClient, ['curriculum'], { limit: logLimit })).filter(msg => msg.value.content?.type === 'curriculum').reverse();
let cvs = records.map(r => r.value.content);
cvs = Array.from(new Map(cvs.map(u => [u.author, u])).values());
if (filter === 'CVs') {
const profiles = publicMode ? null : await about.profiles().catch(() => null);
let out = await Promise.all(cvs.map(async c => {
const photo = await fetchUserImageUrl(c.author, 256);
const photo = profiles ? imageUrlFrom((profiles.get(c.author) || {}).image, 256) : await fetchUserImageUrl(c.author, 256);
const lastActivityTs = await getLastActivityTimestamp(c.author);
const { bucket, range } = bucketLastActivity(lastActivityTs);
const base = this._normalizeCurriculum(c, photo);
@ -308,6 +298,7 @@ module.exports = ({ cooler }) => {
name: c.name,
description: c.description,
photo,
pdf: c.pdf || null,
skills: [
...(c.personalSkills || []),
...(c.oasisSkills || []),
@ -324,6 +315,14 @@ module.exports = ({ cooler }) => {
};
},
async listSupporters(feedId) {
const ssbClient = await openSsb();
const graph = await new Promise((resolve) => ssbClient.friends.graph((err, g) => resolve(err ? {} : (g || {}))));
return Object.entries(graph)
.filter(([source, edges]) => source !== feedId && edges && (edges[feedId] === true || Number(edges[feedId]) > 0))
.map(([source]) => source);
},
async getLatestAboutById(id) {
const ssbClient = await openSsb();
const records = await new Promise((res, rej) => {
@ -369,21 +368,8 @@ module.exports = ({ cooler }) => {
const isOwner = viewer === target;
const arr = (v) => Array.isArray(v) ? v : [];
const up = (v) => String(v || '').toUpperCase();
const COUNTED = new Set(['post','event','task','forum','tribe','market','job','housing','project','industry','shop','image','video','audio','document','bookmark','transfer','map']);
const accessible = (type, c) => {
if (c.encryptedPayload) return false;
switch (type) {
case 'task': return up(c.isPublic) !== 'PRIVATE' || isOwner || arr(c.assignees).includes(viewer);
case 'event': return String(c.isPublic || '').toLowerCase() !== 'private' || isOwner || arr(c.attendees).includes(viewer);
case 'forum': return c.isPrivate !== true || isOwner;
case 'job': return up(c.visibility) !== 'HIDDEN' || isOwner || arr(c.subscribers).includes(viewer);
case 'housing': return up(c.visibility) !== 'HIDDEN' || isOwner;
case 'market': return up(c.visibility) !== 'HIDDEN' || isOwner;
case 'shop': return up(c.visibility) !== 'CLOSED' || isOwner;
case 'tribe': { const st = up(c.status); return !(st === 'PRIVATE' || st === 'INVITE-ONLY') || isOwner || arr(c.members).includes(viewer); }
default: return true;
}
};
const COUNTED = new Set(['post','event','task','forum','market','job','housing','project','industry','shop','image','video','audio','document','bookmark','transfer','map']);
const accessible = (type, c) => isOwner || isContentVisibleTo(type, c, viewer, target);
const counts = {};
await new Promise((resolve) => {
pull(
@ -399,6 +385,13 @@ module.exports = ({ cooler }) => {
}, () => resolve())
);
});
if (tribesModel) {
try {
const visible = await tribesModel.listTribesForViewer(viewer);
const n = visible.filter(t => String(t.author) === String(target)).length;
if (n > 0) counts.tribe = n;
} catch (_) {}
}
return counts;
},
@ -435,16 +428,7 @@ module.exports = ({ cooler }) => {
addAll(tokenize(job.requirements));
if (keywords.size === 0) return [];
const records = await new Promise((res, rej) => {
pull(
ssbClient.createLogStream({ limit: logLimit, reverse: true }),
pull.filter(msg =>
msg.value?.content?.type === 'curriculum' &&
msg.value?.content?.type !== 'tombstone'
),
pull.collect((err, msgs) => err ? rej(err) : res(msgs))
);
});
const records = (await readTyped(ssbClient, ['curriculum'], { limit: logLimit })).filter(msg => msg.value?.content?.type === 'curriculum').reverse();
let cvs = records.map(r => r.value.content);
cvs = Array.from(new Map(cvs.map(u => [u.author, u])).values());
cvs = cvs.filter(c => String(c.visibility || 'PUBLIC').toUpperCase() !== 'HIDDEN');