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:
parent
8d838dd8b1
commit
c332afd6ea
265 changed files with 80970 additions and 29167 deletions
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue