From c662e37668fed93c6efc799b19d5242d99d7bf31 Mon Sep 17 00:00:00 2001 From: SITO Date: Tue, 18 Aug 2026 22:38:09 +0200 Subject: [PATCH] upstream: integrar 0.9.5 en modelos y vistas medianas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/backend/blobHandler.js | 53 ++++++++++++++++++++------ src/configs/config-manager.js | 8 +++- src/models/agenda_model.js | 17 +++++++-- src/models/chats_model.js | 46 ++++++++++++++++++++-- src/models/market_model.js | 72 +++++++++++++++++++++++++++++++---- src/models/votes_model.js | 14 ++++++- src/views/agenda_view.js | 20 +++++++++- src/views/cv_view.js | 14 +++---- src/views/opinions_view.js | 72 +++++++++++++++++++++++++++++++++++ src/views/trending_view.js | 34 ++++++++++++++++- 10 files changed, 310 insertions(+), 40 deletions(-) diff --git a/src/backend/blobHandler.js b/src/backend/blobHandler.js index 95c00b8..60dfd38 100644 --- a/src/backend/blobHandler.js +++ b/src/backend/blobHandler.js @@ -58,25 +58,30 @@ class FileTooLargeError extends Error { } } -const handleBlobUpload = async function (ctx, fileFieldName) { - if (!ctx.request.files || !ctx.request.files[fileFieldName]) { - return null; - } +const OGG_VIDEO_MARKERS = ['theora', 'VP80', 'OggDS', 'Dirac']; +const OGG_AUDIO_MARKERS = ['vorbis', 'OpusHead', 'FLAC', 'Speex']; - const blobUpload = ctx.request.files[fileFieldName]; - if (!blobUpload) return null; +const oggMime = (buffer) => { + const head = buffer.slice(0, 65536); + for (const marker of OGG_VIDEO_MARKERS) if (head.includes(Buffer.from(marker))) return 'video/ogg'; + for (const marker of OGG_AUDIO_MARKERS) if (head.includes(Buffer.from(marker))) return 'audio/ogg'; + return 'video/ogg'; +}; + +const storeUploadedFile = async function (blobUpload) { + if (!blobUpload || !blobUpload.filepath) return null; let data = await promisesFs.readFile(blobUpload.filepath); if (data.length === 0) return null; if (data.length > MAX_BLOB_SIZE) { - throw new FileTooLargeError(blobUpload.originalFilename || blobUpload.name || fileFieldName, data.length); + throw new FileTooLargeError(blobUpload.originalFilename || blobUpload.name || 'file', data.length); } const EXTENSION_MIME_MAP = { '.mp4': 'video/mp4', '.webm': 'video/webm', '.ogg': 'video/ogg', - '.ogv': 'video/ogg', '.avi': 'video/x-msvideo', '.mov': 'video/quicktime', - '.mkv': 'video/x-matroska', '.mp3': 'audio/mpeg', '.wav': 'audio/wav', + '.ogv': 'video/ogg', '.oga': 'audio/ogg', '.mkv': 'video/x-matroska', '.avi': 'video/x-msvideo', '.mov': 'video/quicktime', + '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.flac': 'audio/flac', '.aac': 'audio/aac', '.opus': 'audio/opus', '.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', @@ -93,9 +98,12 @@ const handleBlobUpload = async function (ctx, fileFieldName) { blob.mime = null; } - if (!blob.mime && blob.name) { + if (blob.mime === 'application/ogg') blob.mime = oggMime(data); + + const GENERIC_MIMES = new Set(['application/octet-stream', 'video/x-matroska']); + if ((!blob.mime || GENERIC_MIMES.has(blob.mime)) && blob.name) { const ext = (blob.name.match(/\.[^.]+$/) || [''])[0].toLowerCase(); - blob.mime = EXTENSION_MIME_MAP[ext] || 'application/octet-stream'; + blob.mime = EXTENSION_MIME_MAP[ext] || blob.mime || 'application/octet-stream'; } if (!blob.mime) { @@ -126,6 +134,25 @@ const handleBlobUpload = async function (ctx, fileFieldName) { return `\n[${blob.name}](${blob.id})`; }; +const handleBlobUpload = async function (ctx, fileFieldName) { + if (!ctx.request.files || !ctx.request.files[fileFieldName]) return null; + const entry = ctx.request.files[fileFieldName]; + const first = Array.isArray(entry) ? entry[0] : entry; + return storeUploadedFile(first); +}; + +const handleBlobUploads = async function (ctx, fileFieldName, max = 8) { + if (!ctx.request.files || !ctx.request.files[fileFieldName]) return []; + const entry = ctx.request.files[fileFieldName]; + const files = (Array.isArray(entry) ? entry : [entry]).slice(0, Math.max(0, max)); + const out = []; + for (const file of files) { + const stored = await storeUploadedFile(file); + if (stored) out.push(stored); + } + return out; +}; + function waitForBlob(ssbClient, blobId, timeoutMs = 8000) { return new Promise((resolve, reject) => { let done = false; @@ -229,6 +256,8 @@ const serveBlob = async function (ctx) { } } + if (mime === 'application/ogg') mime = oggMime(buffer); + const isSvg = mime === 'image/svg+xml'; const qName = ctx.query.name ? String(ctx.query.name).replace(/["\r\n\\]/g, '').trim() : ''; const safeRaw = String(raw).replace(/["\r\n\\]/g, ''); @@ -275,5 +304,5 @@ const serveBlob = async function (ctx) { } }; -module.exports = { handleBlobUpload, serveBlob, FileTooLargeError }; +module.exports = { handleBlobUpload, handleBlobUploads, serveBlob, oggMime, FileTooLargeError }; diff --git a/src/configs/config-manager.js b/src/configs/config-manager.js index 8a335b4..5143845 100644 --- a/src/configs/config-manager.js +++ b/src/configs/config-manager.js @@ -12,12 +12,15 @@ if (!fs.existsSync(configFilePath)) { "current": "blocks" }, "modules": { + "blogsMod": "on", + "karvanMod": "on", "popularMod": "on", "topicsMod": "on", "summariesMod": "on", "latestMod": "on", "threadsMod": "on", "multiverseMod": "on", + "pollsMod": "on", "fediverseMod": "off", "invitesMod": "on", "walletMod": "on", @@ -39,7 +42,6 @@ if (!fs.existsSync(configFilePath)) { "reportsMod": "on", "opinionsMod": "on", "padsMod": "on", - "karvanMod": "on", "calendarsMod": "on", "transfersMod": "on", "feedMod": "on", @@ -97,8 +99,10 @@ const getConfig = () => { if (typeof cfg.ux === 'string') cfg.ux = { current: cfg.ux }; if (!cfg.ux || typeof cfg.ux !== 'object') cfg.ux = { current: 'blocks' }; if (cfg.ux.current === 'menus') cfg.ux.current = 'blocks'; - if (cfg.ux.current !== 'blocks' && cfg.ux.current !== 'ainav') cfg.ux.current = 'blocks'; + if (cfg.ux.current !== 'blocks' && cfg.ux.current !== 'ainav' && cfg.ux.current !== 'chats') cfg.ux.current = 'blocks'; if (cfg.ux.current === 'ainav' && cfg.modules && cfg.modules.aiNavMod !== 'on') cfg.ux.current = 'blocks'; + if (cfg.ux.current === 'chats' && cfg.modules && cfg.modules.chatsMod !== 'on') cfg.ux.current = 'blocks'; + let pins = Array.isArray(cfg.bottomBarPins) ? cfg.bottomBarPins : (typeof cfg.bottomBarPin === 'string' diff --git a/src/models/agenda_model.js b/src/models/agenda_model.js index 1f4b014..9090b89 100644 --- a/src/models/agenda_model.js +++ b/src/models/agenda_model.js @@ -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, diff --git a/src/models/chats_model.js b/src/models/chats_model.js index 3549e09..a74d7c1 100644 --- a/src/models/chats_model.js +++ b/src/models/chats_model.js @@ -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 = { diff --git a/src/models/market_model.js b/src/models/market_model.js index cb2bcd1..ed2a038 100644 --- a/src/models/market_model.js +++ b/src/models/market_model.js @@ -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() diff --git a/src/models/votes_model.js b/src/models/votes_model.js index 7802606..75f2ca7 100644 --- a/src/models/votes_model.js +++ b/src/models/votes_model.js @@ -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(); } diff --git a/src/views/agenda_view.js b/src/views/agenda_view.js index f0112f7..bdfdc56 100644 --- a/src/views/agenda_view.js +++ b/src/views/agenda_view.js @@ -25,6 +25,7 @@ function getViewDetailsAction(item) { case 'job': return `/jobs/${encodeURIComponent(item.id)}`; case 'project': return `/projects/${encodeURIComponent(item.id)}`; case 'industry': return `/industry/build/${encodeURIComponent(item.id)}`; + case 'housing': return `/housing/${encodeURIComponent(item.id)}`; case 'calendar': return `/calendars/${encodeURIComponent(item.id)}`; default: return `/messages/${encodeURIComponent(item.id)}`; } @@ -150,6 +151,21 @@ const renderAgendaItem = (item, userId, filter) => { ]; } + if (item.type === 'housing') { + const isOwner = String(item.author) === String(userId); + const requestCount = Number(item.requestCount) || 0; + details = [ + renderCardField(i18n.housingType + ":", (i18n["housingType" + String(item.housing_type || '').toUpperCase()] || item.housing_type || '').toUpperCase()), + renderCardField(i18n.housingStatus + ":", String(item.status || '').toUpperCase() === 'CLOSED' ? i18n.housingStatusCLOSED : i18n.housingStatusOPEN), + item.place ? renderCardField(i18n.housingPlace + ":", item.place) : null, + renderCardField(i18n.housingPrice + ":", String(item.housing_type) === 'couchsurfing' ? (i18n.housingFree || 'FREE') : `${item.price} ECO`), + item.availableFrom ? renderCardField(i18n.housingAvailableFrom + ":", moment(item.availableFrom).format('YYYY/MM/DD')) : null, + isOwner + ? renderCardField(i18n.housingRequests + ":", String(requestCount)) + : renderCardField(i18n.housingRequests + ":", i18n.housingRequestedBadge || 'REQUESTED') + ].filter(Boolean); + } + if (item.type === 'job') { const subs = Array.isArray(item.subscribers) ? item.subscribers @@ -214,7 +230,7 @@ const renderAgendaItem = (item, userId, filter) => { exports.agendaView = async (data, filter, q = '') => { const { items = [], counts: _c = {} } = data || {}; - const counts = { all: 0, open: 0, closed: 0, events: 0, tasks: 0, reports: 0, tribes: 0, jobs: 0, market: 0, projects: 0, transfers: 0, calendars: 0, discarded: 0, ..._c }; + const counts = { all: 0, open: 0, closed: 0, events: 0, tasks: 0, reports: 0, tribes: 0, jobs: 0, market: 0, projects: 0, transfers: 0, calendars: 0, housing: 0, discarded: 0, ..._c }; return template( i18n.agendaTitle, section( @@ -252,6 +268,8 @@ exports.agendaView = async (data, filter, q = '') => { `${i18n.agendaFilterProjects} (${counts.projects})`), button({ type: 'submit', name: 'filter', value: 'industry', class: filter === 'industry' ? 'filter-btn active' : 'filter-btn' }, `${i18n.agendaFilterIndustry || 'INDUSTRY'} (${counts.industry || 0})`), + button({ type: 'submit', name: 'filter', value: 'housing', class: filter === 'housing' ? 'filter-btn active' : 'filter-btn' }, + `${i18n.agendaFilterHousing || 'HOUSING'} (${counts.housing || 0})`), button({ type: 'submit', name: 'filter', value: 'calendars', class: filter === 'calendars' ? 'filter-btn active' : 'filter-btn' }, `${i18n.agendaFilterCalendars || 'CALENDARS'} (${counts.calendars})`), button({ type: 'submit', name: 'filter', value: 'transfers', class: filter === 'transfers' ? 'filter-btn active' : 'filter-btn' }, diff --git a/src/views/cv_view.js b/src/views/cv_view.js index c64e183..b076e4b 100644 --- a/src/views/cv_view.js +++ b/src/views/cv_view.js @@ -81,19 +81,19 @@ exports.createCVView = async (cv = {}, editMode = false) => { input({ type: "text", name: "location", required: true, value: cv.location || "UNKNOWN" }), br(), label(i18n.cvStatusLabel), br(), select({ name: "status", required: true }, - option({ value: "AVAILABLE", selected: cv.status === "AVAILABLE FOR COLLABORATION" }, "AVAILABLE FOR COLLABORATION"), - option({ value: "UNAVAILABLE", selected: cv.status === "NOT CURRENTLY AVAILABLE" }, "NOT CURRENTLY AVAILABLE"), - option({ value: "LOOKING FOR WORK", selected: !cv.status || cv.status === "LOOKING FOR WORK" }, "LOOKING FOR WORK") + option({ value: "AVAILABLE", ...(cv.status === "AVAILABLE FOR COLLABORATION" ? { selected: true } : {})}, "AVAILABLE FOR COLLABORATION"), + option({ value: "UNAVAILABLE", ...(cv.status === "NOT CURRENTLY AVAILABLE" ? { selected: true } : {})}, "NOT CURRENTLY AVAILABLE"), + option({ value: "LOOKING FOR WORK", ...(!cv.status || cv.status === "LOOKING FOR WORK" ? { selected: true } : {})}, "LOOKING FOR WORK") ), br(), br(), label(i18n.cvPreferencesLabel), br(), select({ name: "preferences", required: true }, - option({ value: "IN PERSON", selected: cv.preferences === "IN-PERSON ONLY" }, "IN-PERSON ONLY"), - option({ value: "REMOTE WORKING", selected: !cv.preferences || cv.preferences === "REMOTE WORKING" }, "REMOTE-WORKING") + option({ value: "IN PERSON", ...(cv.preferences === "IN-PERSON ONLY" ? { selected: true } : {})}, "IN-PERSON ONLY"), + option({ value: "REMOTE WORKING", ...(!cv.preferences || cv.preferences === "REMOTE WORKING" ? { selected: true } : {})}, "REMOTE-WORKING") ), br(), br(), label(i18n.visibilityLabel || "Visibility"), br(), select({ name: "visibility" }, - option({ value: "PUBLIC", selected: (cv.visibility || "PUBLIC") === "PUBLIC" }, i18n.visibilityPublic || "Public"), - option({ value: "HIDDEN", selected: cv.visibility === "HIDDEN" }, i18n.visibilityHidden || "Hidden") + option({ value: "PUBLIC", ...((cv.visibility || "PUBLIC") === "PUBLIC" ? { selected: true } : {})}, i18n.visibilityPublic || "Public"), + option({ value: "HIDDEN", ...(cv.visibility === "HIDDEN" ? { selected: true } : {})}, i18n.visibilityHidden || "Hidden") ), br() ], "availability"), diff --git a/src/views/opinions_view.js b/src/views/opinions_view.js index 00adc8f..3f86bfe 100644 --- a/src/views/opinions_view.js +++ b/src/views/opinions_view.js @@ -25,6 +25,8 @@ const detailHref = (type, key) => { case 'task': return `/tasks/${encodeURIComponent(key)}`; case 'event': return `/events/${encodeURIComponent(key)}`; case 'shopProduct': return `/shops/product/${encodeURIComponent(key)}`; + case 'housing': return `/housing/${encodeURIComponent(key)}`; + case 'market': return `/market/${encodeURIComponent(key)}`; default: return null; } }; @@ -71,6 +73,76 @@ const renderContentHtml = (content, key) => { content.description ? p(...renderUrl(content.description)) : null ) ); + case 'housing': { + const free = String(content.housing_type || '').toLowerCase() === 'couchsurfing'; + return div({ class: 'opinion-housing' }, + div({ class: 'card-section housing' }, + div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.title + ':'), + span({ class: 'card-value' }, content.title || '') + ), + content.housing_type ? div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.housingType + ':'), + span({ class: 'card-value' }, String(i18n['housingType' + String(content.housing_type).toUpperCase()] || content.housing_type).toUpperCase()) + ) : null, + content.property_type ? div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.housingProperty + ':'), + span({ class: 'card-value' }, String(i18n['housingProperty' + String(content.property_type).toUpperCase()] || content.property_type).toUpperCase()) + ) : null, + div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.housingPrice + ':'), + span({ class: 'card-value' }, free ? (i18n.housingFree || 'FREE') : `${Number(content.price || 0).toFixed(2)} ECO`) + ), + content.place ? div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.housingPlace + ':'), + span({ class: 'card-value' }, content.place) + ) : null, + Number(content.rooms) > 0 ? div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.housingRooms + ':'), + span({ class: 'card-value' }, String(content.rooms)) + ) : null, + Number(content.size) > 0 ? div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.housingSize + ':'), + span({ class: 'card-value' }, `${content.size} m²`) + ) : null, + Number(content.capacity) > 0 ? div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.housingCapacity + ':'), + span({ class: 'card-value' }, String(content.capacity)) + ) : null, + content.description ? p(...renderUrl(content.description)) : null, + Array.isArray(content.tags) && content.tags.length + ? div({ class: 'card-tags' }, content.tags.map(tag => + a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`))) + : null + ) + ); + } + case 'market': + return div({ class: 'opinion-market' }, + div({ class: 'card-section market' }, + div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.title + ':'), + span({ class: 'card-value' }, content.title || '') + ), + content.item_type ? div({ class: 'card-field' }, + span({ class: 'card-label' }, i18n.marketItemType + ':'), + span({ class: 'card-value' }, String(content.item_type).toUpperCase()) + ) : null, + content.item_status ? div({ class: 'card-field' }, + span({ class: 'card-label' }, (i18n.marketItemCondition || i18n.status) + ':'), + span({ class: 'card-value' }, String(content.item_status).toUpperCase()) + ) : null, + div({ class: 'card-field' }, + span({ class: 'card-label' }, (i18n.marketItemPrice || i18n.price) + ':'), + span({ class: 'card-value' }, `${content.price} ECO`) + ), + content.description ? p(...renderUrl(content.description)) : null, + Array.isArray(content.tags) && content.tags.length + ? div({ class: 'card-tags' }, content.tags.map(tag => + a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`))) + : null + ) + ); case 'bookmark': return div({ class: 'opinion-bookmark' }, div({ class: 'card-section bookmark' }, diff --git a/src/views/trending_view.js b/src/views/trending_view.js index e097ae3..320a8c1 100644 --- a/src/views/trending_view.js +++ b/src/views/trending_view.js @@ -118,6 +118,36 @@ const renderTrendingCard = (item, votes, categories, seenTitles, spreadMap = new c.description ? p(...renderUrl(c.description)) : null ) ); + } else if (c.type === 'housing') { + const free = String(c.housing_type || '').toLowerCase() === 'couchsurfing'; + contentHtml = div({ class: 'trending-housing' }, + div({ class: 'card-section housing' }, + div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, c.title || '')), + c.housing_type ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.housingType + ':'), span({ class: 'card-value' }, String(i18n['housingType' + String(c.housing_type).toUpperCase()] || c.housing_type).toUpperCase())) : "", + c.property_type ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.housingProperty + ':'), span({ class: 'card-value' }, String(i18n['housingProperty' + String(c.property_type).toUpperCase()] || c.property_type).toUpperCase())) : "", + div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.housingPrice + ':'), span({ class: 'card-value' }, free ? (i18n.housingFree || 'FREE') : `${Number(c.price || 0).toFixed(2)} ECO`)), + c.place ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.housingPlace + ':'), span({ class: 'card-value' }, c.place)) : "", + Number(c.rooms) > 0 ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.housingRooms + ':'), span({ class: 'card-value' }, String(c.rooms))) : "", + Number(c.size) > 0 ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.housingSize + ':'), span({ class: 'card-value' }, `${c.size} m²`)) : "", + c.description ? p(...renderUrl(c.description)) : null, + Array.isArray(c.tags) && c.tags.length + ? div({ class: 'card-tags' }, c.tags.map(tag => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`))) + : null + ) + ); + } else if (c.type === 'market') { + contentHtml = div({ class: 'trending-market' }, + div({ class: 'card-section market' }, + div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, c.title || '')), + c.item_type ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemType + ':'), span({ class: 'card-value' }, String(c.item_type).toUpperCase())) : "", + c.item_status ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.marketItemCondition || i18n.status) + ':'), span({ class: 'card-value' }, String(c.item_status).toUpperCase())) : "", + div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.marketItemPrice || i18n.price) + ':'), span({ class: 'card-value' }, `${c.price} ECO`)), + c.description ? p(...renderUrl(c.description)) : null, + Array.isArray(c.tags) && c.tags.length + ? div({ class: 'card-tags' }, c.tags.map(tag => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`))) + : null + ) + ); } else if (c.type === 'feed') { const { text, refeeds } = c; contentHtml = div({ class: 'trending-feed' }, @@ -183,7 +213,9 @@ const renderTrendingCard = (item, votes, categories, seenTitles, spreadMap = new report: 'reports', task: 'tasks', event: 'events', - shopProduct: 'shops/product' + shopProduct: 'shops/product', + housing: 'housing', + market: 'market' }; const detailHref = detailPaths[c.type] ? `/${detailPaths[c.type]}/${encodeURIComponent(item.key)}`