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).
This commit is contained in:
parent
f964e61a1c
commit
82b6c3ae05
43 changed files with 3221 additions and 2416 deletions
|
|
@ -164,7 +164,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
|
|||
// ties correctly — the mock and real SSB can stamp equal timestamps.
|
||||
if (prev && !((isTip && !prev.isTip) || (isTip === prev.isTip && (a.ts || 0) >= prev.ts))) continue;
|
||||
const cc = a.content || {};
|
||||
stateByRoot.set(root, { ts: a.ts || 0, isTip, status: String(cc.status || '').toUpperCase(), tribeId: cc.tribeId || null, title: String(cc.title || ''), description: String(cc.description || '') });
|
||||
stateByRoot.set(root, { ts: a.ts || 0, isTip, status: String(cc.status || '').toUpperCase(), tribeId: cc.tribeId || null, title: String(cc.title || ''), description: String(cc.description || ''), members: Array.isArray(cc.members) ? cc.members.length : 0 });
|
||||
}
|
||||
const msgsByRoot = new Map();
|
||||
for (const a of idToAction.values()) {
|
||||
|
|
@ -181,7 +181,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
|
|||
const val = await getMsg(ssbClient, root);
|
||||
const cc = val && val.content;
|
||||
if (cc && typeof cc === 'object' && cc.type === 'chat') {
|
||||
stateByRoot.set(root, { ts: 0, status: String(cc.status || '').toUpperCase(), tribeId: cc.tribeId || null, title: String(cc.title || ''), description: String(cc.description || '') });
|
||||
stateByRoot.set(root, { ts: 0, status: String(cc.status || '').toUpperCase(), tribeId: cc.tribeId || null, title: String(cc.title || ''), description: String(cc.description || ''), members: Array.isArray(cc.members) ? cc.members.length : 0 });
|
||||
} else {
|
||||
stateByRoot.set(root, null);
|
||||
}
|
||||
|
|
@ -202,6 +202,8 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
|
|||
chatRoot: root,
|
||||
title: info.title || '',
|
||||
description: info.description || '',
|
||||
members: info.members || 0,
|
||||
messageCount: asc.length,
|
||||
replies: asc.slice(-CHAT_THREAD_LIMIT).map(m => ({ id: m.id, author: m.author, ts: m.ts || 0, text: (m.content && m.content.text) || '' }))
|
||||
}
|
||||
});
|
||||
|
|
@ -251,6 +253,21 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
|
|||
const idToAction = new Map();
|
||||
const rawById = new Map();
|
||||
const voteTally = buildVoteTally(results);
|
||||
const pollTally = new Map();
|
||||
for (const m of results) {
|
||||
const c = m && m.value && m.value.content;
|
||||
if (!c || c.type !== 'pollVote' || typeof c.target !== 'string') continue;
|
||||
if (!Array.isArray(c.choices)) continue;
|
||||
const entry = pollTally.get(c.target) || { counts: {}, voters: new Map() };
|
||||
entry.voters.set(m.value.author, { ts: m.value.timestamp || 0, choices: c.choices });
|
||||
pollTally.set(c.target, entry);
|
||||
}
|
||||
for (const entry of pollTally.values()) {
|
||||
entry.counts = {};
|
||||
for (const v of entry.voters.values()) {
|
||||
for (const choice of v.choices) entry.counts[choice] = (entry.counts[choice] || 0) + 1;
|
||||
}
|
||||
}
|
||||
const fpIdx = tribeCrypto ? tribeCrypto.buildFingerprintIndex() : null;
|
||||
const accessibleTribeIds = await buildAccessibleTribeIds();
|
||||
|
||||
|
|
@ -577,6 +594,11 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
|
|||
const actionRoot = rootOf(a.id);
|
||||
const extra = a.type === 'aiExchange' ? { helpfulVotes: aiHelpfulCounts.get(a.id) || 0 } : {};
|
||||
const voteAgg = a.type === 'votes' ? voteTally.get(a.id) : null;
|
||||
if (a.type === 'poll') {
|
||||
const tally = pollTally.get(a.id) || pollTally.get(actionRoot);
|
||||
a.pollCounts = tally ? tally.counts : {};
|
||||
a.pollVoters = tally ? tally.voters.size : 0;
|
||||
}
|
||||
let content = voteAgg ? { ...c, ...voteAgg } : a.content;
|
||||
if (a.type === 'feed') {
|
||||
const base = a.content || {};
|
||||
|
|
@ -688,12 +710,13 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
|
|||
}
|
||||
|
||||
const tribeInternalTypes = new Set(['tribe-content', 'tribeParliamentCandidature', 'tribeParliamentTerm', 'tribeParliamentProposal', 'tribeParliamentRule', 'tribeParliamentLaw', 'tribeParliamentRevocation']);
|
||||
const hiddenTypes = new Set(['padEntry', 'chatMessage', 'calendarDate', 'calendarNote', 'calendarReminderSent', 'taskReminderSent', 'feed-action', 'pubBalance', 'pubAvailability', 'log', 'gameScore']);
|
||||
const hiddenTypes = new Set(['padEntry', 'chatMessage', 'calendarDate', 'calendarNote', 'calendarReminderSent', 'taskReminderSent', 'feed-action', 'pubBalance', 'pubAvailability', 'log', 'logPublic', 'gameScore', 'pollVote', 'pollClose', 'pollOpinion', 'curriculum']);
|
||||
const chatThreadItems = await buildChatThreads(ssbClient, idToAction, rootOf, deduped);
|
||||
deduped = deduped.concat(chatThreadItems);
|
||||
const isAllowedTribeActivity = (a) => {
|
||||
if (tribeInternalTypes.has(a.type)) return false;
|
||||
const c = a.content || {};
|
||||
if (a.type === 'poll' && c.chatId) return false;
|
||||
if (c.tribeId) return false;
|
||||
if (a.type === 'tribe') {
|
||||
const isInitial = !c.replaces;
|
||||
|
|
@ -704,8 +727,8 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
|
|||
}
|
||||
return true;
|
||||
};
|
||||
const itemVisible = (a) => {
|
||||
if (hiddenTypes.has(a.type)) return false;
|
||||
const itemVisible = (a, opts = {}) => {
|
||||
if (hiddenTypes.has(a.type) && !(opts.allowHidden && opts.allowHidden.has(a.type))) return false;
|
||||
const c = a.content || {};
|
||||
if (c.encryptedPayload) return false;
|
||||
if (a.type === 'pad' && c.status !== 'OPEN') return false;
|
||||
|
|
@ -722,8 +745,8 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
|
|||
if (a.type === 'transfer' && String(c.status || '').toUpperCase() === 'UNCONFIRMED' && a.author !== userId && c.from !== userId && c.to !== userId) return false;
|
||||
return true;
|
||||
};
|
||||
const isVisible = (a) => {
|
||||
if (!itemVisible(a)) return false;
|
||||
const isVisible = (a, opts) => {
|
||||
if (!itemVisible(a, opts)) return false;
|
||||
if (a.type === 'post' || a.type === 'opinion') {
|
||||
const c = a.content || {};
|
||||
const ref = (typeof c.root === 'string' && c.root)
|
||||
|
|
@ -742,6 +765,14 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
|
|||
if (filter === 'mine') out = deduped.filter(a => a.author === userId && isAllowedTribeActivity(a) && isVisible(a));
|
||||
else if (filter === 'recent') { const cutoff = Date.now() - 24 * 60 * 60 * 1000; out = deduped.filter(a => (a.ts || 0) >= cutoff && isAllowedTribeActivity(a) && isVisible(a)) }
|
||||
else if (filter === 'all') out = deduped.filter(a => isAllowedTribeActivity(a) && isVisible(a));
|
||||
else if (filter === 'top') {
|
||||
const visible = deduped.filter(a => isAllowedTribeActivity(a) && isVisible(a));
|
||||
const perAuthor = new Map();
|
||||
for (const a of visible) perAuthor.set(a.author, (perAuthor.get(a.author) || 0) + 1);
|
||||
out = visible
|
||||
.map(a => ({ ...a, authorActions: perAuthor.get(a.author) || 0 }))
|
||||
.sort((x, y) => (y.authorActions - x.authorActions) || ((y.ts || 0) - (x.ts || 0)));
|
||||
}
|
||||
else if (filter === 'banking') out = deduped.filter(a => a.type === 'bankWallet' || a.type === 'bankClaim' || a.type === 'ubiClaim');
|
||||
else if (filter === 'tribe') out = deduped.filter(a => a.type === 'tribe' || String(a.type || '').startsWith('tribe'));
|
||||
else if (filter === 'spread') out = deduped.filter(a => a.type === 'spread');
|
||||
|
|
@ -756,6 +787,8 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
|
|||
});
|
||||
else if (filter === 'task')
|
||||
out = deduped.filter(a => a.type === 'task' || a.type === 'taskAssignment');
|
||||
else if (filter === 'votes')
|
||||
out = deduped.filter(a => (a.type === 'votes' || a.type === 'poll') && isAllowedTribeActivity(a) && isVisible(a));
|
||||
else if (filter === 'industry')
|
||||
out = deduped.filter(a => ['industry', 'industryBuild', 'industryBlueprint', 'industryAllocation'].includes(a.type) && isVisible(a));
|
||||
else if (filter === 'pad') out = deduped.filter(a => a.type === 'pad' && (a.content || {}).status === 'OPEN');
|
||||
|
|
@ -764,7 +797,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
|
|||
else if (filter === 'transfer') out = deduped.filter(a => a.type === 'transfer' && isVisible(a));
|
||||
else out = deduped.filter(a => a.type === filter);
|
||||
|
||||
out.sort((a, b) => (b.ts || 0) - (a.ts || 0));
|
||||
if (filter !== 'top') out.sort((a, b) => (b.ts || 0) - (a.ts || 0));
|
||||
return out;
|
||||
})();
|
||||
_feedCacheInflight.set(cacheKey, promise);
|
||||
|
|
|
|||
168
src/models/blog_model.js
Normal file
168
src/models/blog_model.js
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
const pull = require('../server/node_modules/pull-stream');
|
||||
const { getConfig } = require('../configs/config-manager.js');
|
||||
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
|
||||
const logLimit = getConfig().ssbLogStream?.limit || 1000;
|
||||
|
||||
const OPINION_TYPE = 'blogOpinion';
|
||||
|
||||
module.exports = ({ cooler, isPublic = false }) => {
|
||||
let ssb;
|
||||
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
|
||||
|
||||
const getAllMessages = async (ssbClient) =>
|
||||
new Promise((resolve, reject) => {
|
||||
pull(
|
||||
ssbClient.createLogStream({ limit: logLimit }),
|
||||
pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs)))
|
||||
);
|
||||
});
|
||||
|
||||
const isRootPost = (c) =>
|
||||
!!c && c.type === 'post' && typeof c.text === 'string' &&
|
||||
!c.root && !c.fork && !c.branch && !c.about && !c.target;
|
||||
|
||||
const buildIndex = (messages) => {
|
||||
const tomb = buildValidatedTombstoneSet(messages);
|
||||
const posts = new Map();
|
||||
const commentsByRoot = new Map();
|
||||
const opinionsByRoot = new Map();
|
||||
|
||||
for (const m of messages) {
|
||||
const v = m && m.value;
|
||||
const c = v && v.content;
|
||||
if (!c || typeof c !== 'object') continue;
|
||||
if (tomb.has(m.key)) continue;
|
||||
|
||||
if (c.type === 'post' && typeof c.text === 'string') {
|
||||
if (isRootPost(c)) {
|
||||
posts.set(m.key, { key: m.key, author: v.author, ts: v.timestamp || m.timestamp || 0, c });
|
||||
} else if (typeof c.root === 'string') {
|
||||
commentsByRoot.set(c.root, (commentsByRoot.get(c.root) || 0) + 1);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c.type === OPINION_TYPE && typeof c.target === 'string') {
|
||||
const entry = opinionsByRoot.get(c.target) || { counts: {}, voters: [] };
|
||||
if (!entry.voters.includes(v.author)) {
|
||||
entry.voters.push(v.author);
|
||||
entry.counts[c.category] = (entry.counts[c.category] || 0) + 1;
|
||||
}
|
||||
opinionsByRoot.set(c.target, entry);
|
||||
}
|
||||
}
|
||||
|
||||
return { tomb, posts, commentsByRoot, opinionsByRoot };
|
||||
};
|
||||
|
||||
const buildBlog = (node, idx) => {
|
||||
const c = node.c || {};
|
||||
const op = idx.opinionsByRoot.get(node.key) || { counts: {}, voters: [] };
|
||||
return {
|
||||
id: node.key,
|
||||
key: node.key,
|
||||
author: node.author,
|
||||
subject: typeof c.contentWarning === 'string' ? c.contentWarning : '',
|
||||
text: c.text || '',
|
||||
mentions: Array.isArray(c.mentions) ? c.mentions : [],
|
||||
allowComments: c.allowComments !== false,
|
||||
createdAt: new Date(node.ts).toISOString(),
|
||||
commentCount: idx.commentsByRoot.get(node.key) || 0,
|
||||
opinions: op.counts,
|
||||
opinions_inhabitants: op.voters
|
||||
};
|
||||
};
|
||||
|
||||
const collect = async () => {
|
||||
const ssbClient = await openSsb();
|
||||
const messages = await getAllMessages(ssbClient);
|
||||
const idx = buildIndex(messages);
|
||||
const list = [];
|
||||
for (const node of idx.posts.values()) list.push(buildBlog(node, idx));
|
||||
return { list, idx, viewerId: ssbClient.id };
|
||||
};
|
||||
|
||||
const opinionScore = (blog) =>
|
||||
Object.values(blog.opinions || {}).reduce((n, v) => n + (Number(v) || 0), 0);
|
||||
|
||||
return {
|
||||
type: 'post',
|
||||
|
||||
async listAll(filter = 'ALL', opts = {}) {
|
||||
const { list, viewerId } = await collect();
|
||||
const f = String(filter || 'ALL').toUpperCase();
|
||||
const favorites = Array.isArray(opts.favorites) ? new Set(opts.favorites.map(String)) : null;
|
||||
let out = list.slice();
|
||||
|
||||
if (f === 'MINE') out = out.filter(b => String(b.author) === String(viewerId));
|
||||
else if (f === 'FAVORITES') out = favorites ? out.filter(b => favorites.has(String(b.id))) : [];
|
||||
|
||||
const q = String(opts.q || '').trim().toLowerCase();
|
||||
if (q) out = out.filter(b =>
|
||||
b.text.toLowerCase().includes(q) || b.subject.toLowerCase().includes(q));
|
||||
|
||||
if (f === 'TOP') {
|
||||
out.sort((a, b) => (opinionScore(b) + b.commentCount) - (opinionScore(a) + a.commentCount) ||
|
||||
new Date(b.createdAt) - new Date(a.createdAt));
|
||||
} else {
|
||||
out.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||||
}
|
||||
|
||||
return out;
|
||||
},
|
||||
|
||||
async getBlogById(id) {
|
||||
const { list } = await collect();
|
||||
const blog = list.find(b => b.id === id);
|
||||
if (!blog) throw new Error('Blog not found');
|
||||
return blog;
|
||||
},
|
||||
|
||||
async createBlog({ text, subject = '', mentions = [], allowComments = true } = {}) {
|
||||
if (isPublic) throw new Error('Not available in public mode');
|
||||
const body = String(text || '').trim();
|
||||
if (!body) throw new Error('Blog text is required');
|
||||
const ssbClient = await openSsb();
|
||||
const content = {
|
||||
type: 'post',
|
||||
text: body,
|
||||
allowComments: allowComments !== false,
|
||||
...(Array.isArray(mentions) && mentions.length ? { mentions } : {}),
|
||||
...(String(subject || '').trim() ? { contentWarning: String(subject).trim() } : {})
|
||||
};
|
||||
return new Promise((res, rej) => ssbClient.publish(content, (err, msg) => err ? rej(err) : res(msg)));
|
||||
},
|
||||
|
||||
async createOpinion(id, category) {
|
||||
const categories = require('../backend/opinion_categories');
|
||||
if (!categories.includes(category)) throw new Error('Invalid opinion category');
|
||||
const ssbClient = await openSsb();
|
||||
const userId = ssbClient.id;
|
||||
const blog = await this.getBlogById(id);
|
||||
if (blog.opinions_inhabitants.includes(userId)) throw new Error('Already opined');
|
||||
const content = { type: OPINION_TYPE, target: blog.id, category, createdAt: new Date().toISOString() };
|
||||
return new Promise((res, rej) => ssbClient.publish(content, (err, result) => err ? rej(err) : res(result)));
|
||||
},
|
||||
|
||||
async resolveRootId(id) {
|
||||
const ssbClient = await openSsb();
|
||||
const msg = await new Promise((res) => ssbClient.get(id, (err, m) => res(err ? null : m)));
|
||||
const c = msg && (msg.content || (msg.value && msg.value.content));
|
||||
if (c && c.type === 'post' && typeof c.root === 'string') return c.root;
|
||||
return id;
|
||||
},
|
||||
|
||||
async blogHrefFor(id) {
|
||||
const ssbClient = await openSsb();
|
||||
const msg = await new Promise((res) => ssbClient.get(id, (err, m) => res(err ? null : m)));
|
||||
const c = msg && (msg.content || (msg.value && msg.value.content));
|
||||
if (!c || c.type !== 'post' || typeof c.text !== 'string') return null;
|
||||
if (c.private === true || Array.isArray(c.recps)) return null;
|
||||
if (typeof c.root === 'string') {
|
||||
return `/blogs/${encodeURIComponent(c.root)}#${encodeURIComponent(id)}`;
|
||||
}
|
||||
if (c.fork || c.branch || c.about || c.target) return null;
|
||||
return `/blogs/${encodeURIComponent(id)}`;
|
||||
}
|
||||
};
|
||||
};
|
||||
292
src/models/data_model.js
Normal file
292
src/models/data_model.js
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
const pull = require('../server/node_modules/pull-stream');
|
||||
const { getConfig } = require('../configs/config-manager.js');
|
||||
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
|
||||
|
||||
const logLimit = getConfig().ssbLogStream?.limit || 1000;
|
||||
|
||||
const norm = (v) => String(v == null ? '' : v).trim().toLowerCase();
|
||||
|
||||
const KINDS = {
|
||||
inhabitants: { type: 'curriculum', href: (id, c) => `/author/${encodeURIComponent(c.author)}` },
|
||||
jobs: { type: 'job', href: (id) => `/jobs/${encodeURIComponent(id)}` },
|
||||
projects: { type: 'project', href: (id) => `/projects/${encodeURIComponent(id)}` },
|
||||
events: { type: 'event', href: (id) => `/events/${encodeURIComponent(id)}` },
|
||||
tribes: { type: 'tribe', href: (id) => `/tribe/${encodeURIComponent(id)}` },
|
||||
market: { type: 'market', href: (id) => `/market/${encodeURIComponent(id)}` },
|
||||
housing: { type: 'housing', href: (id) => `/housing/${encodeURIComponent(id)}` },
|
||||
industry: { type: 'industry', href: (id) => `/industry/${encodeURIComponent(id)}` },
|
||||
tasks: { type: 'task', href: (id) => `/tasks/${encodeURIComponent(id)}` },
|
||||
reports: { type: 'report', href: (id) => `/reports/${encodeURIComponent(id)}` },
|
||||
votes: { type: 'poll', href: (id) => `/polls/${encodeURIComponent(id)}` },
|
||||
audios: { type: 'audio', href: (id) => `/audios/${encodeURIComponent(id)}` },
|
||||
videos: { type: 'video', href: (id) => `/videos/${encodeURIComponent(id)}` },
|
||||
images: { type: 'image', href: (id) => `/images/${encodeURIComponent(id)}` },
|
||||
documents: { type: 'document', href: (id) => `/documents/${encodeURIComponent(id)}` },
|
||||
bookmarks: { type: 'bookmark', href: (id) => `/bookmarks/${encodeURIComponent(id)}` },
|
||||
torrents: { type: 'torrent', href: (id) => `/torrents/${encodeURIComponent(id)}` },
|
||||
chats: { type: 'chat', href: (id) => `/chats/${encodeURIComponent(id)}` },
|
||||
pads: { type: 'pad', href: (id) => `/pads/${encodeURIComponent(id)}` },
|
||||
maps: { type: 'map', href: (id) => `/maps/${encodeURIComponent(id)}` },
|
||||
calendars: { type: 'calendar', href: (id) => `/calendars/${encodeURIComponent(id)}` },
|
||||
forum: { type: 'forum', href: (id) => `/forum/${encodeURIComponent(id)}` }
|
||||
};
|
||||
|
||||
const KIND_BY_TYPE = Object.fromEntries(Object.entries(KINDS).map(([k, v]) => [v.type, k]));
|
||||
|
||||
const cvSkills = (c) => [
|
||||
...(c.personalSkills || []),
|
||||
...(c.oasisSkills || []),
|
||||
...(c.educationalSkills || []),
|
||||
...(c.professionalSkills || [])
|
||||
];
|
||||
|
||||
const PLACEHOLDERS = new Set(['unknown', 'n/a', 'na', 'none', '-', 'other']);
|
||||
|
||||
const TITLE_STOPWORDS = new Set([
|
||||
'the', 'this', 'that', 'and', 'for', 'with', 'from', 'into', 'about', 'new', 'all', 'not', 'are', 'was', 'you', 'your', 'our',
|
||||
'los', 'las', 'del', 'una', 'unos', 'unas', 'este', 'esta', 'esto', 'que', 'con', 'para', 'por', 'sin', 'sobre', 'bajo', 'mas', 'más'
|
||||
]);
|
||||
|
||||
const titleTerms = (c) => String(c.title || c.name || c.question || c.concept || '')
|
||||
.toLowerCase()
|
||||
.split(/[^\p{L}\p{N}]+/u)
|
||||
.filter(w => w.length >= 3 && !TITLE_STOPWORDS.has(w) && !/^\d+$/.test(w));
|
||||
|
||||
const termsOf = (kind, c) => {
|
||||
const out = [];
|
||||
if (kind === 'inhabitants') {
|
||||
out.push(...cvSkills(c));
|
||||
if (c.languages) out.push(...String(c.languages).split(/[,;]/));
|
||||
} else if (kind === 'jobs') {
|
||||
out.push(...(c.tasks || []), ...(c.tags || []), c.job_type, c.location);
|
||||
} else if (kind === 'industry') {
|
||||
out.push(...(c.tags || []), c.sector, ...(c.skills || []));
|
||||
} else {
|
||||
out.push(...(c.tags || []));
|
||||
if (c.category) out.push(c.category);
|
||||
}
|
||||
return Array.from(new Set(out.map(norm).filter(t => t && !PLACEHOLDERS.has(t))));
|
||||
};
|
||||
|
||||
const titleOf = (kind, c, author) => {
|
||||
if (kind === 'inhabitants') return c.name || author || '';
|
||||
return c.title || c.name || c.question || c.concept || '';
|
||||
};
|
||||
|
||||
const jaccard = (a, b) => {
|
||||
if (!a.size || !b.size) return { score: 0, common: [] };
|
||||
const common = [...b].filter(x => a.has(x));
|
||||
const union = a.size + b.size - common.length;
|
||||
return { score: union > 0 ? common.length / union : 0, common };
|
||||
};
|
||||
|
||||
module.exports = ({ cooler }) => {
|
||||
let ssb;
|
||||
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
|
||||
|
||||
const getAllMessages = async (ssbClient) =>
|
||||
new Promise((resolve, reject) => {
|
||||
pull(
|
||||
ssbClient.createLogStream({ limit: logLimit }),
|
||||
pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs)))
|
||||
);
|
||||
});
|
||||
|
||||
const buildGraph = async () => {
|
||||
const ssbClient = await openSsb();
|
||||
const viewerId = ssbClient.id;
|
||||
const messages = await getAllMessages(ssbClient);
|
||||
const tomb = buildValidatedTombstoneSet(messages);
|
||||
|
||||
const latestByKey = new Map();
|
||||
const replaced = new Set();
|
||||
for (const m of messages) {
|
||||
const v = m && m.value;
|
||||
const c = v && v.content;
|
||||
if (!c || typeof c !== 'object' || !c.type) continue;
|
||||
if (tomb.has(m.key)) continue;
|
||||
if (c.encryptedPayload || c.encryptedQuestion) continue;
|
||||
if (c.tribeId && c.type !== 'tribe') continue;
|
||||
const kind = KIND_BY_TYPE[c.type];
|
||||
if (!kind) continue;
|
||||
if (typeof c.replaces === 'string') replaced.add(c.replaces);
|
||||
latestByKey.set(m.key, { key: m.key, author: v.author, ts: v.timestamp || m.timestamp || 0, kind, c });
|
||||
}
|
||||
|
||||
const nodes = [];
|
||||
const byAuthorCv = new Map();
|
||||
for (const node of latestByKey.values()) {
|
||||
if (replaced.has(node.key)) continue;
|
||||
const coreTerms = termsOf(node.kind, node.c);
|
||||
const extra = node.kind === 'inhabitants' ? [] : titleTerms(node.c).map(norm).filter(t => t && !PLACEHOLDERS.has(t));
|
||||
const terms = Array.from(new Set([...coreTerms, ...extra]));
|
||||
if (!terms.length) continue;
|
||||
const entry = {
|
||||
id: node.key,
|
||||
kind: node.kind,
|
||||
author: node.c.author || node.author,
|
||||
title: titleOf(node.kind, node.c, node.c.author || node.author),
|
||||
terms,
|
||||
termSet: new Set(terms),
|
||||
coreTermSet: new Set(coreTerms),
|
||||
ts: node.ts,
|
||||
createdAt: node.c.createdAt || new Date(node.ts).toISOString(),
|
||||
href: KINDS[node.kind].href(node.key, node.c)
|
||||
};
|
||||
if (node.kind === 'inhabitants') {
|
||||
const prev = byAuthorCv.get(entry.author);
|
||||
if (prev && prev.ts >= entry.ts) continue;
|
||||
byAuthorCv.set(entry.author, entry);
|
||||
continue;
|
||||
}
|
||||
nodes.push(entry);
|
||||
}
|
||||
for (const cv of byAuthorCv.values()) nodes.push(cv);
|
||||
|
||||
return { viewerId, nodes, cvByAuthor: byAuthorCv };
|
||||
};
|
||||
|
||||
const MAX_ENTITIES = 400;
|
||||
const MAX_PAIRS = 300;
|
||||
|
||||
const strip = (n) => ({
|
||||
id: n.id, kind: n.kind, author: n.author, title: n.title,
|
||||
href: n.href, createdAt: n.createdAt, ts: n.ts
|
||||
});
|
||||
|
||||
return {
|
||||
KINDS: Object.keys(KINDS),
|
||||
|
||||
async listMatches(filter = 'ALL', opts = {}) {
|
||||
const { viewerId, nodes, cvByAuthor } = await buildGraph();
|
||||
const use = nodes.slice(0, MAX_ENTITIES);
|
||||
const f = String(filter || 'ALL').toUpperCase();
|
||||
|
||||
const myTermSet = new Set();
|
||||
const mineCv = cvByAuthor.get(viewerId);
|
||||
if (mineCv) for (const t of mineCv.terms) myTermSet.add(t);
|
||||
for (const n of use) {
|
||||
if (String(n.author) === String(viewerId)) for (const t of n.terms) myTermSet.add(t);
|
||||
}
|
||||
|
||||
const df = new Map();
|
||||
for (const n of use) for (const t of n.termSet) df.set(t, (df.get(t) || 0) + 1);
|
||||
const total = use.length || 1;
|
||||
const weightOf = (t) => Math.log(1 + total / (df.get(t) || 1));
|
||||
|
||||
let out = [];
|
||||
for (const n of use) {
|
||||
if (String(n.author) === String(viewerId)) continue;
|
||||
const common = [...n.termSet].filter(t => myTermSet.has(t));
|
||||
if (!common.length) continue;
|
||||
let commonW = 0;
|
||||
for (const t of common) commonW += weightOf(t);
|
||||
let itemW = 0;
|
||||
for (const t of n.termSet) itemW += weightOf(t);
|
||||
const score = itemW > 0 ? Math.min(1, commonW / itemW) : 0;
|
||||
if (score <= 0) continue;
|
||||
common.sort((x, y) => weightOf(y) - weightOf(x) || x.localeCompare(y));
|
||||
out.push({ ...strip(n), score, common, connections: common.length });
|
||||
}
|
||||
|
||||
if (f === 'RECENT') {
|
||||
const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
||||
out = out.filter(s => s.ts >= cutoff);
|
||||
} else if (f !== 'ALL' && f !== 'TOP') {
|
||||
const kind = f.toLowerCase();
|
||||
if (KINDS[kind]) out = out.filter(s => s.kind === kind);
|
||||
}
|
||||
|
||||
const q = norm(opts.q);
|
||||
if (q) out = out.filter(s => norm(s.title).includes(q) || s.common.some(t => t.includes(q)));
|
||||
|
||||
if (f === 'RECENT') out.sort((x, y) => y.ts - x.ts || y.score - x.score);
|
||||
else out.sort((x, y) => y.score - x.score || y.ts - x.ts);
|
||||
|
||||
return {
|
||||
matches: out.slice(0, MAX_PAIRS),
|
||||
total: out.length,
|
||||
hasProfile: myTermSet.size > 0,
|
||||
myTerms: [...myTermSet]
|
||||
};
|
||||
},
|
||||
|
||||
async jobMatchesFor(viewerId, { minScore = 0.8 } = {}) {
|
||||
const { nodes, cvByAuthor } = await buildGraph();
|
||||
const mine = cvByAuthor.get(viewerId);
|
||||
if (!mine) return [];
|
||||
return nodes
|
||||
.filter(n => n.kind === 'jobs' && String(n.author) !== String(viewerId))
|
||||
.map(n => {
|
||||
const { score, common } = jaccard(mine.termSet, n.coreTermSet || n.termSet);
|
||||
return { id: n.id, title: n.title, author: n.author, href: n.href, score, common };
|
||||
})
|
||||
.filter(m => m.score >= minScore)
|
||||
.sort((a, b) => b.score - a.score);
|
||||
},
|
||||
|
||||
async cohesion() {
|
||||
const { cvByAuthor, nodes } = await buildGraph();
|
||||
const use = nodes.slice(0, MAX_ENTITIES);
|
||||
let comparisons = 0;
|
||||
let sum = 0;
|
||||
let connectedPairs = 0;
|
||||
const linkedEntities = new Set();
|
||||
for (let i = 0; i < use.length; i++) {
|
||||
for (let j = i + 1; j < use.length; j++) {
|
||||
const a = use[i], b = use[j];
|
||||
if (a.kind === 'inhabitants' && b.kind === 'inhabitants' && a.author === b.author) continue;
|
||||
const { score } = jaccard(a.termSet, b.termSet);
|
||||
comparisons += 1;
|
||||
sum += score;
|
||||
if (score > 0) { connectedPairs += 1; linkedEntities.add(a.id); linkedEntities.add(b.id); }
|
||||
}
|
||||
}
|
||||
const coefficient = comparisons > 0 ? sum / comparisons : 0;
|
||||
|
||||
const people = [...cvByAuthor.values()];
|
||||
const linked = new Set();
|
||||
let cvComparisons = 0;
|
||||
let cvSum = 0;
|
||||
for (let i = 0; i < people.length; i++) {
|
||||
for (let j = i + 1; j < people.length; j++) {
|
||||
const { score } = jaccard(people[i].termSet, people[j].termSet);
|
||||
cvComparisons += 1;
|
||||
cvSum += score;
|
||||
if (score > 0) { linked.add(people[i].author); linked.add(people[j].author); }
|
||||
}
|
||||
}
|
||||
const cvCoefficient = cvComparisons > 0 ? cvSum / cvComparisons : 0;
|
||||
|
||||
const skillSet = new Set();
|
||||
for (const p of people) for (const t of p.terms) skillSet.add(t);
|
||||
|
||||
const termCount = new Map();
|
||||
for (const n of nodes) for (const t of n.terms) termCount.set(t, (termCount.get(t) || 0) + 1);
|
||||
const topTerms = [...termCount.entries()]
|
||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
||||
.slice(0, 12)
|
||||
.map(([term, count]) => ({ term, count }));
|
||||
|
||||
const perKind = {};
|
||||
for (const n of nodes) perKind[n.kind] = (perKind[n.kind] || 0) + 1;
|
||||
|
||||
return {
|
||||
coefficient,
|
||||
percent: Math.round(coefficient * 1000) / 10,
|
||||
comparisons,
|
||||
pairs: connectedPairs,
|
||||
entities: use.length,
|
||||
distinctTerms: termCount.size,
|
||||
topTerms,
|
||||
perKind,
|
||||
people: people.length,
|
||||
skills: skillSet.size,
|
||||
connected: linkedEntities.size,
|
||||
isolated: Math.max(0, use.length - linkedEntities.size),
|
||||
cvCoefficient,
|
||||
cvPercent: Math.round(cvCoefficient * 1000) / 10
|
||||
};
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
@ -130,9 +130,9 @@ module.exports = ({ cooler }) => {
|
|||
return filterInactive(users);
|
||||
}
|
||||
|
||||
if (filter === 'all' || filter === 'TOP KARMA' || filter === 'TOP ACTIVITY' || filter === 'TOP ECO') {
|
||||
if (filter === 'all' || filter === 'TOP KARMA' || filter === 'TOP ACTIVITY' || filter === 'TOP INACTIVITY' || filter === 'TOP ECO') {
|
||||
let users = await listAllBase(ssbClient);
|
||||
if (filter !== 'TOP ACTIVITY') {
|
||||
if (filter !== 'TOP ACTIVITY' && filter !== 'TOP INACTIVITY') {
|
||||
users = filterInactive(users);
|
||||
}
|
||||
if (search) {
|
||||
|
|
@ -170,6 +170,7 @@ module.exports = ({ cooler }) => {
|
|||
}));
|
||||
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;
|
||||
}
|
||||
|
|
@ -254,7 +255,7 @@ module.exports = ({ cooler }) => {
|
|||
);
|
||||
}
|
||||
|
||||
if (filter === 'CVs' || filter === 'MATCHSKILLS') {
|
||||
if (filter === 'CVs') {
|
||||
const records = await new Promise((res, rej) => {
|
||||
pull(
|
||||
ssbClient.createLogStream({ limit: logLimit, reverse: true}),
|
||||
|
|
@ -295,54 +296,13 @@ module.exports = ({ cooler }) => {
|
|||
return out;
|
||||
}
|
||||
|
||||
if (filter === 'MATCHSKILLS') {
|
||||
let base = 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 norm = this._normalizeCurriculum(c, photo);
|
||||
const karmaScore = await getLastKarmaScore(c.author).catch(() => 0);
|
||||
return { ...norm, lastActivityTs, lastActivityBucket: bucket, lastActivityRange: range, karmaScore };
|
||||
}));
|
||||
base = filterInactive(base);
|
||||
const mecv = await this.getCVByUserId();
|
||||
const userSkills = Array.from(new Set(
|
||||
(mecv
|
||||
? [
|
||||
...(mecv.personalSkills || []),
|
||||
...(mecv.oasisSkills || []),
|
||||
...(mecv.educationalSkills || []),
|
||||
...(mecv.professionalSkills || [])
|
||||
]
|
||||
: []).map(s => String(s || '').toLowerCase()).filter(Boolean)
|
||||
));
|
||||
if (!userSkills.length) return [];
|
||||
const userSet = new Set(userSkills);
|
||||
const matches = base.map(c => {
|
||||
if (c.id === userId) return null;
|
||||
const theirSkillsRaw = (c.skills || []).map(s => String(s || '').toLowerCase()).filter(Boolean);
|
||||
const theirSet = new Set(theirSkillsRaw);
|
||||
const common = Array.from(theirSet).filter(s => userSet.has(s));
|
||||
if (!common.length) return null;
|
||||
const unionSize = userSet.size + theirSet.size - common.length;
|
||||
const matchScore = unionSize > 0 ? common.length / unionSize : 0;
|
||||
const matchCoverage = userSet.size > 0 ? common.length / userSet.size : 0;
|
||||
return { ...c, commonSkills: common, matchScore, matchCoverage };
|
||||
}).filter(Boolean);
|
||||
return matches.sort((a, b) =>
|
||||
(b.matchScore - a.matchScore) ||
|
||||
(b.commonSkills.length - a.commonSkills.length) ||
|
||||
((b.karmaScore || 0) - (a.karmaScore || 0)) ||
|
||||
((b.lastActivityTs || 0) - (a.lastActivityTs || 0))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
|
||||
_normalizeCurriculum(c, photoUrl) {
|
||||
const photo = photoUrl || toImageUrl(c.photo, 256);
|
||||
const photo = c.photo ? toImageUrl(c.photo, 256) : (photoUrl || toImageUrl(null, 256));
|
||||
return {
|
||||
id: c.author,
|
||||
name: c.name,
|
||||
|
|
@ -358,6 +318,8 @@ module.exports = ({ cooler }) => {
|
|||
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
|
||||
};
|
||||
},
|
||||
|
|
|
|||
|
|
@ -568,10 +568,14 @@ module.exports = ({ cooler, tribesModel, tribeCrypto }) => {
|
|||
});
|
||||
}
|
||||
|
||||
function wallIsPublic(houseKey, { isGoverning = false } = {}) {
|
||||
return houseKey === 'academia' || isGoverning === true;
|
||||
}
|
||||
|
||||
async function listHousePosts(houseKey, { viewerHouse = null, isGoverning = false } = {}) {
|
||||
if (!VALID_KEY(houseKey)) return [];
|
||||
const viewerIsMember = viewerHouse === houseKey;
|
||||
if (!viewerIsMember && !isGoverning) return [];
|
||||
if (!viewerIsMember && !wallIsPublic(houseKey, { isGoverning })) return [];
|
||||
const client = await openSsb();
|
||||
const memberships = await listAllMemberships();
|
||||
return new Promise((resolve) => {
|
||||
|
|
@ -912,6 +916,7 @@ module.exports = ({ cooler, tribesModel, tribeCrypto }) => {
|
|||
return {
|
||||
HOUSES,
|
||||
HOUSE_KEYS,
|
||||
wallIsPublic,
|
||||
TEST_COOLDOWN_MS,
|
||||
TEST_QUESTIONS_COUNT,
|
||||
PROFILE_QUESTIONS,
|
||||
|
|
|
|||
171
src/models/mentions_model.js
Normal file
171
src/models/mentions_model.js
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
const fs = require('fs');
|
||||
const pull = require('../server/node_modules/pull-stream');
|
||||
|
||||
const SEEN_FILE = 'oasis-mentions-seen';
|
||||
|
||||
const seenPath = () => {
|
||||
try { return require('../server/ssb_config').statePath(SEEN_FILE); } catch (_) { return null; }
|
||||
};
|
||||
|
||||
const MAX_SEEN_KEYS = 500;
|
||||
|
||||
const readSeen = () => {
|
||||
const p = seenPath();
|
||||
if (!p) return { ts: 0, keys: [] };
|
||||
let raw = '';
|
||||
try { raw = fs.readFileSync(p, 'utf8').trim(); } catch (_) { return { ts: 0, keys: [] }; }
|
||||
if (!raw) return { ts: 0, keys: [] };
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
return { ts: Number(parsed.ts) || 0, keys: Array.isArray(parsed.keys) ? parsed.keys : [] };
|
||||
}
|
||||
} catch (_) {}
|
||||
return { ts: Number(raw) || 0, keys: [] };
|
||||
};
|
||||
|
||||
const writeSeen = (keys, ts) => {
|
||||
const p = seenPath();
|
||||
if (!p) return false;
|
||||
const trimmed = (Array.isArray(keys) ? keys : []).filter(Boolean).slice(0, MAX_SEEN_KEYS);
|
||||
try { fs.writeFileSync(p, JSON.stringify({ ts: Number(ts) || Date.now(), keys: trimmed })); return true; } catch (_) { return false; }
|
||||
};
|
||||
const { getConfig } = require('../configs/config-manager.js');
|
||||
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
|
||||
const logLimit = getConfig().ssbLogStream?.limit || 1000;
|
||||
|
||||
const TEXT_FIELDS = ['text', 'description', 'title', 'concept', 'question', 'rules', 'message', 'body', 'subject'];
|
||||
|
||||
const SKIP_TYPES = new Set([
|
||||
'about', 'contact', 'vote', 'pub', 'tombstone', 'karmaScore',
|
||||
'tribe-invite-msg', 'tribe-invite-tombstone', 'tribe-key-distrib'
|
||||
]);
|
||||
|
||||
module.exports = ({ cooler }) => {
|
||||
let ssb;
|
||||
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
|
||||
|
||||
const getAllMessages = async (ssbClient) =>
|
||||
new Promise((resolve, reject) => {
|
||||
pull(
|
||||
ssbClient.createLogStream({ limit: logLimit }),
|
||||
pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs)))
|
||||
);
|
||||
});
|
||||
|
||||
const mentionsList = (content, feedId) => {
|
||||
const m = content && content.mentions;
|
||||
if (!m) return false;
|
||||
const values = Array.isArray(m) ? m : (typeof m === 'object' ? Object.values(m).flat() : []);
|
||||
return values.some(v => v && (v.link === feedId || v === feedId));
|
||||
};
|
||||
|
||||
const mentionsText = (content, feedId) => {
|
||||
const bare = feedId.startsWith('@') ? feedId.slice(1) : feedId;
|
||||
for (const field of TEXT_FIELDS) {
|
||||
const v = content[field];
|
||||
if (typeof v === 'string' && (v.includes(feedId) || v.includes(bare))) return true;
|
||||
}
|
||||
if (Array.isArray(content.tags)) {
|
||||
if (content.tags.some(t => typeof t === 'string' && (t.includes(feedId) || t.includes(bare)))) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const snippet = (content) => {
|
||||
for (const field of TEXT_FIELDS) {
|
||||
const v = content[field];
|
||||
if (typeof v === 'string' && v.trim()) return v.trim();
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const titleOf = (content) => {
|
||||
for (const field of ['title', 'concept', 'question', 'subject']) {
|
||||
const v = content[field];
|
||||
if (typeof v === 'string' && v.trim()) return v.trim();
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
return {
|
||||
async listMentions(filter = 'ALL', opts = {}) {
|
||||
const ssbClient = await openSsb();
|
||||
const myFeedId = ssbClient.id;
|
||||
const messages = await getAllMessages(ssbClient);
|
||||
const tomb = buildValidatedTombstoneSet(messages);
|
||||
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
const replaced = new Set();
|
||||
for (const m of messages) {
|
||||
const v = m && m.value;
|
||||
const c = v && v.content;
|
||||
if (!c || typeof c !== 'object' || !c.type) continue;
|
||||
if (tomb.has(m.key)) continue;
|
||||
if (SKIP_TYPES.has(c.type)) continue;
|
||||
if (c.encryptedPayload || c.encryptedQuestion) continue;
|
||||
if (Array.isArray(c.recps) || Array.isArray(v.content && v.content.recps)) continue;
|
||||
if (c.tribeId && c.type !== 'tribe') continue;
|
||||
if (String(v.author) === String(myFeedId)) continue;
|
||||
if (typeof c.replaces === 'string') replaced.add(c.replaces);
|
||||
if (!mentionsList(c, myFeedId) && !mentionsText(c, myFeedId)) continue;
|
||||
if (seen.has(m.key)) continue;
|
||||
seen.add(m.key);
|
||||
out.push({
|
||||
id: m.key,
|
||||
key: m.key,
|
||||
author: v.author,
|
||||
type: c.type,
|
||||
title: titleOf(c),
|
||||
text: snippet(c),
|
||||
content: c,
|
||||
createdAt: new Date(v.timestamp || m.timestamp || 0).toISOString(),
|
||||
ts: v.timestamp || m.timestamp || 0
|
||||
});
|
||||
}
|
||||
|
||||
const fresh = out.filter(x => !replaced.has(x.key));
|
||||
const q = String(opts.q || '').trim().toLowerCase();
|
||||
let list = q
|
||||
? fresh.filter(x => x.text.toLowerCase().includes(q) || x.title.toLowerCase().includes(q) || x.type.toLowerCase().includes(q))
|
||||
: fresh;
|
||||
|
||||
const f = String(filter || 'ALL').toUpperCase();
|
||||
if (f !== 'ALL' && f !== 'RECENT') list = list.filter(x => x.type === filter);
|
||||
|
||||
list.sort((a, b) => b.ts - a.ts);
|
||||
return list;
|
||||
},
|
||||
|
||||
seenAt: () => readSeen().ts,
|
||||
|
||||
markSeen(list) {
|
||||
const items = Array.isArray(list) ? list : [];
|
||||
const keys = items.map(x => x && x.key).filter(Boolean);
|
||||
const newest = items.reduce((max, x) => Math.max(max, Number(x && x.ts) || 0), 0);
|
||||
return writeSeen(keys, newest || Date.now());
|
||||
},
|
||||
|
||||
unseenOf(list) {
|
||||
const seen = readSeen();
|
||||
const known = new Set(seen.keys);
|
||||
const items = Array.isArray(list) ? list : [];
|
||||
if (known.size) return items.filter(x => x && !known.has(x.key));
|
||||
if (seen.ts) return items.filter(x => x && (Number(x.ts) || 0) > seen.ts);
|
||||
return items;
|
||||
},
|
||||
|
||||
async countUnseen() {
|
||||
return this.unseenOf(await this.listMentions('ALL')).length;
|
||||
},
|
||||
|
||||
async countTypes(list) {
|
||||
const counts = {};
|
||||
for (const item of (Array.isArray(list) ? list : [])) {
|
||||
counts[item.type] = (counts[item.type] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
@ -546,7 +546,7 @@ module.exports = ({ cooler }) => {
|
|||
return publishReplace(ssbClient, project.id, patch)
|
||||
},
|
||||
|
||||
async listProjects(filter) {
|
||||
async listProjects(filter, opts = {}) {
|
||||
const ssbClient = await openSsb()
|
||||
const currentUserId = ssbClient.id
|
||||
const idx = buildProjectIndex(await getAllMsgs(ssbClient))
|
||||
|
|
@ -580,6 +580,15 @@ module.exports = ({ cooler }) => {
|
|||
else if (F === "RECENT") list = list.filter((p) => p && moment(p.createdAt).isAfter(moment().subtract(24, "hours")))
|
||||
else if (F === "FOLLOWING") list = list.filter((p) => Array.isArray(p.followers) && p.followers.includes(currentUserId))
|
||||
|
||||
const q = norm(opts.q)
|
||||
if (q) {
|
||||
list = list.filter((p) =>
|
||||
norm(p.title).includes(q) ||
|
||||
norm(p.description).includes(q) ||
|
||||
(Array.isArray(p.tags) && p.tags.some(t => norm(t).includes(q)))
|
||||
)
|
||||
}
|
||||
|
||||
if (F === "TOP") {
|
||||
list.sort((a, b) => (parseFloat(b.pledged || 0) / (parseFloat(b.goal || 1))) - (parseFloat(a.pledged || 0) / (parseFloat(a.goal || 1))))
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ module.exports = ({ cooler, padsModel, tribeCrypto, tribesModel }) => {
|
|||
'post', 'about', 'curriculum', 'tribe', 'transfer', 'feed',
|
||||
'votes', 'report', 'task', 'event', 'bookmark', 'document',
|
||||
'image', 'audio', 'video', 'torrent', 'market', 'bankWallet', 'bankClaim',
|
||||
'project', 'job', 'industry', 'industryBlueprint', 'forum', 'vote', 'contact', 'pub', 'map', 'shop', 'shopProduct', 'chat', 'pad'
|
||||
'project', 'job', 'industry', 'industryBlueprint', 'forum', 'vote', 'contact', 'pub', 'map', 'shop', 'shopProduct', 'chat', 'pad', 'poll'
|
||||
];
|
||||
|
||||
const getRelevantFields = (type, content) => {
|
||||
|
|
@ -60,7 +60,7 @@ module.exports = ({ cooler, padsModel, tribeCrypto, tribesModel }) => {
|
|||
case 'audio':
|
||||
return [content?.url, content?.mimeType, content?.title, content?.description, ...(content?.tags || [])];
|
||||
case 'image':
|
||||
return [content?.url, content?.title, content?.description, ...(content?.tags || []), content?.meme];
|
||||
return [content?.url, content?.title, content?.description, ...(content?.tags || [])];
|
||||
case 'video':
|
||||
return [content?.url, content?.mimeType, content?.title, content?.description, ...(content?.tags || [])];
|
||||
case 'document':
|
||||
|
|
@ -85,6 +85,12 @@ module.exports = ({ cooler, padsModel, tribeCrypto, tribesModel }) => {
|
|||
return [content?.amount, content?.epochId, content?.allocationId, content?.txid];
|
||||
case 'project':
|
||||
return [content?.title, content?.status, content?.progress, content?.goal, content?.pledged, content?.deadline, (content?.followers || []).length, (content?.backers || []).length, (content?.milestones || []).length, content?.bounty, content?.bountyAmount, content?.bounty_currency, content?.activity?.kind, content?.activityActor];
|
||||
case 'poll':
|
||||
return content?.encryptedQuestion
|
||||
? []
|
||||
: [content?.question, ...(content?.options || []), ...(content?.tags || [])];
|
||||
case 'housing':
|
||||
return [content?.title, content?.housing_type, content?.property_type, content?.place, content?.price, content?.rooms, content?.size, content?.capacity, content?.status];
|
||||
case 'job':
|
||||
return [content?.title, content?.job_type, ...(content?.tasks || []), content?.location, content?.vacants, content?.salary, content?.status, (content?.subscribers || []).length];
|
||||
case 'industry':
|
||||
|
|
@ -232,6 +238,17 @@ module.exports = ({ cooler, padsModel, tribeCrypto, tribesModel }) => {
|
|||
].join('|');
|
||||
}
|
||||
|
||||
if (t === 'housing') {
|
||||
return [
|
||||
'housing',
|
||||
author,
|
||||
norm(c.title),
|
||||
norm(c.place),
|
||||
norm(c.price),
|
||||
norm(c.housing_type)
|
||||
].join('|');
|
||||
}
|
||||
|
||||
if (t === 'job') {
|
||||
return [
|
||||
'job',
|
||||
|
|
|
|||
|
|
@ -13,7 +13,10 @@ module.exports = ({ cooler, padsModel, tribesModel }) => {
|
|||
const norm = (v) => String(v == null ? '' : v).trim().toLowerCase();
|
||||
|
||||
const normalizeTag = (tag) => String(tag == null ? '' : tag).trim().replace(/^#/, '');
|
||||
const tagKey = (tag) => normalizeTag(tag).toLowerCase();
|
||||
const tagKey = (tag) => {
|
||||
const k = normalizeTag(tag).toLowerCase();
|
||||
return k.includes(':') ? '' : k;
|
||||
};
|
||||
|
||||
const getDedupeKey = (msg) => {
|
||||
const c = msg?.value?.content || {};
|
||||
|
|
@ -102,7 +105,7 @@ module.exports = ({ cooler, padsModel, tribesModel }) => {
|
|||
};
|
||||
|
||||
return {
|
||||
async listTags(filter = 'all') {
|
||||
async listTags(filter = 'all', search = '') {
|
||||
const ssbClient = await openSsb();
|
||||
|
||||
const messages = await new Promise((resolve, reject) => {
|
||||
|
|
@ -158,21 +161,34 @@ module.exports = ({ cooler, padsModel, tribesModel }) => {
|
|||
if (c.type === 'task' && String(c.isPublic).toUpperCase() === 'PRIVATE') return false;
|
||||
if ((c.type === 'chat' || c.type === 'pad' || c.type === 'map' || c.type === 'calendar') && c.status === 'INVITE-ONLY' && c.author !== viewerId && !(Array.isArray(c.members) && c.members.includes(viewerId))) return false;
|
||||
if (c.type === 'shop' && c.visibility === 'CLOSED') return false;
|
||||
if (c.type === 'poll' && c.chatId) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
filtered = dedupeKeepLatest(filtered);
|
||||
|
||||
const counts = new Map();
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
const bump = (k, display, { mine, ts }) => {
|
||||
const prev = counts.get(k);
|
||||
if (!prev) counts.set(k, { name: display, count: 1, mine: mine ? 1 : 0, lastTs: ts || 0 });
|
||||
else counts.set(k, {
|
||||
name: prev.name || display,
|
||||
count: prev.count + 1,
|
||||
mine: prev.mine + (mine ? 1 : 0),
|
||||
lastTs: Math.max(prev.lastTs || 0, ts || 0)
|
||||
});
|
||||
};
|
||||
|
||||
for (const record of filtered) {
|
||||
const tagsArr = record?.value?.content?.tags || [];
|
||||
const uniqueTags = new Set(tagsArr.map(tagKey).filter(Boolean));
|
||||
const mine = String(record?.value?.author || '') === String(viewerId);
|
||||
const ts = Number(record?.value?.timestamp || record?.timestamp || 0);
|
||||
for (const k of uniqueTags) {
|
||||
const display = normalizeTag(tagsArr.find(t => tagKey(t) === k) || k) || k;
|
||||
const prev = counts.get(k);
|
||||
if (!prev) counts.set(k, { name: display, count: 1 });
|
||||
else counts.set(k, { name: prev.name || display, count: prev.count + 1 });
|
||||
bump(k, display, { mine, ts });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -186,16 +202,22 @@ module.exports = ({ cooler, padsModel, tribesModel }) => {
|
|||
const uniquePadTags = new Set(pad.tags.map(tagKey).filter(Boolean));
|
||||
for (const k of uniquePadTags) {
|
||||
const display = normalizeTag(pad.tags.find(t => tagKey(t) === k) || k) || k;
|
||||
const prev = counts.get(k);
|
||||
if (!prev) counts.set(k, { name: display, count: 1 });
|
||||
else counts.set(k, { name: prev.name || display, count: prev.count + 1 });
|
||||
bump(k, display, { mine: String(pad.author || '') === String(viewerId), ts: Number(pad.createdAt ? new Date(pad.createdAt).getTime() : 0) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let tags = Array.from(counts.values());
|
||||
|
||||
if (filter === 'top') {
|
||||
const q = String(search || '').trim().toLowerCase().replace(/^#/, '');
|
||||
if (q) tags = tags.filter(t => String(t.name || '').toLowerCase().includes(q));
|
||||
|
||||
if (filter === 'mine') {
|
||||
tags = tags.filter(t => (t.mine || 0) > 0).sort((a, b) => b.mine - a.mine || a.name.localeCompare(b.name));
|
||||
} else if (filter === 'recent') {
|
||||
const since = Date.now() - 7 * DAY;
|
||||
tags = tags.filter(t => (t.lastTs || 0) >= since).sort((a, b) => (b.lastTs || 0) - (a.lastTs || 0));
|
||||
} else if (filter === 'top') {
|
||||
tags.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
|
||||
} else if (filter === 'cloud') {
|
||||
const max = Math.max(...tags.map(t => t.count), 1);
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ module.exports = ({ cooler }) => {
|
|||
};
|
||||
|
||||
const types = [
|
||||
'bookmark', 'votes', 'feed',
|
||||
'bookmark', 'votes', 'poll', 'feed',
|
||||
'image', 'audio', 'video', 'document', 'torrent', 'transfer',
|
||||
'industry', 'project', 'report', 'task', 'event', 'shopProduct'
|
||||
];
|
||||
|
|
@ -141,7 +141,8 @@ module.exports = ({ cooler }) => {
|
|||
case 'feed':
|
||||
return `feed::${(c.text || '').replace(/\s+/g, ' ').trim()}`;
|
||||
case 'votes':
|
||||
return `votes::${(c.question || '').replace(/\s+/g, ' ').trim()}`;
|
||||
case 'poll':
|
||||
return `${c.type}::${(c.question || '').replace(/\s+/g, ' ').trim()}`;
|
||||
case 'transfer':
|
||||
return `transfer::${(c.concept || '')}|${c.amount || ''}|${c.from || ''}|${c.to || ''}|${c.deadline || ''}`;
|
||||
default:
|
||||
|
|
|
|||
104
src/models/workflows_model.js
Normal file
104
src/models/workflows_model.js
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
const ALL_MODULES = [
|
||||
'agenda', 'ai', 'aiNav', 'audios', 'banking', 'blogs', 'bookmarks', 'calendars', 'chats', 'cipher',
|
||||
'courts', 'dev', 'docs', 'events', 'favorites', 'fediverse', 'feed', 'forum', 'games', 'graphos',
|
||||
'housing', 'images', 'industry', 'invites', 'jobs', 'larp', 'legacy', 'logs', 'maps', 'market',
|
||||
'melody', 'opinions', 'pads', 'parliament', 'pixelia', 'polls', 'projects', 'reports', 'shops',
|
||||
'tags', 'tasks', 'torrents', 'transfers', 'trending', 'tribes', 'videos', 'votes', 'wallet'
|
||||
];
|
||||
|
||||
const NETWORK = ['feed', 'blogs', 'tags', 'trending', 'opinions', 'pads', 'forum', 'maps', 'chats'];
|
||||
const MEDIA = ['audios', 'bookmarks', 'docs', 'images', 'torrents', 'videos'];
|
||||
const OFFICE = ['agenda', 'calendars', 'events', 'tasks', 'reports', 'favorites'];
|
||||
const GOVERNANCE = ['tribes', 'larp', 'votes', 'polls', 'parliament', 'courts', 'logs'];
|
||||
const ECONOMY = ['banking', 'wallet', 'transfers', 'market', 'housing', 'jobs', 'shops', 'industry', 'projects'];
|
||||
|
||||
const MOBILE_MODULES = [
|
||||
'agenda', 'favorites', 'wallet', 'tribes', 'larp', 'votes', 'polls', 'events', 'calendars', 'tasks',
|
||||
'reports', 'banking', 'market', 'housing', 'jobs', 'shops', 'transfers', 'cipher', 'invites',
|
||||
'games', 'audios', 'bookmarks', 'docs', 'images', ...NETWORK
|
||||
];
|
||||
|
||||
const WORKFLOWS = [
|
||||
{
|
||||
key: 'default',
|
||||
theme: 'Dark-SNH',
|
||||
homePage: 'activity',
|
||||
modules: ALL_MODULES.filter(m => m !== 'aiNav')
|
||||
},
|
||||
{
|
||||
key: 'jobs',
|
||||
theme: 'Clear-SNH',
|
||||
homePage: 'activity',
|
||||
modules: [...OFFICE, 'jobs', 'projects', 'industry', 'market', 'shops', 'banking', 'wallet', 'transfers',
|
||||
'forum', 'chats', 'pads', 'feed', 'tags', 'trending', 'docs', 'invites']
|
||||
},
|
||||
{
|
||||
key: 'ruling',
|
||||
theme: 'Purple-SNH',
|
||||
homePage: 'activity',
|
||||
modules: [...GOVERNANCE, ...OFFICE, 'forum', 'pads', 'chats', 'feed', 'blogs', 'opinions', 'tags', 'trending', 'docs', 'invites']
|
||||
},
|
||||
{
|
||||
key: 'politics',
|
||||
theme: 'Dark-SNH',
|
||||
homePage: 'trending',
|
||||
modules: ['parliament', 'courts', 'votes', 'polls', 'reports', 'logs', 'forum', 'opinions', 'blogs',
|
||||
'feed', 'tags', 'trending', 'pads', 'chats', 'fediverse', 'tribes', 'events', 'agenda']
|
||||
},
|
||||
{
|
||||
key: 'social',
|
||||
theme: 'Clear-SNH',
|
||||
homePage: 'feed',
|
||||
modules: [...NETWORK, ...MEDIA, 'events', 'calendars', 'agenda', 'favorites', 'games', 'pixelia',
|
||||
'melody', 'tribes', 'fediverse', 'invites', 'polls']
|
||||
},
|
||||
{
|
||||
key: 'business',
|
||||
theme: 'Dark-SNH',
|
||||
homePage: 'activity',
|
||||
modules: [...ECONOMY, ...OFFICE, 'forum', 'chats', 'pads', 'docs', 'feed', 'tags', 'trending', 'invites', 'cipher']
|
||||
},
|
||||
{
|
||||
key: 'night',
|
||||
theme: 'Matrix-SNH',
|
||||
homePage: 'feed',
|
||||
modules: ['feed', 'blogs', 'opinions', 'chats', 'forum', 'tags', 'trending', 'audios', 'videos',
|
||||
'images', 'bookmarks', 'melody', 'games', 'pixelia', 'favorites']
|
||||
},
|
||||
{
|
||||
key: 'mobile',
|
||||
theme: 'OasisMobile',
|
||||
homePage: 'activity',
|
||||
modules: MOBILE_MODULES
|
||||
}
|
||||
];
|
||||
|
||||
const WORKFLOW_KEYS = WORKFLOWS.map(w => w.key);
|
||||
|
||||
const getWorkflow = (key) => WORKFLOWS.find(w => w.key === String(key || '')) || null;
|
||||
|
||||
const modulesOf = (workflow) => {
|
||||
const set = new Set(workflow.modules.filter(m => ALL_MODULES.includes(m)));
|
||||
return ALL_MODULES.filter(m => set.has(m));
|
||||
};
|
||||
|
||||
const currentWorkflow = (config) => {
|
||||
const modules = (config && config.modules) || {};
|
||||
const theme = (config && config.themes && config.themes.current) || '';
|
||||
return WORKFLOW_KEYS.find(key => {
|
||||
const w = getWorkflow(key);
|
||||
if (theme !== w.theme) return false;
|
||||
const on = new Set(modulesOf(w));
|
||||
return ALL_MODULES.every(m => (modules[`${m}Mod`] === 'on') === on.has(m));
|
||||
}) || null;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
ALL_MODULES,
|
||||
MOBILE_MODULES,
|
||||
WORKFLOWS,
|
||||
WORKFLOW_KEYS,
|
||||
getWorkflow,
|
||||
modulesOf,
|
||||
currentWorkflow
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue