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).
532 lines
22 KiB
JavaScript
532 lines
22 KiB
JavaScript
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: '2w–6m' };
|
||
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);
|
||
}
|
||
};
|
||
};
|
||
|