upstream: integrar 0.9.5 en modelos y vistas medianas

chats_model: se adopta 0.9.5 y se reinyecta el replyTo de la rama (responder a
mensajes). El fix del salt en joinByInvite ya venia en upstream, y el limite de
mensajes pasa a ser configurable (60/h) en vez del parche que lo saltaba en movil.

config-manager: version de 0.9.5 con karvanMod, los seis modulos de public y toda
la normalizacion de bottomBarPins reinyectados; housingMod fuera. Entran blogsMod
y pollsMod del dev.

Se adopta 0.9.5 en votes_model (ya trae el margen de 2 minutos de la rama y anade
codigos de error), blobHandler (superconjunto compatible: mantiene handleBlobUpload
y anade subida multiple y deteccion de OGG), market_model (misma refactorizacion
mas opiniones y compradores), agenda_model y agenda_view (el codigo de 0.9.5 ya
degrada solo sin housingModel), opinions_view, cv_view y trending_view.

peers_view y parliament_view se quedan como estan: sus celdas llevan data-label,
que es lo que OasisMobile.css usa para convertir las tablas en tarjetas en movil.

Verificado con la app en marcha: 20 rutas OK, interfaz intacta (10 categorias, 47
modulos, 7 accesos rapidos, 0 etiquetas vacias) y Karvan completo de punta a punta
— crear sala, enviar y leer mensajes, mailbox de senalizacion WebRTC con miembros,
y la pagina de sala sirviendo karvan.js con el panel de videollamada.
This commit is contained in:
SITO 2026-08-18 22:38:09 +02:00
parent 82b6c3ae05
commit c662e37668
10 changed files with 310 additions and 40 deletions

View file

@ -19,7 +19,7 @@ function writeAgendaConfig(cfg) {
fs.writeFileSync(agendaConfigPath, JSON.stringify(cfg, null, 2));
}
module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel, jobsModel, projectsModel, industryModel }) => {
module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel, jobsModel, projectsModel, industryModel, housingModel }) => {
let ssb;
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
@ -188,7 +188,11 @@ module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel
? industryModel.listMyBuilds().then(normalize).catch(() => [])
: [];
const [tasksAll, eventsAll, transfersAll, tribesAll, marketAll, reportsAll, jobsAll, projectsAll, calendarsAll, industryAll] = await Promise.all([
const housingViaModel = housingModel && typeof housingModel.listHousing === 'function'
? housingModel.listHousing('ALL', userId).then(normalize).catch(() => [])
: [];
const [tasksAll, eventsAll, transfersAll, tribesAll, marketAll, reportsAll, jobsAll, projectsAll, calendarsAll, industryAll, housingAll] = await Promise.all([
tasksViaModel,
eventsViaModel,
fetchItems('transfer'),
@ -198,7 +202,8 @@ module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel
jobsViaModel,
projectsViaModel,
calendarsViaModel,
industryViaModel
industryViaModel,
housingViaModel
]);
const tasks = tasksAll.filter(c => Array.isArray(c.assignees) && c.assignees.includes(userId)).map(t => ({ ...t, type: 'task' }));
@ -212,6 +217,9 @@ module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel
const jobs = jobsAll.filter(c => c.author === userId || (Array.isArray(c.subscribers) && c.subscribers.includes(userId))).map(j => ({ ...j, type: 'job', title: j.title }));
const projects = projectsAll.map(p => ({ ...p, type: 'project' }));
const industryBuilds = industryAll.map(b => ({ ...b, type: 'industry', title: b.title }));
const housingPlaces = housingAll
.filter(h => h.author === userId || (Array.isArray(h.requests) && h.requests.includes(userId)))
.map(h => ({ ...h, type: 'housing', date: h.availableFrom || h.createdAt, requested: Array.isArray(h.requests) && h.requests.includes(userId) }));
const myCalendars = calendarsAll
.filter(c => c.author === userId || (Array.isArray(c.participants) && c.participants.includes(userId)));
const calendars = myCalendars.map(c => ({ ...c, type: 'calendar' }));
@ -248,6 +256,7 @@ module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel
...jobs,
...projects,
...industryBuilds,
...housingPlaces,
...calendars,
...calendarDates
];
@ -268,6 +277,7 @@ module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel
else if (filter === 'jobs') filtered = filtered.filter(i => i.type === 'job');
else if (filter === 'projects') filtered = filtered.filter(i => i.type === 'project');
else if (filter === 'industry') filtered = filtered.filter(i => i.type === 'industry');
else if (filter === 'housing') filtered = filtered.filter(i => i.type === 'housing');
else if (filter === 'calendars') filtered = filtered.filter(i => i.type === 'calendar' || i.type === 'calendarDate');
else if (filter === 'today') {
const startOfDay = new Date(); startOfDay.setHours(0, 0, 0, 0);
@ -322,6 +332,7 @@ module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel
jobs: mainItems.filter(i => i.type === 'job').length,
projects: mainItems.filter(i => i.type === 'project').length,
industry: mainItems.filter(i => i.type === 'industry').length,
housing: mainItems.filter(i => i.type === 'housing').length,
calendars: mainItems.filter(i => i.type === 'calendar' || i.type === 'calendarDate').length,
today: mainItems.filter(i => { const d = itemTs(i); return d >= startOfDay.getTime() && d <= endOfDay.getTime(); }).length,
upcoming: mainItems.filter(i => itemTs(i) > now).length,

View file

@ -17,10 +17,22 @@ const normalizeTags = (raw) => {
const INVITE_CODE_BYTES = 16
const VALID_STATUS = ["OPEN", "INVITE-ONLY", "CLOSED"]
const DEFAULT_MESSAGES_PER_HOUR = 60
module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
let ssb
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
const MESSAGES_PER_HOUR = (() => {
try {
const raw = getConfig()?.chats?.messagesPerHour
const n = parseInt(raw, 10)
return Number.isFinite(n) && n > 0 ? n : DEFAULT_MESSAGES_PER_HOUR
} catch (_) {
return DEFAULT_MESSAGES_PER_HOUR
}
})()
const ownCrypto = chatCrypto || tribeCrypto
const lookupKey = (rid) => (ownCrypto && ownCrypto.getKey(rid)) || (tribeCrypto && tribeCrypto.getKey(rid)) || null
const lookupKeys = (rid) => {
@ -265,6 +277,7 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
invites,
author: c.author || node.author,
createdAt: c.createdAt || new Date(node.ts).toISOString(),
replyTo: c.replyTo || null,
updatedAt: c.updatedAt || null,
encrypted: !!c.encrypted,
tribeId: c.tribeId || null,
@ -293,8 +306,7 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
text,
image: c.image || null,
author: c.author || node.author,
createdAt: c.createdAt || new Date(node.ts).toISOString(),
replyTo: c.replyTo || null
createdAt: c.createdAt || new Date(node.ts).toISOString()
}
}
@ -358,6 +370,15 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
return {
type: "chat",
async encryptionKeyFor(chatRootId, tribeId = null) {
if (!tribeCrypto) return null
if (tribeId) {
const k = await getTribeFirstKeyFor(tribeId)
if (k) return k
}
return lookupKey(chatRootId) || null
},
async resolveRootId(id) {
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
@ -597,6 +618,18 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
let list = chatCollab.visibleThenCollapsed(collectChats(idx), uid)
const lastMsgAt = new Map()
const lastMineAt = new Map()
for (const node of idx.msgNodes.values()) {
const cid = node.c && node.c.chatId
if (!cid) continue
const r = idx.rawRootOf(cid) || cid
const t = node.ts || 0
if (t > (lastMsgAt.get(r) || 0)) lastMsgAt.set(r, t)
if (node.author === uid && t > (lastMineAt.get(r) || 0)) lastMineAt.set(r, t)
}
list = list.map(c => ({ ...c, lastMsgAt: lastMsgAt.get(c.rootId) || 0, lastMineAt: lastMineAt.get(c.rootId) || 0 }))
if (filter === "mine") list = list.filter(c => c.author === uid)
else if (filter === "recent") list = list.filter(c => new Date(c.createdAt).getTime() >= now - 86400000)
else if (filter === "open") list = list.filter(c => c.status === "OPEN" || c.status === "INVITE-ONLY")
@ -707,7 +740,7 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
let chatKey = null
if (tribeCrypto && typeof matchedInvite === "object") {
if (matchedInvite.ekChain) {
const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code, matchedInvite.salt)
const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code, matchedInvite.salt, 3)
if (Array.isArray(chain) && chain.length) {
for (const entry of chain) {
if (Array.isArray(entry.keys) && entry.keys.length) {
@ -809,7 +842,12 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
const c = m.value?.content
return c?.type === "chatMessage" && c?.chatId === chat.rootId && m.value?.author === userId && (m.value?.timestamp || 0) >= oneHourAgo
}).length
if (process.env.OASIS_MOBILE !== '1' && recentCount >= 3) throw new Error("Rate limit: max 3 messages per hour")
if (recentCount >= MESSAGES_PER_HOUR) {
const err = new Error(`Rate limit: max ${MESSAGES_PER_HOUR} messages per hour`)
err.code = "CHAT_RATE_LIMIT"
err.retryAfterMinutes = 60
throw err
}
const now = new Date().toISOString()
let content = {

View file

@ -1,6 +1,7 @@
const pull = require("../server/node_modules/pull-stream")
const moment = require("../server/node_modules/moment")
const { getConfig } = require("../configs/config-manager.js")
const categories = require("../backend/opinion_categories")
const { buildValidatedTombstoneSet } = require('./tombstone_validator')
const { dedupeByPreferring, norm } = require('../backend/dedupe')
const logLimit = getConfig().ssbLogStream?.limit || 1000
@ -73,12 +74,14 @@ module.exports = ({ cooler, tribeCrypto }) => {
const nodes = new Map()
const bids = []
const purchases = []
const opinionMsgs = []
for (const m of messages) {
const c = m.value && m.value.content
if (!c) continue
if (c.type === "tombstone") continue
if (c.type === "marketBid") { bids.push({ target: c.target, author: m.value.author, amount: c.amount, time: c.time, ts: (m.value && m.value.timestamp) || 0 }); continue }
if (c.type === "marketPurchase") { purchases.push({ target: c.target, author: m.value.author, ts: (m.value && m.value.timestamp) || 0 }); continue }
if (c.type === "marketOpinion" && c.target) { opinionMsgs.push({ target: c.target, author: m.value.author, category: c.category }); continue }
if (c.type !== "market") continue
nodes.set(m.key, { key: m.key, ts: (m.value && m.value.timestamp) || m.timestamp || 0, c, author: m.value.author })
}
@ -95,11 +98,11 @@ module.exports = ({ cooler, tribeCrypto }) => {
if (!cn || cn.author !== pn.author) { naivePrev.delete(child); nodes.delete(child); continue }
strictNext.set(parent, child)
}
return { tomb, nodes, bids, purchases, naivePrev, strictNext }
return { tomb, nodes, bids, purchases, opinionMsgs, naivePrev, strictNext }
}
const resolveGroups = (idx) => {
const { tomb, nodes, bids, purchases, naivePrev, strictNext } = idx
const { tomb, nodes, bids, purchases, opinionMsgs, naivePrev, strictNext } = idx
const rootOf = (key) => { let x = key, g = 0; while (naivePrev.has(x) && nodes.has(naivePrev.get(x)) && g++ < 100000) x = naivePrev.get(x); return x }
const followStrict = (key) => { let x = key, g = 0; while (strictNext.has(x) && g++ < 100000) x = strictNext.get(x); return x }
@ -108,7 +111,21 @@ module.exports = ({ cooler, tribeCrypto }) => {
const bidsByRoot = new Map()
for (const bd of bids) { if (!nodes.has(bd.target)) continue; const r = rootOf(bd.target); if (!bidsByRoot.has(r)) bidsByRoot.set(r, []); bidsByRoot.get(r).push(bd) }
const soldByRoot = new Map()
for (const pu of purchases) { if (!nodes.has(pu.target)) continue; const r = rootOf(pu.target); soldByRoot.set(r, (soldByRoot.get(r) || 0) + 1) }
const buyersByRoot = new Map()
for (const pu of purchases) {
if (!nodes.has(pu.target)) continue
const r = rootOf(pu.target)
soldByRoot.set(r, (soldByRoot.get(r) || 0) + 1)
if (!buyersByRoot.has(r)) buyersByRoot.set(r, new Set())
buyersByRoot.get(r).add(pu.author)
}
const opinionsByRoot = new Map()
for (const op of (opinionMsgs || [])) {
if (!nodes.has(op.target)) continue
const r = rootOf(op.target)
if (!opinionsByRoot.has(r)) opinionsByRoot.set(r, [])
opinionsByRoot.get(r).push(op)
}
const out = new Map()
for (const [root, keys] of groups) {
@ -131,7 +148,16 @@ module.exports = ({ cooler, tribeCrypto }) => {
const addLine = (line) => { const b = parseBidEntry(line); if (!b) return; const kk = `${b.bidder}|${b.amount}|${b.time}`; if (pollSet.has(kk)) return; pollSet.add(kk); poll.push(line) }
for (const k of (sellerKeys.length ? sellerKeys : keys)) { const n = nodes.get(k); if (n && Array.isArray(n.c.auctions_poll)) for (const line of n.c.auctions_poll) addLine(line) }
for (const bd of (bidsByRoot.get(root) || [])) { const amt = Number(bd.amount); addLine(`${bd.author}|${Number.isFinite(amt) ? amt.toFixed(6) : bd.amount}|${bd.time}`) }
out.set(root, { tip, rootId: root, best, statusN: bestS, poll, soldCount: soldByRoot.get(root) || 0 })
const opinions = {}
const voters = []
for (const op of (opinionsByRoot.get(root) || [])) {
if (!op.author || op.author === sellerId) continue
if (voters.includes(op.author)) continue
if (!categories.includes(op.category)) continue
voters.push(op.author)
opinions[op.category] = (opinions[op.category] || 0) + 1
}
out.set(root, { tip, rootId: root, best, statusN: bestS, poll, soldCount: soldByRoot.get(root) || 0, buyers: Array.from(buyersByRoot.get(root) || []), opinions, voters })
}
return out
}
@ -228,9 +254,13 @@ module.exports = ({ cooler, tribeCrypto }) => {
normalized.price = p.toFixed(6)
}
const currentItem = await new Promise((resolve) => ssbClient.get(tipId, (e, m) => resolve(e ? null : (m && m.content) || null)))
if (normalized.deadline !== undefined && normalized.deadline !== null && normalized.deadline !== "") {
const dl = moment(normalized.deadline, moment.ISO_8601, true)
if (!dl.isValid()) throw new Error("Invalid deadline")
const changed = !currentItem || !currentItem.deadline || !moment(currentItem.deadline).isSame(dl)
if (changed && dl.isBefore(moment(), "minute")) throw new Error("The deadline cannot be in the past")
normalized.deadline = dl.toISOString()
}
@ -298,7 +328,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
const items = []
const now = moment()
for (const { tip, rootId, best, statusN, poll, soldCount } of resolveGroups(buildMarketIndex(messages)).values()) {
for (const { tip, rootId, best, statusN, poll, soldCount, buyers, opinions, voters } of resolveGroups(buildMarketIndex(messages)).values()) {
const leaf = tip
const c = best.c
let status = D(statusN)
@ -346,7 +376,11 @@ module.exports = ({ cooler, tribeCrypto }) => {
shopProductId: c.shopProductId || "",
shopId: c.shopId || "",
shopTitle: c.shopTitle || "",
industry: c.industry || ""
industry: c.industry || "",
opinions: opinions || {},
opinions_inhabitants: voters || [],
purchasedByViewer: Array.isArray(buyers) && buyers.includes(userId),
ratedByViewer: Array.isArray(voters) && voters.includes(userId)
})
}
@ -401,7 +435,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
const rootOf = (key) => { let x = key, g = 0; while (idx.naivePrev.has(x) && idx.nodes.has(idx.naivePrev.get(x)) && g++ < 100000) x = idx.naivePrev.get(x); return x }
const grp = resolveGroups(idx).get(rootOf(itemId))
if (!grp) return null
const { tip, rootId, best, statusN, poll, soldCount } = grp
const { tip, rootId, best, statusN, poll, soldCount, buyers, opinions, voters } = grp
const c = best.c
let status = D(statusN)
@ -448,7 +482,11 @@ module.exports = ({ cooler, tribeCrypto }) => {
shopProductId: c.shopProductId || "",
shopId: c.shopId || "",
shopTitle: c.shopTitle || "",
industry: c.industry || ""
industry: c.industry || "",
opinions: opinions || {},
opinions_inhabitants: voters || [],
purchasedByViewer: Array.isArray(buyers) && buyers.includes(userId),
ratedByViewer: Array.isArray(voters) && voters.includes(userId)
}
},
@ -489,6 +527,24 @@ module.exports = ({ cooler, tribeCrypto }) => {
return items.find((i) => i.shopProductId === shopProductId) || null
},
async createOpinion(itemId, category) {
if (!categories.includes(category)) throw new Error("Invalid category")
const ssbClient = await openSsb()
const userId = ssbClient.id
const messages = await readAll(ssbClient)
const groups = resolveGroups(buildMarketIndex(messages))
let group = null
for (const g of groups.values()) {
if (g.rootId === itemId || g.tip === itemId) { group = g; break }
}
if (!group) throw new Error("Item not found")
if (group.best.author === userId) throw new Error("You cannot rate your own item")
if ((group.voters || []).includes(userId)) throw new Error("Already voted")
if (!(group.buyers || []).includes(userId)) throw new Error("You can rate only an item you have bought")
const content = { type: "marketOpinion", target: group.rootId, category, createdAt: new Date().toISOString() }
return new Promise((res, rej) => ssbClient.publish(content, (e, m) => e ? rej(e) : res(m)))
},
async setItemAsSold(itemId) {
const tipId = await this.resolveCurrentId(itemId)
const ssbClient = await openSsb()

View file

@ -170,12 +170,18 @@ module.exports = ({ cooler }) => {
}
return {
MIN_VOTE_DAYS,
async createVote(question, deadline, options = ['YES', 'NO', 'ABSTENTION', 'CONFUSED', 'FOLLOW_MAJORITY', 'NOT_INTERESTED'], tagsRaw = []) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const parsedDeadline = moment(deadline, moment.ISO_8601, true);
if (!parsedDeadline.isValid()) throw new Error('Invalid deadline');
if (parsedDeadline.isBefore(moment().add(MIN_VOTE_DAYS, 'days').subtract(2, 'minutes'))) throw new Error(`Deadline must be at least ${MIN_VOTE_DAYS} days from now`);
if (parsedDeadline.isBefore(moment().add(MIN_VOTE_DAYS, 'days').subtract(2, 'minutes'))) {
const err = new Error(`Deadline must be at least ${MIN_VOTE_DAYS} days from now`);
err.code = 'VOTE_DEADLINE_MIN';
throw err;
}
const tags = Array.isArray(tagsRaw)
? tagsRaw.filter(Boolean)
@ -246,7 +252,11 @@ module.exports = ({ cooler }) => {
if (deadline != null && deadline !== '') {
const parsed = moment(deadline, moment.ISO_8601, true);
if (!parsed.isValid()) throw new Error('Invalid deadline');
if (parsed.isBefore(moment().add(MIN_VOTE_DAYS, 'days').subtract(2, 'minutes'))) throw new Error(`Deadline must be at least ${MIN_VOTE_DAYS} days from now`);
if (parsed.isBefore(moment().add(MIN_VOTE_DAYS, 'days').subtract(2, 'minutes'))) {
const err = new Error(`Deadline must be at least ${MIN_VOTE_DAYS} days from now`);
err.code = 'VOTE_DEADLINE_MIN';
throw err;
}
newDeadline = parsed.toISOString();
}