OASIS_MOBILE/src/models/inhabitants_model.js
SITO 82b6c3ae05 upstream: integrar 0.9.5 en 34 ficheros mas y portar los modulos nuevos
Segunda tanda. Resueltas por merge de tres vias 15 divergencias que no solapaban,
y aplicada la version de 0.9.5 en 19 vistas y modelos donde la rama solo arrastraba
codigo antiguo (firmas viejas de renderProductForm, renderJobForm, listTags sin
search, y el import sin renderSpreadEditWarning). Se preserva la eliminacion de
housing donde la rama lo habia descartado: modules_view, blockchain_view,
search_model y tags_model.

Modulos nuevos del dev portados: blog, data, gallery, mentions, polls, workflows,
comments, recurrence y media_gallery, mas contentPdf, pdfDocument y content_favorites.

main_views recupera renderEngagement, spreadsFor y renderSpreadEditWarning, que la
rama habia eliminado y que las vistas de 0.9.5 necesitan. Sin ellas polls_view
lanzaba TypeError al editar.

El overlay de i18n pasa de 15 a 41 claves (46 en castellano): incorpora las 9 que
upstream tenia en 0.9.1 y elimino en 0.9.5 mientras nuestro codigo las sigue usando
(menuBlogs, spreadHint, chatShareUrl, forumFilterHot, publishBlog...) y las 17 de
Karvan, que nunca existieron y tiraban de texto de reserva en ingles; ahora estan
traducidas al castellano.

Verificado con la app arrancada: declara 0.9.5, 0 claves i18n sin resolver (antes
26), 26 rutas responden 200/302, y la interfaz de la rama sale intacta: 10
categorias y 47 modulos en los hexagonos, topbar con Personal/Community y avatar,
7 accesos rapidos en la barra inferior y 0 etiquetas vacias (antes 1, la de Blogs).
2026-08-18 22:03:41 +02:00

532 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const pull = require('../server/node_modules/pull-stream');
const ssbClientGUI = require("../client/gui");
const coolerInstance = ssbClientGUI({ offline: require('../server/ssb_config').offline });
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;
function toImageUrl(imgId, size=256){
if (!imgId) return '/assets/images/default-avatar.png';
if (typeof imgId === 'string' && imgId.startsWith('/image/')) return imgId.replace('/image/256/','/image/'+size+'/').replace('/image/512/','/image/'+size+'/');
return `/image/${size}/${encodeURIComponent(imgId)}`;
}
module.exports = ({ cooler }) => {
let ssb;
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
async function getLastKarmaScore(feedId) {
const ssbClient = await openSsb();
return new Promise(resolve => {
const src = ssbClient.messagesByType
? ssbClient.messagesByType({ type: "karmaScore", reverse: true })
: ssbClient.createLogStream && ssbClient.createLogStream({ reverse: true });
if (!src) return resolve(0);
pull(
src,
pull.filter(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);
})
);
});
}
async function getLastActivityTimestamp(feedId) {
const ssbClient = await openSsb();
const norm = (t) => (t && t < 1e12 ? t * 1000 : t || 0);
return new Promise((resolve) => {
pull(
ssbClient.createUserStream({ id: feedId, reverse: true }),
pull.filter(m => m && m.value && m.value.content && m.value.content.type !== 'tombstone'),
pull.take(1),
pull.collect((err, arr) => {
if (err || !arr || !arr.length) return resolve(null);
const m = arr[0];
const ts = norm((m.value && m.value.timestamp) || m.timestamp);
resolve(ts || null);
})
);
});
}
function bucketLastActivity(ts) {
if (!ts) return { bucket: 'red', range: '≥6m' };
const now = Date.now();
const delta = Math.max(0, now - ts);
const days = delta / 86400000;
if (days < 14) return { bucket: 'green', range: '<2w' };
if (days < 182.5) return { bucket: 'orange', range: '2w6m' };
return { bucket: 'red', range: '≥6m' };
}
const timeoutPromise = (timeout) => new Promise((_, reject) => setTimeout(() => reject('Timeout'), timeout));
const fetchUserImageUrl = async (feedId, size=256) => {
try{
const img = await Promise.race([about.image(feedId), timeoutPromise(5000)]);
const id = typeof img === 'string' ? img : (img && (img.link || img.url));
return toImageUrl(id, size);
}catch{
return '/assets/images/default-avatar.png';
}
};
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 users = await Promise.all(
uniqueFeedIds.map(async (feedId) => {
const rawName = await about.name(feedId);
const name = rawName || feedId.slice(0, 10);
const description = await about.description(feedId);
const photo = 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 };
})
);
return Array.from(new Map(users.filter(u => u && u.id).map(u => [u.id, u])).values());
}
function normalizeRel(rel) {
const r = rel || {};
const iFollow = !!(r.following || r.iFollow || r.youFollow || r.i_follow || r.isFollowing);
const followsMe = !!(r.followsMe || r.followingMe || r.follows_me || r.theyFollow || r.isFollowedBy);
const blocking = !!(r.blocking || r.iBlock || r.isBlocking);
const blockedBy = !!(r.blocked || r.blocksMe || r.isBlockedBy);
return { iFollow, followsMe, blocking, blockedBy };
}
return {
async listInhabitants(options = {}) {
const { filter = 'all', search = '', location = '', language = '', skills = '', includeInactive = false } = options;
const ssbClient = await openSsb();
const userId = ssbClient.id;
const filterInactive = (users) => {
if (includeInactive) return users;
return users.filter(u => u.lastActivityBucket !== 'red');
};
if (filter === 'GALLERY') {
const users = await listAllBase(ssbClient);
return filterInactive(users);
}
if (filter === 'all' || filter === 'TOP KARMA' || filter === 'TOP ACTIVITY' || filter === 'TOP INACTIVITY' || filter === 'TOP ECO') {
let users = await listAllBase(ssbClient);
if (filter !== 'TOP ACTIVITY' && filter !== 'TOP INACTIVITY') {
users = filterInactive(users);
}
if (search) {
const q = search.toLowerCase();
users = users.filter(u =>
(u.name || '').toLowerCase().includes(q) ||
(u.description || '').toLowerCase().includes(q) ||
(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 withMetrics = await Promise.all(users.map(async u => {
const karmaScore = await getLastKarmaScore(u.id);
const bytes = (bytesByAuthor && bytesByAuthor[u.id]) || 0;
const carbonGrams = (bytes / (1024 * 1024)) * 0.095;
if (filter === 'TOP ECO') {
const ecoScore = karmaScore / Math.max(0.01, carbonGrams);
return { ...u, karmaScore, carbonGrams, ecoScore };
}
return { ...u, karmaScore, carbonGrams };
}));
if (filter === 'TOP KARMA') return withMetrics.sort((a, b) => (b.karmaScore || 0) - (a.karmaScore || 0));
if (filter === 'TOP ACTIVITY') return withMetrics.sort((a, b) => (b.lastActivityTs || 0) - (a.lastActivityTs || 0));
if (filter === 'TOP INACTIVITY') return withMetrics.sort((a, b) => (a.lastActivityTs || 0) - (b.lastActivityTs || 0));
if (filter === 'TOP ECO') return withMetrics.sort((a, b) => (b.ecoScore || 0) - (a.ecoScore || 0));
return withMetrics;
}
if (filter === 'contacts') {
const all = await this.listInhabitants({ filter: 'all' });
const result = [];
for (const user of all) {
const rel = await friend.getRelationship(user.id).catch(() => ({}));
if (rel && (rel.following || rel.iFollow)) result.push(user);
}
return Array.from(new Map(result.map(u => [u.id, u])).values());
}
if (filter === 'blocked') {
const all = await this.listInhabitants({ filter: 'all', includeInactive: true });
const result = [];
for (const user of all) {
const rel = await friend.getRelationship(user.id).catch(() => ({}));
const n = normalizeRel(rel);
if (n.blocking) result.push({ ...user, isBlocked: true });
}
return Array.from(new Map(result.map(u => [u.id, u])).values());
}
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 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 candidates = rels.filter(Boolean).filter(x => x.suggestionScore > 0);
const enriched = candidates.map(x => ({
...x.user,
karmaScore: x.karmaScore,
followsYou: x.rel.followsMe,
commonSkills: x.commonSkills,
mutualCount: x.rel.followsMe ? 1 : 0,
suggestionScore: 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)
);
}
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))
);
});
let cvs = records.map(r => r.value.content);
cvs = Array.from(new Map(cvs.map(u => [u.author, u])).values());
if (filter === 'CVs') {
let out = await Promise.all(cvs.map(async c => {
const photo = await fetchUserImageUrl(c.author, 256);
const lastActivityTs = await getLastActivityTimestamp(c.author);
const { bucket, range } = bucketLastActivity(lastActivityTs);
const base = this._normalizeCurriculum(c, photo);
return { ...base, lastActivityTs, lastActivityBucket: bucket, lastActivityRange: range };
}));
out = filterInactive(out);
if (search) {
const q = search.toLowerCase();
out = out.filter(u =>
(u.name || '').toLowerCase().includes(q) ||
(u.description || '').toLowerCase().includes(q) ||
u.skills.some(s => (s || '').toLowerCase().includes(q))
);
}
if (location) out = out.filter(u => (u.location || '').toLowerCase() === location.toLowerCase());
if (language) out = out.filter(u => u.languages.map(l => l.toLowerCase()).includes(language.toLowerCase()));
if (skills) {
const skillList = skills.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
out = out.filter(u => skillList.every(s => u.skills.map(k => (k || '').toLowerCase()).includes(s)));
}
return out;
}
}
return [];
},
_normalizeCurriculum(c, photoUrl) {
const photo = c.photo ? toImageUrl(c.photo, 256) : (photoUrl || toImageUrl(null, 256));
return {
id: c.author,
name: c.name,
description: c.description,
photo,
skills: [
...(c.personalSkills || []),
...(c.oasisSkills || []),
...(c.educationalSkills || []),
...(c.professionalSkills || [])
],
location: c.location,
languages: typeof c.languages === 'string'
? c.languages.split(',').map(x => x.trim())
: Array.isArray(c.languages) ? c.languages : [],
status: c.status,
preferences: c.preferences,
createdAt: c.createdAt
};
},
async getLatestAboutById(id) {
const ssbClient = await openSsb();
const records = await new Promise((res, rej) => {
pull(
ssbClient.createUserStream({ id }),
pull.filter(msg =>
msg.value.content?.type === 'about' &&
msg.value.content?.type !== 'tombstone'
),
pull.collect((err, msgs) => err ? rej(err) : res(msgs))
);
});
if (!records.length) return null;
const latest = records.sort((a, b) => b.value.timestamp - a.value.timestamp)[0];
return latest.value.content;
},
async getFeedByUserId(id) {
const ssbClient = await openSsb();
const targetId = id || ssbClient.id;
const records = await new Promise((res, rej) => {
pull(
ssbClient.createUserStream({ id: targetId }),
pull.filter(msg =>
msg.value &&
msg.value.content &&
typeof msg.value.content.text === 'string' &&
msg.value.content?.type !== 'tombstone'
),
pull.collect((err, msgs) => err ? rej(err) : res(msgs))
);
});
return records
.filter(m => typeof m.value.content.text === 'string')
.sort((a, b) => b.value.timestamp - a.value.timestamp)
.slice(0, 10);
},
async getInhabitantStats(targetId, viewerId) {
const ssbClient = await openSsb();
const target = targetId || ssbClient.id;
const viewer = viewerId || ssbClient.id;
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','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 '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 counts = {};
await new Promise((resolve) => {
pull(
ssbClient.createUserStream({ id: target }),
pull.drain((m) => {
const c = m && m.value && m.value.content;
if (!c || typeof c !== 'object') return;
const type = c.type;
if (!type || !COUNTED.has(type)) return;
if (c.replaces) return;
if (!accessible(type, c)) return;
counts[type] = (counts[type] || 0) + 1;
}, () => resolve())
);
});
return counts;
},
async getCVByUserId(id) {
const ssbClient = await openSsb();
const targetId = id || ssbClient.id;
const records = await new Promise((res, rej) => {
pull(
ssbClient.createUserStream({ id: targetId }),
pull.filter(msg =>
msg.value.content?.type === 'curriculum' &&
msg.value.content?.type !== 'tombstone'
),
pull.collect((err, msgs) => err ? rej(err) : res(msgs))
);
});
return records.length ? records[records.length - 1].value.content : null;
},
async getCandidatesForJob(job, viewerId = null) {
if (!job || typeof job !== 'object') return [];
const ssbClient = await openSsb();
const tokenize = (s) => String(s || '')
.toLowerCase()
.split(/[^a-z0-9áéíóúñü+#./-]+/i)
.map(t => t.trim())
.filter(t => t && t.length >= 2);
const stop = new Set(['the','a','an','and','or','of','to','in','for','on','with','is','are','be','as','at','by','from','that','this','it','we','you','our','your','un','una','el','la','los','las','de','del','en','con','para','por','y','o','un','una','que','se','su','sus','al','etc']);
const keywords = new Set();
const addAll = (arr) => arr.forEach(t => { if (!stop.has(t)) keywords.add(t); });
if (Array.isArray(job.tags)) job.tags.forEach(t => { const k = String(t || '').toLowerCase().trim(); if (k) keywords.add(k); });
addAll(tokenize(job.title));
addAll(tokenize(job.description));
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))
);
});
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');
const jobAuthor = job.author || null;
const out = await Promise.all(cvs.map(async c => {
if (!c.author) return null;
if (jobAuthor && c.author === jobAuthor) return null;
const cvSkills = [
...(c.personalSkills || []),
...(c.oasisSkills || []),
...(c.educationalSkills || []),
...(c.professionalSkills || [])
].map(s => String(s || '').toLowerCase()).filter(Boolean);
const common = Array.from(new Set(cvSkills.filter(s => keywords.has(s))));
if (common.length === 0) return null;
if (viewerId && c.author !== viewerId) {
try {
const rel = await friend.getRelationship(c.author);
if (rel && (rel.blocking || rel.blockedBy)) return null;
} catch (_) {}
}
const authorTs = await getLastActivityTimestamp(c.author);
let interactionTs = null;
try {
const cvId = c.id || c.key || null;
if (cvId) {
const ssbClient = await openSsb();
interactionTs = await new Promise((resolve) => {
try {
pull(
ssbClient.backlinks.read({ query: [{ $filter: { dest: cvId } }], index: 'DTA', reverse: true, limit: 1 }),
pull.collect((err, arr) => {
if (err || !arr || !arr.length) return resolve(null);
const m = arr[0];
const raw = (m.value && m.value.timestamp) || m.timestamp;
resolve(raw && raw < 1e12 ? raw * 1000 : raw || null);
})
);
} catch (_) { resolve(null); }
});
}
} catch (_) {}
const lastActivityTs = Math.max(authorTs || 0, interactionTs || 0) || null;
const { bucket, range } = bucketLastActivity(lastActivityTs);
if (bucket === 'red') return null;
const photo = await fetchUserImageUrl(c.author, 256);
const matchScore = common.length / keywords.size;
return {
id: c.author,
name: c.name || 'Anonymous',
description: c.description || '',
photo,
location: c.location || '',
status: c.status || '',
preferences: c.preferences || '',
languages: typeof c.languages === 'string'
? c.languages.split(',').map(x => x.trim()).filter(Boolean)
: Array.isArray(c.languages) ? c.languages : [],
commonSkills: common,
matchScore,
lastActivityTs,
lastActivityBucket: bucket,
lastActivityRange: range
};
}));
return out.filter(Boolean).sort((a, b) => {
if (b.matchScore !== a.matchScore) return b.matchScore - a.matchScore;
return (b.lastActivityTs || 0) - (a.lastActivityTs || 0);
}).slice(0, 20);
},
async getPhotoUrlByUserId(id, size = 256) {
return await fetchUserImageUrl(id, size);
},
async getLastActivityTimestampByUserId(id) {
return await getLastActivityTimestamp(id);
},
bucketLastActivity(ts) {
return bucketLastActivity(ts);
}
};
};