diff --git a/src/backend/backend.js b/src/backend/backend.js index aebba6d..8c832ff 100644 --- a/src/backend/backend.js +++ b/src/backend/backend.js @@ -52,7 +52,7 @@ const writeAddrMap = (map) => { fs.mkdirSync(path.dirname(ADDR_PATH), { recursiv //parliament model let electionInFlight = null; const ensureTerm = async () => { - const cur = await parliamentModel.getCurrentTerm().catch(() => null); + const cur = await parliamentModel.getPublishedTerm().catch(() => null); if (cur) return cur; if (electionInFlight) return electionInFlight; electionInFlight = parliamentModel.resolveElection().catch(() => null).finally(() => { electionInFlight = null; }); @@ -367,6 +367,10 @@ const refreshInboxCount = async (messagesOpt) => { sharedState.setInboxCount(filtered.filter(isToUser).length); }; +const refreshMentionsCount = async () => { + try { sharedState.setMentionsCount(await mentionsModel.countUnseen()); } catch (_) {} +}; + const PM_CRYPTER_MAX = 4600; const buildInboxMessages = async () => { @@ -394,7 +398,7 @@ const buildInboxMessages = async () => { } return messages; }; -const mediaFavorites = require("./media-favorites.js"); +const contentFavorites = require("./content_favorites.js"); const customStyleFile = path.join(envPaths("oasis", { suffix: "" }).config, "/custom-style.css"); let haveCustomStyle = false; try { fs.readFileSync(customStyleFile, "utf8"); haveCustomStyle = true; } catch (e) { if (e.code !== "ENOENT") { console.log(`Problem loading ${customStyleFile}`); throw e; } } @@ -578,7 +582,8 @@ const { about, blob, friend, meta, post, vote, spreads, lifetime } = models({ cooler, isPublic: config.public, }); -const { handleBlobUpload, serveBlob, FileTooLargeError } = require('../backend/blobHandler.js'); +const { handleBlobUpload, handleBlobUploads, serveBlob, FileTooLargeError } = require('../backend/blobHandler.js'); +const { mergeGallery } = require('../models/media_gallery'); const extractBlobId = (md) => md ? (md.match(/\((&[^)]+)\)/)?.[1] ?? null) : null; const exportmodeModel = require('../models/exportmode_model'); const panicmodeModel = require('../models/panicmode_model'); @@ -637,7 +642,6 @@ const documentsModel = require("../models/documents_model")({ cooler, isPublic: const trendingModel = require('../models/trending_model')({ cooler, isPublic: config.public }); const statsModel = require('../models/stats_model')({ cooler, isPublic: config.public, tribeCrypto, tribesModel }); const padsModel = require('../models/pads_model')({ cooler, cipherModel, tribeCrypto, padCrypto, tribesModel }); - const tagsModel = require('../models/tags_model')({ cooler, isPublic: config.public, padsModel, tribesModel }); const tribesContentModel = require('../models/tribes_content_model')({ cooler, isPublic: config.public, tribeCrypto, tribesModel }); const searchModel = require('../models/search_model')({ cooler, isPublic: config.public, padsModel, tribeCrypto, tribesModel }); @@ -647,15 +651,21 @@ const pixeliaModel = require('../models/pixelia_model')({ cooler, isPublic: conf const melodyModel = require('../models/melody_model')({ cooler }); const marketModel = require('../models/market_model')({ cooler, isPublic: config.public, tribeCrypto }); const forumModel = require('../models/forum_model')({ cooler, isPublic: config.public, tribeCrypto, forumCrypto }); +const blogModel = require('../models/blog_model')({ cooler, isPublic: config.public }); +const dataModel = require('../models/data_model')({ cooler }); +const workflowsModel = require('../models/workflows_model'); +const mentionsModel = require('../models/mentions_model')({ cooler }); const jobsModel = require('../models/jobs_model')({ cooler, isPublic: config.public, tribeCrypto }); +const housingModel = require('../models/housing_model')({ cooler }); const shopsModel = require('../models/shops_model')({ cooler, isPublic: config.public, tribeCrypto }); const chatsModel = require('../models/chats_model')({ cooler, tribeCrypto, chatCrypto, tribesModel }); +const pollsModel = require('../models/polls_model')({ cooler, isPublic: config.public, tribeCrypto, chatsModel }); const projectsModel = require("../models/projects_model")({ cooler, isPublic: config.public }); -const agendaModel = require("../models/agenda_model")({ cooler, isPublic: config.public, calendarsModel, eventsModel, tasksModel, marketModel, jobsModel, projectsModel, industryModel }); +const agendaModel = require("../models/agenda_model")({ cooler, isPublic: config.public, calendarsModel, eventsModel, tasksModel, marketModel, jobsModel, projectsModel, industryModel, housingModel }); const mapsModel = require("../models/maps_model")({ cooler, isPublic: config.public, tribeCrypto, mapCrypto, tribesModel }); const gamesModel = require('../models/games_model')({ cooler }); const bankingModel = require("../models/banking_model")({ services: { cooler }, isPublic: config.public }); -const favoritesModel = require("../models/favorites_model")({ services: { cooler }, audiosModel, bookmarksModel, documentsModel, imagesModel, videosModel, mapsModel, padsModel, chatsModel, calendarsModel, torrentsModel, marketModel, shopsModel }); +const favoritesModel = require("../models/favorites_model")({ services: { cooler }, audiosModel, bookmarksModel, documentsModel, imagesModel, videosModel, mapsModel, padsModel, chatsModel, calendarsModel, torrentsModel, marketModel, shopsModel, eventsModel, tasksModel, reportsModel, votesModel, jobsModel, housingModel, projectsModel, transfersModel, forumModel, blogsModel: blogModel, pollsModel }); const logsModel = require("../models/logs_model")({ cooler }); const parliamentModel = require('../models/parliament_model')({ cooler, services: { tribes: tribesModel, votes: votesModel, inhabitants: inhabitantsModel, banking: bankingModel } }); const fediverseModel = require('../models/fediverse_model')({ isPublic: config.public }); @@ -810,8 +820,40 @@ const applyListFilters = async (items, ctx, opts = {}) => { } out = filtered; } + out = await markFavorites(out, ctx); return out; }; + +const applyTextSearch = (items, q, fields) => { + const needle = String(q || '').trim().toLowerCase(); + if (!needle || !Array.isArray(items)) return items; + return items.filter(it => fields.some(f => { + const v = it && it[f]; + if (Array.isArray(v)) return v.some(x => String(x || '').toLowerCase().includes(needle)); + return String(v == null ? '' : v).toLowerCase().includes(needle); + })); +}; + +const withFavorite = async (item, kind) => { + if (!item || typeof item !== 'object') return item; + try { + const fav = await contentFavorites.getFavoriteSet(kind); + return { ...item, isFavorite: fav.has(String(item.rootId || item.id || item.key)) }; + } catch (_) { return item; } +}; + +const markFavorites = async (items, ctx) => { + if (!Array.isArray(items) || !items.length || !ctx) return items; + const kind = String(ctx.path || '').split('/').filter(Boolean)[0]; + if (!kind || !contentModCheck[kind]) return items; + let fav; + try { fav = await contentFavorites.getFavoriteSet(kind); } catch (_) { return items; } + if (!fav || typeof fav.has !== 'function') return items; + return items.map(it => (it && typeof it === 'object' && it.isFavorite === undefined) + ? { ...it, isFavorite: fav.has(String(it.rootId || it.id || it.key)) } + : it); +}; + const enrichItemLifetime = async (item, opts = {}) => { if (!item) return item; try { @@ -871,30 +913,245 @@ const resolveMapUrl = async (mapUrl) => { } catch (_) { return null; } }; -const mediaResolvers = { +const contentResolvers = { images: id => imagesModel.resolveRootId(id), audios: id => audiosModel.resolveRootId(id), videos: id => videosModel.resolveRootId(id), documents: id => documentsModel.resolveRootId(id), bookmarks: id => bookmarksModel.resolveRootId(id), shops: id => shopsModel.resolveRootId(id), + shopProducts: id => shopsModel.resolveRootId(id), chats: id => chatsModel.resolveRootId(id), maps: id => mapsModel.resolveRootId(id), pads: id => padsModel.resolveRootId(id), calendars: id => calendarsModel.resolveRootId(id), - torrents: id => torrentsModel.resolveRootId(id) + torrents: id => torrentsModel.resolveRootId(id), + events: async id => id, + blogs: async id => id, + logs: async id => id, + polls: async id => pollsModel.resolveRootId(id).catch(() => id), + forum: async id => id, + tasks: async id => id, + reports: async id => id, + votes: async id => id, + jobs: async id => jobsModel.resolveRootId(id).catch(() => id), + housing: async id => housingModel.resolveRootId(id).catch(() => id), + projects: async id => id, + transfers: async id => id, + market: async id => id }; -const mediaModCheck = { images: 'imagesMod', audios: 'audiosMod', videos: 'videosMod', documents: 'documentsMod', bookmarks: 'bookmarksMod', market: 'marketMod', jobs: 'jobsMod', projects: 'projectsMod', shops: 'shopsMod', chats: 'chatsMod', maps: 'mapsMod', pads: 'padsMod', calendars: 'calendarsMod', torrents: 'torrentsMod' }; +const contentModCheck = { images: 'imagesMod', audios: 'audiosMod', videos: 'videosMod', documents: 'documentsMod', bookmarks: 'bookmarksMod', market: 'marketMod', jobs: 'jobsMod', projects: 'projectsMod', shops: 'shopsMod', shopProducts: 'shopsMod', chats: 'chatsMod', maps: 'mapsMod', pads: 'padsMod', calendars: 'calendarsMod', torrents: 'torrentsMod', events: 'eventsMod', forum: 'forumMod', blogs: 'blogsMod', logs: 'logsMod', polls: 'pollsMod', tasks: 'tasksMod', reports: 'reportsMod', votes: 'votesMod', housing: 'housingMod', transfers: 'transfersMod' }; const favAction = async (ctx, kind, action) => { - if (!checkMod(ctx, mediaModCheck[kind])) { ctx.redirect('/modules'); return; } + if (!checkMod(ctx, contentModCheck[kind])) { ctx.redirect('/modules'); return; } try { - const rootId = await mediaResolvers[kind](ctx.params.id); - if (rootId) await mediaFavorites[action + 'Favorite'](kind, rootId); + const rootId = await contentResolvers[kind](ctx.params.id); + if (rootId) await contentFavorites[action + 'Favorite'](kind, rootId); } catch (_) {} ctx.redirect(safeReturnTo(ctx, `/${kind}`, [`/${kind}`])); }; +const voteFormRedirect = (mode, id, err, body = {}) => { + const params = new URLSearchParams(); + params.set('error', err && err.code === 'VOTE_DEADLINE_MIN' ? 'deadline' : 'generic'); + if (body.question) params.set('question', String(body.question).slice(0, 300)); + if (body.deadline) params.set('deadline', String(body.deadline).slice(0, 40)); + if (body.tags) params.set('tags', String(body.tags).slice(0, 300)); + return mode === 'edit' && id + ? `/votes/edit/${encodeURIComponent(id)}?${params.toString()}` + : `/votes?filter=create&${params.toString()}`; +}; + +const voteFormState = (ctx) => ({ + error: typeof ctx.query.error === 'string' ? ctx.query.error : '', + minVoteDays: votesModel.MIN_VOTE_DAYS, + draft: { + question: typeof ctx.query.question === 'string' ? ctx.query.question : '', + deadline: typeof ctx.query.deadline === 'string' ? ctx.query.deadline : '', + tags: typeof ctx.query.tags === 'string' ? ctx.query.tags : '' + } +}); + +const notifyHousingRequesters = async (item, reason) => { + if (!item || String(item.author) !== String(getViewerId())) return; + const requesters = Array.isArray(item.requests) ? item.requests.filter(id => id && id !== item.author) : []; + if (!requesters.length) return; + const label = reason === 'deleted' ? 'has been removed' : 'is no longer available'; + for (const requester of requesters) { + try { + await pmModel.sendMessage([requester], 'HOUSING_UNAVAILABLE', `The place [${item.title || 'a place'}](/housing/${encodeURIComponent(item.id)}) you requested ${label}`); + } catch (_) {} + } +}; + +const readInterval = (b) => { + const single = String((b && b.interval) || '').toLowerCase(); + if (single) { + return { + intervalWeekly: single === 'weekly', + intervalMonthly: single === 'monthly', + intervalYearly: single === 'yearly' + }; + } + return { + intervalWeekly: [].concat(b.intervalWeekly).includes('1'), + intervalMonthly: [].concat(b.intervalMonthly).includes('1'), + intervalYearly: [].concat(b.intervalYearly).includes('1') + }; +}; + +const readGalleryUpload = async (ctx) => { + const b = ctx.request.body || {}; + const uploaded = await handleBlobUploads(ctx, 'images'); + const clip = ctx.request.files?.video ? await handleBlobUpload(ctx, 'video') : null; + const removeIndex = b.removePhoto !== undefined && b.removePhoto !== '' ? parseInt(b.removePhoto, 10) : -1; + const action = String(b.action || ''); + const keep = [].concat(b.keepImages || []).filter(Boolean); + return { + uploaded, + clip, + keep, + removeIndex, + action, + isMediaAction: action === 'addPhoto' || action === 'addVideo' || action === 'removeVideo' || removeIndex >= 0 + }; +}; + +const galleryPatch = (media, current) => { + const patch = {}; + if (media.uploaded.length || media.removeIndex >= 0) patch.images = mergeGallery(current, media.uploaded, media.removeIndex); + if (media.clip) patch.video = media.clip; + else if (media.action === 'removeVideo') patch.video = ''; + return patch; +}; + +const formatFileSize = (n) => { + const x = Number(n) || 0; + if (x < 1024) return x + ' B'; + const u = ['KB', 'MB', 'GB', 'TB']; + let v = x / 1024, i = 0; + while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; } + return v.toFixed(v >= 100 || i === 0 ? 0 : 1) + ' ' + u[i]; +}; + +const PDF_KINDS = { + reports: { mod: 'reportsMod', load: async (id) => ({ item: await reportsModel.getReportById(id) }) }, + votes: { mod: 'votesMod', load: async (id) => ({ item: await votesModel.getVoteById(id) }) }, + events: { + mod: 'eventsMod', + load: async (id, viewerId) => { + const event = await eventsModel.getEventById(id); + if (!event) return null; + const attendees = Array.isArray(event.attendees) ? event.attendees : []; + const isPrivate = String(event.isPublic || '').toUpperCase() === 'PRIVATE'; + if (isPrivate && String(event.organizer) !== String(viewerId) && !attendees.includes(viewerId)) return null; + return { item: event }; + } + }, + tasks: { + mod: 'tasksMod', + load: async (id, viewerId) => { + const task = await tasksModel.getTaskById(id); + if (!task) return null; + const assignees = Array.isArray(task.assignees) ? task.assignees : []; + const isPrivate = String(task.isPublic || '').toUpperCase() === 'PRIVATE'; + if (isPrivate && String(task.author) !== String(viewerId) && !assignees.includes(viewerId)) return null; + return { item: task }; + } + }, + calendars: { + mod: 'calendarsMod', + load: async (id, viewerId) => { + const cal = await calendarsModel.getCalendarById(id); + if (!cal) return null; + if (cal.tribeId) { + const tribe = await tribesModel.getTribeById(cal.tribeId).catch(() => null); + if (!tribe || !tribe.members.includes(viewerId)) return null; + } else { + const participants = Array.isArray(cal.participants) ? cal.participants : []; + const isOpen = String(cal.status || '').toUpperCase() === 'OPEN'; + if (!isOpen && cal.author !== viewerId && !participants.includes(viewerId)) return null; + } + if (String(cal.status || '').toUpperCase() === 'CLOSED' && cal.author !== viewerId) return null; + const dates = await calendarsModel.getDatesForCalendar(cal.rootId); + const notesByDate = {}; + for (const d of dates) notesByDate[d.key] = await calendarsModel.getNotesForDate(cal.rootId, d.key); + return { item: cal, extra: { dates, notesByDate } }; + } + }, + cv: { mod: null, load: async () => ({ item: await cvModel.getCVByUserId() }) } +}; + +const loadPdfDoc = async (ctx, kind, id) => { + const cfg = PDF_KINDS[kind]; + if (!cfg) return null; + if (cfg.mod && !checkMod(ctx, cfg.mod)) return null; + try { + return await cfg.load(id, getViewerId()); + } catch (_) { + return null; + } +}; + +const sendContentPdf = async (ctx, kind, id) => { + const doc = await loadPdfDoc(ctx, kind, id); + if (!doc || !doc.item) { ctx.redirect(`/${kind === 'cv' ? 'cv' : kind}`); return; } + const pdf = buildContentPdf(kind, doc.item, doc.extra || {}, getViewerId()); + ctx.set('Content-Type', 'application/pdf'); + ctx.set('Content-Disposition', `attachment; filename="${pdfFilename(kind, doc.item)}"`); + ctx.body = pdf; +}; + +const sharePdfBuffer = async (ctx, pdf, filename, subject) => { + let pointer; + try { + pointer = await fileshareModel.createShareFromBuffer({ buffer: pdf, filename, mime: 'application/pdf' }); + } catch (_) { + ctx.redirect('/pm?fileerror=failed#fileshare'); + return; + } + const subjectText = String(subject || filename).slice(0, 150); + ctx.body = await pmView('', subjectText, '', false, '', false, null, false, '', { + recipient: '', subject: subjectText, + manifestBlobId: pointer.manifestBlobId, keyHex: pointer.key, + filename: pointer.filename, mime: pointer.mime, size: pointer.size, + sizeLabel: formatFileSize(pointer.size), crypter: false, sharedKey: '' + }); +}; + +const sharePdfAsPm = async (ctx, kind, id) => { + const doc = await loadPdfDoc(ctx, kind, id); + if (!doc || !doc.item) { ctx.redirect(`/${kind}`); return; } + const pdf = buildContentPdf(kind, doc.item, doc.extra || {}, null); + const subject = doc.item.title || doc.item.question || doc.item.name || ''; + await sharePdfBuffer(ctx, pdf, pdfFilename(kind, doc.item), subject); +}; + +const MODULE_HOME_PATHS = new Set([ + 'images', 'audios', 'videos', 'documents', 'bookmarks', 'torrents', 'maps', + 'events', 'tasks', 'reports', 'votes', 'market', 'jobs', 'housing', 'projects', + 'industry', 'shops', 'transfers', 'pads', 'chats', 'calendars', 'forum', + 'tribes', 'feed', 'logs', 'opinions', 'trending', 'agenda' +]); + +const moduleHomeFor = (ctx) => { + if (!ctx || ctx.method !== 'GET') return null; + const segment = String(ctx.path || '').split('/').filter(Boolean)[0]; + return segment && MODULE_HOME_PATHS.has(segment) ? `/${segment}` : null; +}; + +const isMissingContentError = (err) => { + if (!err) return false; + if (err.status === 400 || err.status === 404 || err.name === 'BadRequestError') return true; + const msg = String(err.message || '').toLowerCase(); + return msg.includes('failed to decode') || + msg.includes('not found') || + msg.includes('malformed') || + msg.includes('invalid') || + msg.includes('cannot be decrypted') || + msg.includes('undecodable'); +}; + const commentAction = async (ctx, kind, idParam) => { - const modKey = mediaModCheck[kind]; + const modKey = contentModCheck[kind]; if (modKey && !checkMod(ctx, modKey)) { ctx.redirect('/modules'); return; } const itemId = ctx.params[idParam]; let text = stripDangerousTags((ctx.request.body.text || '').trim()); @@ -908,14 +1165,14 @@ const commentAction = async (ctx, kind, idParam) => { const opinionModels = { images: imagesModel, audios: audiosModel, videos: videosModel, documents: documentsModel, bookmarks: bookmarksModel, torrents: torrentsModel }; const deleteModels = { images: imagesModel, audios: audiosModel, videos: videosModel, documents: documentsModel, bookmarks: bookmarksModel, torrents: torrentsModel }; const opinionAction = async (ctx, kind, idParam) => { - const modKey = mediaModCheck[kind]; + const modKey = contentModCheck[kind]; if (modKey && !checkMod(ctx, modKey)) { ctx.redirect('/modules'); return; } await opinionModels[kind].createOpinion(ctx.params[idParam], ctx.params.category); try { activityModel.invalidateCache(); } catch (_) {} ctx.redirect(safeReturnTo(ctx, `/${kind}`, [`/${kind}`])); }; const deleteAction = async (ctx, kind, deleteFn = 'delete' + kind.charAt(0).toUpperCase() + kind.slice(1, -1) + 'ById') => { - const modKey = mediaModCheck[kind]; + const modKey = contentModCheck[kind]; if (modKey && !checkMod(ctx, modKey)) { ctx.redirect('/modules'); return; } await deleteModels[kind][deleteFn](ctx.params.id); ctx.redirect(safeReturnTo(ctx, `/${kind}?filter=mine`, [`/${kind}`])); @@ -923,7 +1180,7 @@ const deleteAction = async (ctx, kind, deleteFn = 'delete' + kind.charAt(0).toUp const mediaCreateModels = { audios: audiosModel, videos: videosModel }; const mediaCreateAction = async (ctx, kind) => { - const modKey = mediaModCheck[kind]; + const modKey = contentModCheck[kind]; if (modKey && !checkMod(ctx, modKey)) { ctx.redirect('/modules'); return; } const blob = await handleBlobUpload(ctx, kind.slice(0, -1)); const { tags, title, description, mapUrl } = ctx.request.body; @@ -931,7 +1188,7 @@ const mediaCreateAction = async (ctx, kind) => { ctx.redirect(safeReturnTo(ctx, `/${kind}?filter=all`, [`/${kind}`])); }; const mediaUpdateAction = async (ctx, kind) => { - const modKey = mediaModCheck[kind]; + const modKey = contentModCheck[kind]; if (modKey && !checkMod(ctx, modKey)) { ctx.redirect('/modules'); return; } const { tags, title, description, mapUrl } = ctx.request.body; const singular = kind.slice(0, -1); @@ -1358,7 +1615,7 @@ const resolveCommentComponents = async function (ctx) { } return { messages, myFeedId, parentMessage, contentWarning }; }; -const { authorView, previewCommentView, commentView, editProfileView, extendedView, latestView, likesView, threadView, hashtagView, mentionsView, popularView, previewView, privateView, publishCustomView, publishView, previewSubtopicView, subtopicView, imageSearchView, setLanguage, topicsView, summaryView, threadsView, tribeAccessDeniedView, inviteRequiredView, clearnetInhabitantView, clearnetBlogView } = require("../views/main_views"); +const { authorView, previewCommentView, commentView, editProfileView, likesView, threadView, hashtagView, privateView, previewSubtopicView, subtopicView, imageSearchView, setLanguage, tribeAccessDeniedView, inviteRequiredView, clearnetInhabitantView, clearnetBlogView } = require("../views/main_views"); const { activityView } = require("../views/activity_view"); const { cvView, createCVView } = require("../views/cv_view"); const { indexingView } = require("../views/indexing_view"); @@ -1395,15 +1652,20 @@ const { searchView } = require("../views/search_view"); const { transferView, singleTransferView } = require("../views/transfer_view"); const { cipherView } = require("../views/cipher_view"); const { imageView, singleImageView } = require("../views/image_view"); -const { mapsView, singleMapView } = require("../views/maps_view"); +const { mapsView, singleMapView , renderMapInvitePage } = require("../views/maps_view"); const { settingsView } = require("../views/settings_view"); const { fediverseView, fediverseThreadView, fediverseOverviewView, fediversePreviewView } = require("../views/fediverse_view"); const { trendingView } = require("../views/trending_view"); const { marketView, singleMarketView } = require("../views/market_view"); const { aiView } = require("../views/AI_view"); const { forumView, singleForumView, renderForumInvitePage } = require("../views/forum_view"); +const { blogView, singleBlogView } = require("../views/blog_view"); +const { dataView } = require("../views/data_view"); +const { pollsView, singlePollView } = require("../views/polls_view"); +const { mentionsView } = require("../views/mentions_view"); const { renderBlockchainView, renderSingleBlockView } = require("../views/blockchain_view"); const { jobsView, singleJobsView, renderJobForm, clearnetJobView } = require("../views/jobs_view"); +const { housingView, singleHousingView } = require("../views/housing_view"); const { shopsView, singleShopView, singleProductView, editProductView, shopOrdersView, myPurchasesView, clearnetShopView, renderShopInvitePage } = require("../views/shops_view"); const { chatsView, singleChatView, renderChatInvitePage } = require("../views/chats_view"); const { padsView, singlePadView, renderPadInvitePage } = require("../views/pads_view"); @@ -1415,6 +1677,7 @@ const { favoritesView } = require("../views/favorites_view"); const { logsView } = require("../views/logs_view"); const { buildLogsPdf } = require("./logsPdf"); const { buildSmartContractPdf } = require("./smartContractPdf"); +const { buildContentPdf, pdfFilename } = require("./contentPdf"); const { parliamentView } = require("../views/parliament_view"); const { courtsView, courtsCaseView } = require('../views/courts_view'); let sharp; @@ -1507,6 +1770,10 @@ router ctx.body = ainavHomeView({ recentTags }); return; } + if (currentConfig.ux?.current === "chats") { + ctx.redirect("/chats"); + return; + } const homePage = currentConfig.homePage || "activity"; ctx.redirect(`/${homePage}`); }) @@ -1538,15 +1805,8 @@ router try { stats.userEcoinTax = await bankingModel.getUserEcoinTax(getViewerId()); } catch (_) { stats.userEcoinTax = 0; } ctx.body = statsView(stats, filter); }) - .get("/public/popular/:period", async (ctx) => { - if (!checkMod(ctx, 'popularMod')) return ctx.redirect('/modules'); - const i18n = require("../client/assets/translations/i18n"), lang = ctx.cookies.get('language') || getConfig().language || 'en', t = i18n[lang] || i18n['en']; - const messages = sanitizeMessages(await post.popular({ period: ctx.params.period })); - const spreadMap = await spreads.forMessages((messages || []).map(m => m && m.key)).catch(() => new Map()); - ctx.body = await popularView({ messages, prefix: nav(div({ class: "filters" }, ul(['day','week','month','year'].map(p => li(form({ method: "GET", action: `/public/popular/${p}` }, button({ type: "submit", class: "filter-btn" }, t[p]))))))), spreadMap }); - }) .get("/modules", async (ctx) => { - const modules = ['popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'fediverse', 'invites', 'wallet', 'legacy', 'dev', 'cipher', 'bookmarks', 'calendars', 'chats', 'videos', 'docs', 'audios', 'tags', 'images', 'maps', 'trending', 'events', 'tasks', 'market', 'tribes', 'larp', 'votes', 'reports', 'opinions', 'pads', 'transfers', 'feed', 'pixelia', 'melody', 'agenda', 'favorites', 'ai', 'forum', 'games', 'jobs', 'projects', 'industry', 'shops', 'banking', 'parliament', 'courts']; + const modules = ['blogs', 'polls', 'fediverse', 'invites', 'wallet', 'legacy', 'dev', 'cipher', 'bookmarks', 'calendars', 'chats', 'videos', 'docs', 'audios', 'tags', 'images', 'maps', 'trending', 'events', 'tasks', 'market', 'tribes', 'larp', 'votes', 'reports', 'opinions', 'pads', 'transfers', 'feed', 'pixelia', 'melody', 'agenda', 'favorites', 'ai', 'forum', 'games', 'housing', 'jobs', 'projects', 'industry', 'shops', 'banking', 'parliament', 'courts']; const cfg = getConfig().modules; ctx.body = modulesView(modules.reduce((acc, m) => { acc[`${m}Mod`] = cfg[`${m}Mod`]; return acc; }, {})); }) @@ -1742,41 +2002,6 @@ router } ctx.body = renderSingleBlockView(block, filter, userId, search, viewMode, restricted); }) - .get("/public/latest", async (ctx) => { - if (!checkMod(ctx, 'latestMod')) { ctx.redirect('/modules'); return; } - const messages = sanitizeMessages(await post.latest()); - const spreadMap = await spreads.forMessages((messages || []).map(m => m && m.key)).catch(() => new Map()); - ctx.body = await latestView({ messages, spreadMap }); - }) - .get("/public/latest/extended", async (ctx) => { - if (!checkMod(ctx, 'extendedMod')) { ctx.redirect('/modules'); return; } - const messages = sanitizeMessages(await post.latestExtended()); - const spreadMap = await spreads.forMessages((messages || []).map(m => m && m.key)).catch(() => new Map()); - ctx.body = await extendedView({ messages, spreadMap }); - }) - .get("/public/latest/topics", async (ctx) => { - if (!checkMod(ctx, 'topicsMod')) { ctx.redirect('/modules'); return; } - const messages = sanitizeMessages(await post.latestTopics()); - const channels = await post.channels(); - const list = channels.map((c) => { - return li(a({ href: `/hashtag/${c}` }, `#${c}`)); - }); - const prefix = nav(ul(list)); - const spreadMap = await spreads.forMessages((messages || []).map(m => m && m.key)).catch(() => new Map()); - ctx.body = await topicsView({ messages, prefix, spreadMap }); - }) - .get("/public/latest/summaries", async (ctx) => { - if (!checkMod(ctx, 'summariesMod')) { ctx.redirect('/modules'); return; } - const messages = sanitizeMessages(await post.latestSummaries()); - const spreadMap = await spreads.forMessages((messages || []).map(m => m && m.key)).catch(() => new Map()); - ctx.body = await summaryView({ messages, spreadMap }); - }) - .get("/public/latest/threads", async (ctx) => { - if (!checkMod(ctx, 'threadsMod')) { ctx.redirect('/modules'); return; } - const messages = sanitizeMessages(await post.latestThreads()); - const spreadMap = await spreads.forMessages((messages || []).map(m => m && m.key)).catch(() => new Map()); - ctx.body = await threadsView({ messages, spreadMap }); - }) .get('/author/:feed', async (ctx) => { const feedId = decodeURIComponent(ctx.params.feed || ''), gt = Number(ctx.request.query.gt || -1), lt = Number(ctx.request.query.lt || -1); if (lt > 0 && gt > 0 && gt >= lt) throw new Error('Given search range is empty'); @@ -1814,7 +2039,7 @@ router const { bucket: lastActivityBucket } = inhabitantsModel.bucketLastActivity(fullLastTs || null); const profileItems = await fetchProfileItems(feedId, rawPrefs); const profileFilterType = String(ctx.query.type || '').toLowerCase(); - const profileSpreadable = new Set(['post','audio','video','image','document','torrent','bookmark','event','calendar','task','votes','vote','market','shop','shopProduct','project','industry','industryBuild','industryBlueprint','transfer','job','report','chat','chatMessage','pad','padEntry','forum','map']); + const profileSpreadable = new Set(['post','audio','video','image','document','torrent','bookmark','event','calendar','task','votes','vote','market','shop','shopProduct','project','industry','industryBuild','industryBlueprint','transfer','housing','job','report','chat','chatMessage','pad','padEntry','forum','map']); const profileSpreadKeys = (allActions || []).filter(a => a && a.id && typeof a.id === 'string' && a.id.startsWith('%') && /\.sha256$/.test(a.id) && profileSpreadable.has(a.type)).map(a => a.id); const spreadMap = await spreads.forMessages(profileSpreadKeys).catch(() => new Map()); await warmAuthorNames(allActions, sanitizedMsgs, profileItems); @@ -1836,6 +2061,7 @@ router const applySearchPrivacy = (msgs) => msgs.filter(msg => { const c = msg.value?.content; if (!c) return true; + if (c.type === 'pub') return false; if (Array.isArray(c.recps)) return false; if (c.type === 'post' && (c.private === true || c.recps)) return false; if (c.tribeId && anonTribeIds.has(c.tribeId)) return false; @@ -1901,7 +2127,7 @@ router const { filter = 'all', q = '', sort = 'recent' } = ctx.query; const viewerPrefs = await about.visibilityPrefs(getViewerId()).catch(() => null); const items = await imagesModel.listAll({ filter: filter === 'favorites' ? 'all' : filter, q, sort, viewerId: getViewerId() }); - const fav = await mediaFavorites.getFavoriteSet('images'); + const fav = await contentFavorites.getFavoriteSet('images'); let enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) })); if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite); enriched = await applyListFilters(enriched, ctx); @@ -1913,14 +2139,14 @@ router .get("/images/edit/:id", async (ctx) => { if (!checkMod(ctx, 'imagesMod')) { ctx.redirect('/modules'); return; } const img = await imagesModel.getImageById(ctx.params.id, getViewerId()); - const fav = await mediaFavorites.getFavoriteSet('images'); + const fav = await contentFavorites.getFavoriteSet('images'); ctx.body = await imageView([{ ...img, isFavorite: fav.has(String(img.rootId || img.key)) }], 'edit', img.key, { returnTo: ctx.query.returnTo || '' }); }) .get("/images/:imageId", async (ctx) => { if (!checkMod(ctx, 'imagesMod')) { ctx.redirect('/modules'); return; } const { imageId } = ctx.params; const { filter = 'all', q = '', sort = 'recent' } = ctx.query; const img = await imagesModel.getImageById(imageId, getViewerId()); - const fav = await mediaFavorites.getFavoriteSet('images'); + const fav = await contentFavorites.getFavoriteSet('images'); const comments = await getVoteComments(img.key); const imgAuthorPrefs = await about.visibilityPrefs(img.author).catch(() => null); await enrichItemLifetime(img, { key: img.key }); @@ -1931,7 +2157,7 @@ router const { filter = 'all', q = '', lat, lng, zoom, tribeId, title, description, markerLabel, tags, mapType } = ctx.query; const uid = getViewerId(); const items = await mapsModel.listAll({ filter: filter === 'favorites' ? 'all' : filter, q, viewerId: uid }); - const fav = await mediaFavorites.getFavoriteSet('maps'); + const fav = await contentFavorites.getFavoriteSet('maps'); let enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) })); if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite); const myTribeIds = await getUserTribeIds(uid); @@ -1952,7 +2178,7 @@ router try { mapItem = await mapsModel.getMapById(ctx.params.id, getViewerId()); } catch (_) { ctx.redirect('/maps?filter=all'); return; } if (!mapItem) { ctx.redirect('/maps?filter=all'); return; } if (mapItem.author !== getViewerId()) { ctx.redirect(`/maps/${encodeURIComponent(mapItem.key)}`); return; } - const fav = await mediaFavorites.getFavoriteSet('maps'); + const fav = await contentFavorites.getFavoriteSet('maps'); ctx.body = await mapsView([{ ...mapItem, isFavorite: fav.has(String(mapItem.rootId || mapItem.key)) }], 'edit', mapItem.key, { returnTo: ctx.query.returnTo || '' }); }) .get("/maps/:mapId", async (ctx) => { @@ -1968,7 +2194,7 @@ router return; } if (!mapItem) { ctx.redirect('/maps?filter=all'); return; } - const fav = await mediaFavorites.getFavoriteSet('maps'); + const fav = await contentFavorites.getFavoriteSet('maps'); let tribeMembers = []; let parentTribe = null; if (mapItem.tribeId) { @@ -1993,7 +2219,7 @@ router const { filter = 'all', q = '', sort = 'recent' } = ctx.query; const viewerPrefs = await about.visibilityPrefs(getViewerId()).catch(() => null); const items = await audiosModel.listAll({ filter: filter === 'favorites' ? 'all' : filter, q, sort, viewerId: getViewerId() }); - const fav = await mediaFavorites.getFavoriteSet('audios'); + const fav = await contentFavorites.getFavoriteSet('audios'); let enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) })); if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite); enriched = await applyListFilters(enriched, ctx); @@ -2005,14 +2231,14 @@ router .get("/audios/edit/:id", async (ctx) => { if (!checkMod(ctx, 'audiosMod')) { ctx.redirect('/modules'); return; } const audio = await audiosModel.getAudioById(ctx.params.id, getViewerId()); - const fav = await mediaFavorites.getFavoriteSet('audios'); + const fav = await contentFavorites.getFavoriteSet('audios'); ctx.body = await audioView([{ ...audio, isFavorite: fav.has(String(audio.rootId || audio.key)) }], 'edit', audio.key, { returnTo: ctx.query.returnTo || '' }); }) .get("/audios/:audioId", async (ctx) => { if (!checkMod(ctx, 'audiosMod')) { ctx.redirect('/modules'); return; } const { audioId } = ctx.params; const { filter = 'all', q = '', sort = 'recent' } = ctx.query; const audio = await audiosModel.getAudioById(audioId, getViewerId()); - const fav = await mediaFavorites.getFavoriteSet('audios'); + const fav = await contentFavorites.getFavoriteSet('audios'); const comments = await getVoteComments(audio.key); const audioAuthorPrefs = await about.visibilityPrefs(audio.author).catch(() => null); await enrichItemLifetime(audio, { key: audio.key }); @@ -2023,7 +2249,7 @@ router const { filter = 'all', q = '', sort = 'recent', tribeId = '' } = ctx.query; const viewerPrefs = await about.visibilityPrefs(getViewerId()).catch(() => null); const items = await torrentsModel.listAll({ filter: filter === 'favorites' ? 'all' : filter, q, sort, viewerId: getViewerId() }); - const fav = await mediaFavorites.getFavoriteSet('torrents'); + const fav = await contentFavorites.getFavoriteSet('torrents'); let enriched = items.filter(x => !x.tribeId).map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) })); if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite); enriched = await applyListFilters(enriched, ctx); @@ -2034,14 +2260,14 @@ router .get("/torrents/edit/:id", async (ctx) => { if (!checkMod(ctx, 'torrentsMod')) { ctx.redirect('/modules'); return; } const torrent = await torrentsModel.getTorrentById(ctx.params.id, getViewerId()); - const fav = await mediaFavorites.getFavoriteSet('torrents'); + const fav = await contentFavorites.getFavoriteSet('torrents'); ctx.body = await torrentsView([{ ...torrent, isFavorite: fav.has(String(torrent.rootId || torrent.key)) }], 'edit', torrent.key, { returnTo: ctx.query.returnTo || '' }); }) .get("/torrents/:torrentId", async (ctx) => { if (!checkMod(ctx, 'torrentsMod')) { ctx.redirect('/modules'); return; } const { torrentId } = ctx.params; const { filter = 'all', q = '', sort = 'recent' } = ctx.query; const torrent = await torrentsModel.getTorrentById(torrentId, getViewerId()); - const fav = await mediaFavorites.getFavoriteSet('torrents'); + const fav = await contentFavorites.getFavoriteSet('torrents'); const comments = await getVoteComments(torrent.key); const torrentAuthorPrefs = await about.visibilityPrefs(torrent.author).catch(() => null); await enrichItemLifetime(torrent, { key: torrent.key }); @@ -2052,7 +2278,7 @@ router const { filter = 'all', q = '', sort = 'recent' } = ctx.query; const viewerPrefs = await about.visibilityPrefs(getViewerId()).catch(() => null); const items = await videosModel.listAll({ filter: filter === 'favorites' ? 'all' : filter, q, sort, viewerId: getViewerId() }); - const fav = await mediaFavorites.getFavoriteSet('videos'); + const fav = await contentFavorites.getFavoriteSet('videos'); let enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) })); if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite); enriched = await applyListFilters(enriched, ctx); @@ -2064,14 +2290,14 @@ router .get("/videos/edit/:id", async (ctx) => { if (!checkMod(ctx, 'videosMod')) { ctx.redirect('/modules'); return; } const video = await videosModel.getVideoById(ctx.params.id, getViewerId()); - const fav = await mediaFavorites.getFavoriteSet('videos'); + const fav = await contentFavorites.getFavoriteSet('videos'); ctx.body = await videoView([{ ...video, isFavorite: fav.has(String(video.rootId || video.key)) }], 'edit', video.key, { returnTo: ctx.query.returnTo || '' }); }) .get("/videos/:videoId", async (ctx) => { if (!checkMod(ctx, 'videosMod')) { ctx.redirect('/modules'); return; } const { videoId } = ctx.params; const { filter = 'all', q = '', sort = 'recent' } = ctx.query; const video = await videosModel.getVideoById(videoId, getViewerId()); - const fav = await mediaFavorites.getFavoriteSet('videos'); + const fav = await contentFavorites.getFavoriteSet('videos'); const comments = await getVoteComments(video.key); const videoAuthorPrefs = await about.visibilityPrefs(video.author).catch(() => null); await enrichItemLifetime(video, { key: video.key }); @@ -2081,7 +2307,7 @@ router const { filter = 'all', q = '', sort = 'recent' } = ctx.query; const viewerPrefs = await about.visibilityPrefs(getViewerId()).catch(() => null); const items = await documentsModel.listAll({ filter: filter === 'favorites' ? 'all' : filter, q, sort }); - const fav = await mediaFavorites.getFavoriteSet('documents'); + const fav = await contentFavorites.getFavoriteSet('documents'); let enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) })); if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite); enriched = await applyListFilters(enriched, ctx); @@ -2092,13 +2318,13 @@ router }) .get("/documents/edit/:id", async (ctx) => { const doc = await documentsModel.getDocumentById(ctx.params.id); - const fav = await mediaFavorites.getFavoriteSet('documents'); + const fav = await contentFavorites.getFavoriteSet('documents'); ctx.body = await documentView([{ ...doc, isFavorite: fav.has(String(doc.rootId || doc.key)) }], 'edit', doc.key, { returnTo: ctx.query.returnTo || '' }); }) .get("/documents/:documentId", async (ctx) => { const { filter = "all", q = "", sort = "recent" } = ctx.query; const document = await documentsModel.getDocumentById(ctx.params.documentId); - const fav = await mediaFavorites.getFavoriteSet('documents'); + const fav = await contentFavorites.getFavoriteSet('documents'); Object.assign(document, { isFavorite: fav.has(String(document.rootId || document.key)) }); const comments = await getVoteComments(document.rootId || document.key); const docAuthorPrefs = await about.visibilityPrefs(document.author).catch(() => null); @@ -2150,18 +2376,21 @@ router ctx.body = await privateView({ messages }, returnFilter || 'all', decrypted); }) .get('/tags', async ctx => { - const filter = qf(ctx), tags = await tagsModel.listTags(filter); - ctx.body = await tagsView(tags, filter); + const filter = qf(ctx), search = String(ctx.query.search || '').trim(); + const tags = await tagsModel.listTags(filter, search); + ctx.body = await tagsView(tags, filter, search); }) .get('/reports', async ctx => { const filter = qf(ctx); + const q = String(ctx.query.q || '').trim(); let reports = await enrichWithComments(await reportsModel.listAll()); + reports = applyTextSearch(reports, q, ['title', 'description', 'category', 'tags']); reports = await applyListFilters(reports, ctx); try { reports = await lifetime.enrichAndFilter(reports); } catch (_) {} await enrichMsgSize(reports); const spreadMap = await spreads.forMessages((reports || []).map(x => x && (x.id || x.key))); await warmAuthorNames(reports); - ctx.body = await reportView(reports, filter, null, ctx.query.category || '', { spreadMap, prefillTitle: ctx.query.title || '', prefillDescription: ctx.query.description || '' }); + ctx.body = await reportView(reports, filter, null, ctx.query.category || '', { spreadMap, q, prefillTitle: ctx.query.title || '', prefillDescription: ctx.query.description || '' }); }) .get('/reports/edit/:id', async ctx => { const report = await reportsModel.getReportById(ctx.params.id); @@ -2172,23 +2401,36 @@ router const comments = await getVoteComments(reportId); await enrichMsgSize([report]); await enrichItemLifetime(report); - ctx.body = await singleReportView(withCount(report, comments), filter, comments, { spreads: await spreads.forMessage(report.id).catch(() => null) }); + ctx.body = await singleReportView(await withFavorite(withCount(report, comments), 'reports'), filter, comments, { spreads: await spreads.forMessage(report.id).catch(() => null) }); }) .get('/trending', async (ctx) => { - const filter = qf(ctx, 'TOP'); + const filter = qf(ctx, 'ALL'); + const q = String(ctx.query.q || '').trim(); let { filtered = [] } = await trendingModel.listTrending(filter); filtered = await applyListFilters(filtered, ctx); + if (q) { + const needle = q.toLowerCase(); + filtered = filtered.filter(m => { + const c = (m && m.value && m.value.content) || {}; + return [c.title, c.name, c.question, c.description, c.text, c.concept, ...(Array.isArray(c.tags) ? c.tags : [])] + .some(v => String(v || '').toLowerCase().includes(needle)); + }); + } const spreadMap = new Map(); const results = await Promise.all(filtered.map(it => it && it.key ? spreads.forMessage(it.key).catch(() => null) : Promise.resolve(null))); filtered.forEach((it, i) => { if (it && it.key && results[i]) spreadMap.set(it.key, results[i]); }); await warmAuthorNames(filtered); - ctx.body = await trendingView(filtered, filter, trendingModel.categories, spreadMap); + ctx.body = await trendingView(filtered, filter, trendingModel.categories, spreadMap, q); }) .get('/agenda', async (ctx) => { const filter = qf(ctx); + const q = String(ctx.query.q || '').trim(); let data = await agendaModel.listAgenda(filter); - if (Array.isArray(data)) data = await applyListFilters(data, ctx); - ctx.body = await agendaView(data, filter); + if (data && Array.isArray(data.items)) { + const visible = await applyListFilters(data.items, ctx); + data = { ...data, items: applyTextSearch(visible, q, ['title', 'description', 'name', 'concept', 'location', 'tags']) }; + } + ctx.body = await agendaView(data, filter, q); }) .get("/hashtag/:hashtag", async (ctx) => { const { hashtag } = ctx.params; @@ -2210,7 +2452,7 @@ router ctx.body = renderPendingFollows(enriched); return; } - if (['CVs', 'MATCHSKILLS'].includes(filter)) { + if (filter === 'CVs') { Object.assign(query, { location: ctx.query.location || '', language: ctx.query.language || '', @@ -2344,7 +2586,7 @@ router .get('/inhabitant/:id', async (ctx) => { const id = ctx.params.id; const aboutModel = about; - const [aboutMsg, cv, feed, photo, bank, lastTs, visibilityPrefs, carbonGrams, larpHouseKey, deviceSource] = await Promise.all([ + const [aboutMsg, cv, feed, photo, bank, lastTs, visibilityPrefs, carbonGrams, larpHouseKey, deviceSource, relationship] = await Promise.all([ inhabitantsModel.getLatestAboutById(id), inhabitantsModel.getCVByUserId(id), inhabitantsModel.getFeedByUserId(id), @@ -2354,7 +2596,8 @@ router aboutModel.visibilityPrefs(id).catch(() => null), getCarbonGramsForFeed(id).catch(() => 0), larpModel.getUserHouse(id).catch(() => null), - aboutModel.deviceSource(id).catch(() => null) + aboutModel.deviceSource(id).catch(() => null), + friend.getRelationship(id).catch(() => null) ]); const larpHouse = larpHouseKey ? { key: larpHouseKey, ...larpModel.getHouse(larpHouseKey) } : null; const bucketInfo = inhabitantsModel.bucketLastActivity(lastTs || null); @@ -2366,7 +2609,7 @@ router const totalClaimed = bank?.totalClaimed || 0; const oasisVersion = (String(id) === String(currentUserId) ? OASIS_VERSION : null) || await getOasisVersion(id); await warmAuthorNames(feed); - ctx.body = await inhabitantsProfileView({ about: aboutMsg, cv, feed, photo, karmaScore, estimatedUBI, lastClaimedDate, totalClaimed, carbonGrams, larpHouse, lastActivityBucket: bucketInfo.bucket, viewedId: id, visibilityPrefs, deviceSource, stats, oasisVersion }, currentUserId, fediverseModel.hasAccount()); + ctx.body = await inhabitantsProfileView({ about: aboutMsg, cv, feed, photo, relationship, karmaScore, estimatedUBI, lastClaimedDate, totalClaimed, carbonGrams, larpHouse, lastActivityBucket: bucketInfo.bucket, viewedId: id, visibilityPrefs, deviceSource, stats, oasisVersion }, currentUserId, fediverseModel.hasAccount()); }) .get('/parliament', async (ctx) => { if (!checkMod(ctx, 'parliamentMod')) return ctx.redirect('/modules'); @@ -2527,6 +2770,10 @@ router const forums = await listByTribeAllChain(tribe.id, 'forum'); const replies = await listByTribeAllChain(tribe.id, 'forum-reply'); sectionData = [...forums, ...replies]; + } else if (section === 'polls') { + sectionData = checkMod(ctx, 'pollsMod') + ? await pollsModel.listAll('ALL', { tribeId: tribe.id }).catch(() => []) + : []; } else if (section === 'subtribes') { sectionData = await tribesModel.listSubTribes(tribe.id, uid); } else if (mediaSections[section]) { @@ -2551,7 +2798,14 @@ router ]); const tribeChainSet = new Set(tribeChain); const toStandalone = (type, url) => (item) => ({ contentType: type, id: item.rootId || item.key, title: item.title || '', author: item.author, createdAt: item.createdAt, directUrl: url(item) }); + const allTribePolls = checkMod(ctx, 'pollsMod') + ? (await Promise.all([...tribeChainSet].map(tid => pollsModel.listAll('ALL', { tribeId: tid }).catch(() => [])))).flat() + : []; const standaloneItems = [ + ...allTribePolls.filter(p => !p.undecryptable).map(p => ({ + contentType: 'poll', id: p.id, title: p.question, author: p.author, + createdAt: p.createdAt, directUrl: `/tribe/${encodeURIComponent(tribe.id)}?section=polls` + })), ...allPadsRaw.filter(p => tribeChainSet.has(p.tribeId)).map(toStandalone('pad', p => `/pads/${encodeURIComponent(p.rootId)}`)), ...allChatsRaw.filter(c => tribeChainSet.has(c.tribeId)).map(toStandalone('chat', c => `/chats/${encodeURIComponent(c.rootId || c.key)}`)), ...allCalsRaw.filter(c => tribeChainSet.has(c.tribeId)).map(toStandalone('calendar', c => `/calendars/${encodeURIComponent(c.rootId)}`)), @@ -2786,7 +3040,7 @@ router ctx.body = await tribeView(tribe, uid, query, section, sectionData); }) .get('/activity', async ctx => { - const filter = qf(ctx, 'recent'), userId = getViewerId(); + const filter = qf(ctx, 'all'), userId = getViewerId(); const q = String((ctx.query && ctx.query.q) || ''); try { await bankingModel.ensureSelfAddressPublished(); } catch (_) {} try { await bankingModel.getUserEngagementScore(userId); } catch (_) {} @@ -2830,7 +3084,7 @@ router } allActions = await applyListFilters(allActions, ctx); const spreadMap = new Map(); - const SPREADABLE = new Set(['post','audio','video','image','document','torrent','bookmark','event','calendar','task','votes','vote','market','shop','shopProduct','project','industry','industryBuild','industryBlueprint','transfer','job','report','chat','chatMessage','pad','padEntry','forum','map']); + const SPREADABLE = new Set(['post','audio','video','image','document','torrent','bookmark','event','calendar','task','votes','vote','market','shop','shopProduct','project','industry','industryBuild','industryBlueprint','transfer','housing','job','report','chat','chatMessage','pad','padEntry','forum','map']); const targets = (allActions || []).filter(a => a && a.id && typeof a.id === 'string' && a.id.startsWith('%') && /\.sha256$/.test(a.id) && SPREADABLE.has(a.type)); const results = await Promise.all(targets.map(a => spreads.forMessage(a.id).catch(() => null))); targets.forEach((a, i) => { if (results[i]) spreadMap.set(a.id, results[i]); }); @@ -2860,7 +3114,8 @@ router } } } catch (_) {} - ctx.body = activityView(allActions, filter, userId, q, { spreadMap }); + const favIndex = await contentFavorites.getFavoriteIndex().catch(() => new Map()); + ctx.body = activityView(allActions, filter, userId, q, { spreadMap, favIndex, returnTo: `/activity?filter=${encodeURIComponent(filter)}` }); }) .get("/profile", async (ctx) => { const myFeedId = await meta.myFeedId(), gt = Number(ctx.request.query.gt || -1), lt = Number(ctx.request.query.lt || -1); @@ -2897,7 +3152,7 @@ router const baseUrl = resolveExternalBaseUrl(ctx); const profileItems = await fetchProfileItems(myFeedId, rawPrefs); const profileFilterType = String(ctx.query.type || '').toLowerCase(); - const profileSpreadable = new Set(['post','audio','video','image','document','torrent','bookmark','event','calendar','task','votes','vote','market','shop','shopProduct','project','industry','industryBuild','industryBlueprint','transfer','job','report','chat','chatMessage','pad','padEntry','forum','map']); + const profileSpreadable = new Set(['post','audio','video','image','document','torrent','bookmark','event','calendar','task','votes','vote','market','shop','shopProduct','project','industry','industryBuild','industryBlueprint','transfer','housing','job','report','chat','chatMessage','pad','padEntry','forum','map']); const profileSpreadKeys = (allActions || []).filter(a => a && a.id && typeof a.id === 'string' && a.id.startsWith('%') && /\.sha256$/.test(a.id) && profileSpreadable.has(a.type)).map(a => a.id); const spreadMap = await spreads.forMessages(profileSpreadKeys).catch(() => new Map()); ctx.body = await authorView({ feedId: myFeedId, oasisVersion: OASIS_VERSION || await getOasisVersion(myFeedId), messages: sanitizeMessages(messages), firstPost, lastPost, name, description, avatarUrl: getAvatarUrl(image), relationship: { me: true }, ecoAddress, karmaScore: bankData.karmaScore, estimatedUBI: bankData.estimatedUBI || 0, lastClaimedDate: bankData.lastClaimedDate || null, totalClaimed: bankData.totalClaimed || 0, carbonGrams, larpHouse, lastActivityBucket, visibilityPrefs, stats, baseUrl, userActions, allActions, profileItems, profileFilterType, gpgFingerprint, spreadMap, fediverseConfigured: fediverseModel.hasAccount() }); @@ -3071,9 +3326,6 @@ router } catch (e) { console.error('profile/clearnet-toggle:', e.message); } ctx.redirect('/profile'); }) - .get("/publish/custom", async (ctx) => { - ctx.body = await publishCustomView(); - }) .get("/json/:message", async (ctx) => { if (config.public) { throw new Error( @@ -3202,11 +3454,21 @@ router }, }).png().toBuffer(); }; + const avatarFallback = ctx.query.fallback === 'avatar'; + const fallbackImage = async () => { + ctx.set("Content-Type", "image/png"); + ctx.set("Cache-Control", "no-cache"); + if (avatarFallback) { + try { + return fs.readFileSync(path.join(__dirname, "../client/assets/images/default-avatar.png")); + } catch (_) {} + } + return fakeImage(); + }; try { - const buffer = await blob.getResolved({ blobId }); + const buffer = avatarFallback ? await blob.getCached({ blobId }) : await blob.getResolved({ blobId }); if (!buffer) { - ctx.set("Content-Type", "image/png"); - ctx.body = await fakeImage(); + ctx.body = await fallbackImage(); return; } const fileType = await FileType.fromBuffer(buffer); @@ -3221,8 +3483,7 @@ router ctx.body = buffer; } } catch (err) { - ctx.set("Content-Type", "image/png"); - ctx.body = await fakeImage(); + ctx.body = await fallbackImage(); } }) .get("/settings", async (ctx) => { @@ -3454,7 +3715,7 @@ router if (tr && tr.openInviteCode) h.openInviteCode = tr.openInviteCode; } } catch (_) {} - ctx.body = larpListView({ filter, houses, myHouseKey, cycle, governingKey, governingHouse, governingMembers, governingPosts, canPost }); + ctx.body = larpListView({ filter, houses, myHouseKey, cycle, governingKey, governingHouse, governingMembers, governingPosts, canPost, q: String(ctx.query.q || '').trim() }); }) .get("/larp/test", async (ctx) => { if (!checkMod(ctx, 'larpMod')) return ctx.redirect('/modules'); @@ -3575,7 +3836,7 @@ router const text = stripDangerousTags(String(b.text || '')); const myFeedId = await meta.myFeedId(); const myHouseKey = await larpModel.getUserHouse(myFeedId).catch(() => null); - if (myHouseKey !== houseKey || houseKey === 'academia') { ctx.redirect('/larp'); return; } + if (myHouseKey !== houseKey) { ctx.redirect('/larp'); return; } try { await larpModel.publishHousePost({ house: houseKey, text }); } catch (e) { if (isSsbTooLargeError(e)) { sendErrorPage(ctx, require('../views/main_views').i18n.publishTooLong || 'Your post is too long. Please shorten it.', { status: 400 }); return; } @@ -3593,58 +3854,34 @@ router ctx.body = await likesView({ messages, feed, name: await about.name(feed), spreadMap }); }) .get("/mentions", async (ctx) => { - const { messages, myFeedId } = await post.mentionsMe(); - const tribeMentions = []; - try { - const allTribes = await tribesModel.listAll(); - const myTribes = allTribes.filter(t => t.members.includes(myFeedId)); - for (const t of myTribes) { - const items = await tribesContentModel.listByTribe(t.id, null).catch(() => []); - for (const item of items) { - const text = (item.description || '') + ' ' + (item.title || ''); - if (text.includes(myFeedId) || text.includes(myFeedId.slice(1))) { - tribeMentions.push({ - key: item.id, - value: { - author: item.author, - timestamp: Date.parse(item.createdAt) || item._ts || Date.now(), - content: { - type: 'tribe-content', - text: item.description || item.title || '', - tribeId: t.id, - tribeName: t.title, - contentType: item.contentType, - mentions: { _self: [{ link: myFeedId }] } - } - } - }); - } - } - } - } catch (_) {} - const combined = [...(Array.isArray(messages) ? messages : []), ...tribeMentions]; - for (const msg of combined) { - if (!msg.value) continue; - const authorId = msg.value.author; - if (authorId) { - if (!msg.value.meta) msg.value.meta = {}; - if (!msg.value.meta.author) msg.value.meta.author = {}; - if (!msg.value.meta.author.name) { - try { msg.value.meta.author.name = await about.name(authorId); } catch (_) {} - } - } - } - ctx.body = await mentionsView({ messages: combined, myFeedId }); + const filter = String(ctx.query.filter || 'ALL'); + const q = String(ctx.query.q || '').trim(); + const all = await mentionsModel.listMentions('ALL', { q }); + const counts = await mentionsModel.countTypes(all); + const items = filter === 'ALL' ? all : all.filter(x => x.type === filter); + await warmAuthorNames(items); + try { mentionsModel.markSeen(all); } catch (_) {} + sharedState.setMentionsCount(0); + ctx.body = await mentionsView(items, filter, { counts, total: all.length, q }); }) .get('/opinions', async (ctx) => { - const filter = qf(ctx, 'TOP'); + const filter = qf(ctx, 'ALL'); + const q = String(ctx.query.q || '').trim(); let opinions = await opinionsModel.listOpinions(filter); if (Array.isArray(opinions)) opinions = await applyListFilters(opinions, ctx); + if (q && Array.isArray(opinions)) { + const needle = q.toLowerCase(); + opinions = opinions.filter(m => { + const c = (m && m.value && m.value.content) || {}; + return [c.title, c.name, c.question, c.description, c.text, c.concept, ...(Array.isArray(c.tags) ? c.tags : [])] + .some(v => String(v || '').toLowerCase().includes(needle)); + }); + } const spreadMap = new Map(); const list = Array.isArray(opinions) ? opinions : []; const results = await Promise.all(list.map(it => it && it.key ? spreads.forMessage(it.key).catch(() => null) : Promise.resolve(null))); list.forEach((it, i) => { if (it && it.key && results[i]) spreadMap.set(it.key, results[i]); }); - ctx.body = await opinionsView(opinions, filter, spreadMap); + ctx.body = await opinionsView(opinions, filter, spreadMap, q); }) .get("/feed", async (ctx) => { const filter = String(ctx.query.filter || "ALL").toUpperCase(); @@ -3665,8 +3902,9 @@ router const feed = await feedModel.getFeedById(ctx.params.feedId); if (!feed) { ctx.redirect('/feed'); return; } const comments = await feedModel.getComments(ctx.params.feedId).catch(() => []); - ctx.body = singleFeedView(feed, comments); + ctx.body = singleFeedView(feed, comments, { spreads: await spreads.forMessage(feed.key).catch(() => null) }); }) + .get("/multiverse", async (ctx) => { ctx.redirect('/fediverse'); }) .get("/fediverse", async (ctx) => { if (!checkMod(ctx, 'fediverseMod')) { ctx.redirect('/modules'); return; } const account = fediverseModel.getAccount(); @@ -3816,15 +4054,186 @@ router try { await post.publishFediverseHandle(''); } catch (_) {} ctx.redirect('/settings'); }) + .get('/data', async ctx => { + const filter = String(ctx.query.filter || 'ALL').toUpperCase(); + const q = String(ctx.query.q || '').trim(); + const [{ matches, total, hasProfile }, cohesion] = await Promise.all([ + dataModel.listMatches(filter, { q }), + dataModel.cohesion().catch(() => null) + ]); + await warmAuthorNames(matches); + ctx.body = await dataView({ filter, q, matches, total, hasProfile, cohesion }); + }) + .get('/polls', async ctx => { + if (!checkMod(ctx, 'pollsMod')) { ctx.redirect('/modules'); return; } + const filter = String(ctx.query.filter || 'ALL').toUpperCase(); + const q = String(ctx.query.q || '').trim(); + if (filter === 'CREATE') { ctx.body = await pollsView([], 'CREATE', { q }); return; } + if (filter === 'EDIT') { + const poll = await pollsModel.getPollById(String(ctx.query.id || ''), getViewerId()).catch(() => null); + if (!poll || poll.author !== getViewerId()) { ctx.redirect('/polls'); return; } + ctx.body = await pollsView([], 'EDIT', { poll }); + return; + } + const fav = await contentFavorites.getFavoriteSet('polls'); + let polls = await pollsModel.listAll(filter, { q, favorites: [...fav] }); + polls = polls.map(p => ({ ...p, isFavorite: fav.has(String(p.id)) })); + polls = await applyListFilters(polls, ctx); + try { polls = await lifetime.enrichAndFilter(polls, { getKey: (x) => x.id }); } catch (_) {} + const spreadMap = await spreads.forMessages(polls.map(p => p.id)).catch(() => new Map()); + await warmAuthorNames(polls); + ctx.body = await pollsView(polls, filter, { q, spreadMap }); + }) + .get('/polls/:pollId', async ctx => { + if (!checkMod(ctx, 'pollsMod')) { ctx.redirect('/modules'); return; } + const poll = await pollsModel.getPollById(ctx.params.pollId, getViewerId()); + if (poll.chatId) { ctx.redirect(`/chats/${encodeURIComponent(poll.chatId)}`); return; } + const comments = await getVoteComments(poll.id); + const fav = await contentFavorites.getFavoriteSet('polls'); + await enrichItemLifetime(poll, { key: poll.id }); + await warmAuthorNames([poll], comments); + ctx.body = await singlePollView( + { ...poll, isFavorite: fav.has(String(poll.id)) }, + comments, + { + spreads: await spreads.forMessage(poll.id).catch(() => null), + filter: String(ctx.query.filter || 'ALL').toUpperCase(), + q: String(ctx.query.q || '').trim() + } + ); + }) + .post('/polls/create', koaBody(), async ctx => { + if (!checkMod(ctx, 'pollsMod')) { ctx.redirect('/modules'); return; } + const b = ctx.request.body || {}; + try { + await pollsModel.createPoll({ + question: stripDangerousTags(b.question), + options: stripDangerousTags(String(b.options || '')).split('\n'), + anonymous: [].concat(b.anonymous).includes('1'), + multiple: [].concat(b.multiple).includes('1'), + deadline: b.deadline, + tags: b.tags + }); + } catch (err) { sendErrorPage(ctx, err.message || String(err), { status: 400 }); return; } + try { activityModel.invalidateCache(); } catch (_) {} + ctx.redirect('/polls?filter=MINE'); + }) + .post('/polls/update/:id', koaBody(), async ctx => { + if (!checkMod(ctx, 'pollsMod')) { ctx.redirect('/modules'); return; } + const b = ctx.request.body || {}; + try { + await pollsModel.updatePoll(ctx.params.id, { + question: stripDangerousTags(b.question), + options: stripDangerousTags(String(b.options || '')).split('\n'), + anonymous: [].concat(b.anonymous).includes('1'), + multiple: [].concat(b.multiple).includes('1'), + deadline: b.deadline, + tags: b.tags + }); + } catch (err) { sendErrorPage(ctx, err.message || String(err), { status: 400 }); return; } + ctx.redirect(`/polls/${encodeURIComponent(ctx.params.id)}`); + }) + .post('/polls/vote/:id', koaBody(), async ctx => { + if (!checkMod(ctx, 'pollsMod')) { ctx.redirect('/modules'); return; } + const choices = [].concat(ctx.request.body.choices || []).filter(Boolean); + try { await pollsModel.vote(ctx.params.id, choices); } + catch (err) { sendErrorPage(ctx, err.message || String(err), { status: 400 }); return; } + try { activityModel.invalidateCache(); } catch (_) {} + ctx.redirect(safeReturnTo(ctx, `/polls/${encodeURIComponent(ctx.params.id)}`, ['/polls', '/chats', '/tribe'])); + }) + .post('/polls/close/:id', koaBody(), async ctx => { + if (!checkMod(ctx, 'pollsMod')) { ctx.redirect('/modules'); return; } + try { await pollsModel.closePoll(ctx.params.id); } catch (_) {} + ctx.redirect(safeReturnTo(ctx, `/polls/${encodeURIComponent(ctx.params.id)}`, ['/polls', '/chats', '/tribe'])); + }) + .post('/polls/delete/:id', koaBody(), async ctx => { + if (!checkMod(ctx, 'pollsMod')) { ctx.redirect('/modules'); return; } + let poll = null; + try { poll = await pollsModel.getPollById(ctx.params.id, getViewerId()); } catch (_) {} + try { await pollsModel.deletePoll(ctx.params.id); } catch (_) {} + if (poll && poll.chatId) { ctx.redirect(`/chats/${encodeURIComponent(poll.chatId)}`); return; } + ctx.redirect('/polls?filter=MINE'); + }) + .post('/polls/opinions/:pollId/:category', koaBody(), async ctx => { + if (!checkMod(ctx, 'pollsMod')) { ctx.redirect('/modules'); return; } + try { await pollsModel.createOpinion(ctx.params.pollId, ctx.params.category); } catch (_) {} + try { activityModel.invalidateCache(); } catch (_) {} + ctx.redirect(safeReturnTo(ctx, `/polls/${encodeURIComponent(ctx.params.pollId)}`, ['/polls'])); + }) + .post('/polls/:pollId/comments', koaBodyMiddleware, async ctx => commentAction(ctx, 'polls', 'pollId')) + .post('/polls/favorites/add/:id', koaBody(), async ctx => favAction(ctx, 'polls', 'add')) + .post('/polls/favorites/remove/:id', koaBody(), async ctx => favAction(ctx, 'polls', 'remove')) + .get('/blogs', async ctx => { + if (!checkMod(ctx, 'blogsMod')) { ctx.redirect('/modules'); return; } + const filter = String(ctx.query.filter || 'ALL').toUpperCase(); + const q = String(ctx.query.q || '').trim(); + if (filter === 'CREATE') { ctx.body = await blogView([], 'CREATE', { q }); return; } + const fav = await contentFavorites.getFavoriteSet('blogs'); + let blogs = await blogModel.listAll(filter, { q, favorites: [...fav] }); + blogs = await applyListFilters(blogs, ctx); + const spreadMap = await spreads.forMessages((blogs || []).map(b => b && b.id)).catch(() => new Map()); + await warmAuthorNames(blogs); + ctx.body = await blogView(blogs, filter, { q, spreadMap }); + }) + .get('/blogs/:blogId', async ctx => { + if (!checkMod(ctx, 'blogsMod')) { ctx.redirect('/modules'); return; } + let blog = await blogModel.getBlogById(ctx.params.blogId).catch(() => null); + if (!blog) { + const target = await blogModel.blogHrefFor(ctx.params.blogId).catch(() => null); + if (target && !target.startsWith(`/blogs/${encodeURIComponent(ctx.params.blogId)}`)) { ctx.redirect(target); return; } + ctx.redirect('/blogs'); + return; + } + const comments = await getVoteComments(blog.id); + const fav = await contentFavorites.getFavoriteSet('blogs'); + await warmAuthorNames([blog], comments); + ctx.body = await singleBlogView( + { ...blog, isFavorite: fav.has(String(blog.id)) }, + comments, + { spreads: await spreads.forMessage(blog.id).catch(() => null) } + ); + }) + .post('/blogs/create', koaBody({ multipart: true, urlencoded: true, formidable: { multiples: true, maxFileSize: maxSize } }), async ctx => { + if (!checkMod(ctx, 'blogsMod')) { ctx.redirect('/modules'); return; } + const b = ctx.request.body; + let text = stripDangerousTags((b.text || '').toString().trim()); + const subject = stripDangerousTags((b.subject || '').toString().trim()); + const blobMarkdown = await handleBlobUploads(ctx, 'blob', 9); + if (blobMarkdown.length) text += blobMarkdown.join(''); + const allowComments = [].concat(b.allowComments).includes('1'); + let mentions = []; + try { mentions = await extractMentions(text); } catch (_) { mentions = []; } + try { + await blogModel.createBlog({ text, subject, mentions, allowComments }); + } catch (err) { sendErrorPage(ctx, err.message || String(err), { status: 400 }); return; } + try { activityModel.invalidateCache(); } catch (_) {} + ctx.redirect('/blogs?filter=MINE'); + }) + .post('/blogs/opinions/:blogId/:category', koaBody(), async ctx => { + if (!checkMod(ctx, 'blogsMod')) { ctx.redirect('/modules'); return; } + try { await blogModel.createOpinion(ctx.params.blogId, ctx.params.category); } catch (_) {} + try { activityModel.invalidateCache(); } catch (_) {} + ctx.redirect(safeReturnTo(ctx, `/blogs/${encodeURIComponent(ctx.params.blogId)}`, ['/blogs'])); + }) + .post('/blogs/:blogId/comments', koaBodyMiddleware, async ctx => { + if (!checkMod(ctx, 'blogsMod')) { ctx.redirect('/modules'); return; } + const blog = await blogModel.getBlogById(ctx.params.blogId).catch(() => null); + if (!blog || !blog.allowComments) { ctx.redirect(`/blogs/${encodeURIComponent(ctx.params.blogId)}`); return; } + return commentAction(ctx, 'blogs', 'blogId'); + }) + .post('/blogs/favorites/add/:id', koaBody(), async ctx => favAction(ctx, 'blogs', 'add')) + .post('/blogs/favorites/remove/:id', koaBody(), async ctx => favAction(ctx, 'blogs', 'remove')) .get('/forum', async ctx => { if (!checkMod(ctx, 'forumMod')) { ctx.redirect('/modules'); return; } - const filter = qf(ctx, 'hot'); + const filter = qf(ctx, 'all'); + const q = String(ctx.query.q || '').trim(); let forums = await forumModel.listAll(filter); + forums = applyTextSearch(forums, q, ['title', 'text', 'category']); forums = await applyListFilters(forums, ctx); try { forums = await lifetime.enrichAndFilter(forums); } catch (_) {} const spreadMap = await spreads.forMessages((forums || []).map(x => x && (x.key || x.id))); await warmAuthorNames(forums); - ctx.body = await forumView(forums, filter, { spreadMap }); + ctx.body = await forumView(forums, filter, { spreadMap, q }); }) .get('/forum/:forumId', async ctx => { const msg = await forumModel.getMessageById(ctx.params.forumId), isReply = Boolean(msg.root), forumId = isReply ? msg.root : ctx.params.forumId; @@ -3833,7 +4242,7 @@ router try { const oi = await forumModel.getOpenInvite(forumId).catch(() => null); if (oi && forumObj) forumObj.openInviteCode = oi.code; } catch (_) {} const forumMsgs = await forumModel.getMessagesByForumId(forumId); await warmAuthorNames(forumObj, forumMsgs); - ctx.body = await singleForumView(forumObj, forumMsgs, ctx.query.filter, ctx.query.topic || ''); + ctx.body = await singleForumView(await withFavorite(forumObj, 'forum'), forumMsgs, ctx.query.filter, isReply ? ctx.params.forumId : null, { spreads: spreadInfo }); }) .get('/welcome', async (ctx) => { const lang = ctx.cookies.get('language') || getConfig().language || 'en'; @@ -3842,6 +4251,7 @@ router profile: true, federation: checkMod(ctx, 'invitesMod'), larp: checkMod(ctx, 'larpMod'), + ux: true, backup: checkMod(ctx, 'legacyMod'), greeting: checkMod(ctx, 'feedMod') }; @@ -3874,10 +4284,26 @@ router } catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }); return; } ctx.redirect('/welcome'); }) + .post('/welcome/ux', koaBody(), async (ctx) => { + const cfg = getConfig(); + const v = String((ctx.request.body || {}).ux || '').trim().toLowerCase(); + const aiNavEnabled = cfg.modules && cfg.modules.aiNavMod === 'on'; + const chatsEnabled = cfg.modules && cfg.modules.chatsMod === 'on'; + const next = (v === 'ainav' && aiNavEnabled) ? 'ainav' : (v === 'chats' && chatsEnabled) ? 'chats' : 'blocks'; + cfg.ux = { ...(cfg.ux && typeof cfg.ux === 'object' ? cfg.ux : {}), current: next }; + saveConfig(cfg); + try { onboardingModel.markStep('ux'); } catch (_) {} + ctx.redirect('/welcome'); + }) .post('/welcome/dismiss', koaBody(), async (ctx) => { try { onboardingModel.dismiss(); } catch (_) {} safeRefererRedirect(ctx, '/'); }) + .post('/ai/suggestion/dismiss', koaBody(), async (ctx) => { + const current = sharedState.getBestMatch ? sharedState.getBestMatch() : null; + if (current && current.href) sharedState.setDismissedSuggestion(current.href); + safeRefererRedirect(ctx, '/'); + }) .get('/legacy', async (ctx) => { if (!checkMod(ctx, 'legacyMod')) return ctx.redirect('/modules'); try { ctx.body = await legacyView(); } catch (error) { sendErrorPage(ctx, error.message); } @@ -3925,7 +4351,7 @@ router .get('/bookmarks', async (ctx) => { if (!checkMod(ctx, 'bookmarksMod')) return ctx.redirect('/modules'); const filter = qf(ctx), q = ctx.query.q || '', sort = ctx.query.sort || 'recent', viewerId = getViewerId(); - const favs = await mediaFavorites.getFavoriteSet("bookmarks"); + const favs = await contentFavorites.getFavoriteSet("bookmarks"); let bookmarks = (await bookmarksModel.listAll({ viewerId, filter: filter === "favorites" ? "all" : filter, q, sort })).map(b => ({ ...b, isFavorite: favs.has(String(b.rootId || b.id)) })); if (filter === "favorites") bookmarks = bookmarks.filter(b => b.isFavorite); bookmarks = await applyListFilters(bookmarks, ctx); @@ -3936,25 +4362,27 @@ router }) .get("/bookmarks/edit/:id", async (ctx) => { if (!checkMod(ctx, 'bookmarksMod')) return ctx.redirect('/modules'); - const bookmark = await bookmarksModel.getBookmarkById(ctx.params.id, getViewerId()), favs = await mediaFavorites.getFavoriteSet("bookmarks"); + const bookmark = await bookmarksModel.getBookmarkById(ctx.params.id, getViewerId()), favs = await contentFavorites.getFavoriteSet("bookmarks"); ctx.body = await bookmarkView([{ ...bookmark, isFavorite: favs.has(String(bookmark.rootId || bookmark.id)) }], "edit", bookmark.id, { returnTo: ctx.query.returnTo || "" }); }) .get('/bookmarks/:bookmarkId', async (ctx) => { if (!checkMod(ctx, 'bookmarksMod')) return ctx.redirect('/modules'); - const filter = qf(ctx), q = ctx.query.q || '', sort = ctx.query.sort || 'recent', favs = await mediaFavorites.getFavoriteSet("bookmarks"); + const filter = qf(ctx), q = ctx.query.q || '', sort = ctx.query.sort || 'recent', favs = await contentFavorites.getFavoriteSet("bookmarks"); const bookmark = await bookmarksModel.getBookmarkById(ctx.params.bookmarkId), root = bookmark.rootId || bookmark.id, comments = await getVoteComments(root); await enrichItemLifetime(bookmark); ctx.body = await singleBookmarkView({ ...bookmark, commentCount: comments.length, isFavorite: favs.has(String(root)) }, filter, comments, { q, sort, returnTo: safeReturnTo(ctx, `/bookmarks?filter=${encodeURIComponent(filter)}`, ['/bookmarks']), spreads: await spreads.forMessage(bookmark.id) }); }) .get('/tasks', async ctx => { const filter = qf(ctx); + const q = String(ctx.query.q || '').trim(); let tasks = await enrichWithComments(await tasksModel.listAll()); + tasks = applyTextSearch(tasks, q, ['title', 'description', 'location', 'tags']); tasks = await applyListFilters(tasks, ctx); try { tasks = await lifetime.enrichAndFilter(tasks); } catch (_) {} await enrichMsgSize(tasks); const spreadMap = await spreads.forMessages((tasks || []).map(x => x && (x.id || x.key))); await warmAuthorNames(tasks); - ctx.body = await taskView(tasks, filter, null, ctx.query.returnTo, { spreadMap, prefillTitle: ctx.query.title || '', prefillDescription: ctx.query.description || '' }); + ctx.body = await taskView(tasks, filter, null, ctx.query.returnTo, { spreadMap, q, prefillTitle: ctx.query.title || '', prefillDescription: ctx.query.description || '' }); }) .get('/tasks/edit/:id', async ctx => { const id = ctx.params.id; @@ -3966,19 +4394,21 @@ router const comments = await getVoteComments(taskId); await enrichMsgSize([task]); await enrichItemLifetime(task); - ctx.body = await singleTaskView(withCount(task, comments), filter, comments, { spreads: await spreads.forMessage(task.id).catch(() => null) }); + ctx.body = await singleTaskView(await withFavorite(withCount(task, comments), 'tasks'), filter, comments, { spreads: await spreads.forMessage(task.id).catch(() => null) }); }) .get('/events', async (ctx) => { if (!checkMod(ctx, 'eventsMod')) { ctx.redirect('/modules'); return; } const filter = qf(ctx); + const q = String(ctx.query.q || '').trim(); let events = await enrichWithComments(await eventsModel.listAll(null, filter)); + events = applyTextSearch(events, q, ['title', 'description', 'location', 'tags']); events = await applyListFilters(events, ctx); try { events = await lifetime.enrichAndFilter(events); } catch (_) {} await enrichMsgSize(events); const viewerPrefs = await about.visibilityPrefs(getViewerId()).catch(() => null); const spreadMap = await spreads.forMessages((events || []).map(x => x && (x.id || x.key))); await warmAuthorNames(events); - ctx.body = await eventView(events, filter, null, ctx.query.returnTo, { viewerPrefs, spreadMap }); + ctx.body = await eventView(events, filter, null, ctx.query.returnTo, { viewerPrefs, spreadMap, q }); }) .get('/events/edit/:id', async (ctx) => { if (!checkMod(ctx, 'eventsMod')) { ctx.redirect('/modules'); return; } @@ -3999,7 +4429,7 @@ router try { const oi = await eventsModel.getOpenInvite(eventId).catch(() => null); if (oi) event.openInviteCode = oi.code; } catch (_) {} const evAuthorPrefs2 = await about.visibilityPrefs(event.organizer).catch(() => null); await warmAuthorNames(event, comments); - ctx.body = await singleEventView(withCount(event, comments), filter, comments, { mapData, baseUrl: resolveExternalBaseUrl(ctx), authorPrefs: evAuthorPrefs2, linkedCalendarId, spreads: await spreads.forMessage(event.id).catch(() => null) }); + ctx.body = await singleEventView(await withFavorite(withCount(event, comments), 'events'), filter, comments, { mapData, baseUrl: resolveExternalBaseUrl(ctx), authorPrefs: evAuthorPrefs2, linkedCalendarId, spreads: await spreads.forMessage(event.id).catch(() => null) }); }) .get('/c/events/:eventId', async (ctx) => { let event; @@ -4020,26 +4450,28 @@ router }) .get('/votes', async ctx => { const filter = qf(ctx); + const q = String(ctx.query.q || '').trim(); let voteList = await enrichWithComments(await votesModel.listAll(filter)); voteList = await applyListFilters(voteList, ctx); + voteList = applyTextSearch(voteList, q, ['question', 'tags']); try { voteList = await lifetime.enrichAndFilter(voteList, { getAuthor: (x) => x.createdBy }); } catch (_) {} await enrichMsgSize(voteList); const spreadMap = await spreads.forMessages((voteList || []).map(x => x && (x.id || x.key))); await warmAuthorNames(voteList); - ctx.body = await voteView(voteList, filter, null, [], filter, { spreadMap }); + ctx.body = await voteView(voteList, filter, null, [], filter, { spreadMap, q, ...voteFormState(ctx) }); }) .get('/votes/edit/:id', async ctx => { const id = ctx.params.id; const activeFilter = (ctx.query.filter || 'mine'); const voteData = await votesModel.getVoteById(id); - ctx.body = await voteView([voteData], 'edit', id, [], activeFilter); + ctx.body = await voteView([voteData], 'edit', id, [], activeFilter, voteFormState(ctx)); }) .get('/votes/:voteId', async ctx => { const { voteId } = ctx.params, filter = qf(ctx), voteData = await votesModel.getVoteById(voteId); const comments = await getVoteComments(voteId); await enrichMsgSize([voteData]); await enrichItemLifetime(voteData, { author: voteData.createdBy }); - ctx.body = await voteView([withCount(voteData, comments)], 'detail', voteId, comments, filter, { spreads: await spreads.forMessage(voteId).catch(() => null) }); + ctx.body = await voteView([await withFavorite(withCount(voteData, comments), 'votes')], 'detail', voteId, comments, filter, { spreads: await spreads.forMessage(voteId).catch(() => null) }); }) .get("/market", async (ctx) => { if (!checkMod(ctx, 'marketMod')) { ctx.redirect('/modules'); return; } @@ -4086,7 +4518,7 @@ router return `/market${params.length ? `?${params.join("&")}` : ""}` })() await enrichItemLifetime(item, { author: item.seller, createdAt: item.updatedAt || item.createdAt }) - ctx.body = await singleMarketView(withCount(item, comments), filter, comments, { q, minPrice, maxPrice, sort, returnTo, mapData, zoom, spreads: await spreads.forMessage(item.id).catch(() => null) }) + ctx.body = await singleMarketView(await withFavorite(withCount(item, comments), 'market'), filter, comments, { q, minPrice, maxPrice, sort, returnTo, mapData, zoom, spreads: await spreads.forMessage(item.id).catch(() => null) }) }) .get('/jobs', async (ctx) => { if (!checkMod(ctx, 'jobsMod')) { ctx.redirect('/modules'); return; } @@ -4174,7 +4606,63 @@ router } const jobAuthorPrefs2 = await about.visibilityPrefs(job.author).catch(() => null); await enrichItemLifetime(job) - ctx.body = await singleJobsView(withCount(job, comments), filter, comments, { ...params, mapData, candidates, baseUrl: resolveExternalBaseUrl(ctx), authorPrefs: jobAuthorPrefs2, spreads: await spreads.forMessage(job.id).catch(() => null) }) + ctx.body = await singleJobsView(await withFavorite(withCount(job, comments), 'jobs'), filter, comments, { ...params, mapData, candidates, baseUrl: resolveExternalBaseUrl(ctx), authorPrefs: jobAuthorPrefs2, spreads: await spreads.forMessage(job.id).catch(() => null) }) + }) + .get('/housing', async (ctx) => { + if (!checkMod(ctx, 'housingMod')) { ctx.redirect('/modules'); return; } + const filter = String(ctx.query.filter || 'ALL').toUpperCase() + const query = { + search: ctx.query.search || '', + minPrice: ctx.query.minPrice ?? '', + maxPrice: ctx.query.maxPrice ?? '', + place: ctx.query.place || '', + sort: ctx.query.sort || 'recent' + } + query.maxImages = housingModel.MAX_IMAGES + if (filter === 'CREATE') { ctx.body = await housingView([], 'CREATE', query); return } + const viewerId = getViewerId() + let items = await housingModel.listHousing(filter, viewerId, query) + await enrichWithComments(items) + items = await applyListFilters(items, ctx) + if (filter !== 'MINE') { + try { items = await lifetime.enrichAndFilter(items); } catch (_) {} + } + await enrichMsgSize(items) + const spreadMap = await spreads.forMessages((items || []).map(x => x && (x.id || x.key))); + await warmAuthorNames(items); + ctx.body = await housingView(items, filter, { ...query, spreadMap }) + }) + .get('/housing/edit/:id', async (ctx) => { + if (!checkMod(ctx, 'housingMod')) { ctx.redirect('/modules'); return; } + let item; + try { item = await housingModel.getHousingById(ctx.params.id, getViewerId()); } catch (_) { ctx.redirect('/housing?filter=MINE'); return; } + if (!item || String(item.author) !== String(getViewerId())) { ctx.redirect('/housing?filter=MINE'); return; } + ctx.body = await housingView([item], 'EDIT', { maxImages: housingModel.MAX_IMAGES }) + }) + .get('/housing/:housingId', async (ctx) => { + if (!checkMod(ctx, 'housingMod')) { ctx.redirect('/modules'); return; } + let housingId = ctx.params.housingId + if (housingId && housingId.startsWith('%25')) { + try { housingId = decodeURIComponent(housingId); } catch (_) {} + } + const filter = String(ctx.query.filter || 'ALL').toUpperCase() + const params = { + search: ctx.query.search || '', + minPrice: ctx.query.minPrice ?? '', + maxPrice: ctx.query.maxPrice ?? '', + place: ctx.query.place || '', + sort: ctx.query.sort || 'recent', + returnTo: safeReturnTo(ctx, `/housing?filter=${encodeURIComponent(filter)}`, ['/housing']) + } + let item; + try { item = await housingModel.getHousingById(housingId, getViewerId()); } catch (e) { + sendErrorPage(ctx, `Housing not found or invalid id: ${housingId}`, { status: 404 }); return; + } + if (!item) { sendErrorPage(ctx, `Housing not found: ${housingId}`, { status: 404 }); return; } + const [comments, mapData] = await Promise.all([getVoteComments(housingId), resolveMapUrl(item.mapUrl)]) + await enrichMsgSize([item]) + await enrichItemLifetime(item) + ctx.body = await singleHousingView(await withFavorite(withCount(item, comments), 'housing'), filter, comments, { ...params, mapData, spreads: await spreads.forMessage(item.id).catch(() => null) }) }) .get('/c/jobs/:jobId', async (ctx) => { let job; @@ -4210,7 +4698,7 @@ router return; } const items = await shopsModel.listAll({ filter: filter === 'favorites' ? 'all' : filter, q, sort, viewerId: getViewerId() }); - const fav = await mediaFavorites.getFavoriteSet('shops'); + const fav = await contentFavorites.getFavoriteSet('shops'); let enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) })); if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite); enriched = await applyListFilters(enriched, ctx); @@ -4227,7 +4715,7 @@ router if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; } const shop = await shopsModel.getShopById(ctx.params.id); if (!shop) { ctx.redirect('/shops'); return; } - const fav = await mediaFavorites.getFavoriteSet('shops'); + const fav = await contentFavorites.getFavoriteSet('shops'); ctx.body = await shopsView([{ ...shop, isFavorite: fav.has(String(shop.rootId || shop.key)) }], 'edit', shop, { returnTo: ctx.query.returnTo || '' }); }) .get("/shops/product/edit/:id", async (ctx) => { @@ -4245,7 +4733,8 @@ router const myPurchases = await shopsModel.listMyPurchases().catch(() => []); const productRootId = product.rootId || product.key; const canRate = myPurchases.some(o => o.productId === productRootId && String(o.status || '').toUpperCase() === 'RECEIVED'); - ctx.body = await singleProductView(withCount(product, comments), shop, comments, { shopId: product.shopId, canRate, returnTo: safeReturnTo(ctx, `/shops/${encodeURIComponent(product.shopId)}`, ['/shops']) }); + const productFav = await contentFavorites.getFavoriteSet('shopProducts'); + ctx.body = await singleProductView({ ...withCount(product, comments), isFavorite: productFav.has(String(product.rootId || product.key)) }, shop, comments, { shopId: product.shopId, canRate, returnTo: safeReturnTo(ctx, `/shops/${encodeURIComponent(product.shopId)}`, ['/shops']), spreads: await spreads.forMessage(product.key).catch(() => null) }); }) .get("/c/audios/:id", async (ctx) => { let item; try { item = await audiosModel.getAudioById(ctx.params.id); } catch (_) {} @@ -4538,7 +5027,7 @@ router const shop = await shopsModel.getShopById(ctx.params.shopId); if (!shop) { ctx.redirect('/shops'); return; } if (shop.encrypted && shop.undecryptable && shop.author !== getViewerId()) { ctx.redirect('/invites#invites-shops'); return; } - const fav = await mediaFavorites.getFavoriteSet('shops'); + const fav = await contentFavorites.getFavoriteSet('shops'); const [products, comments, mapData] = await Promise.all([shopsModel.listProducts(shop.rootId || shop.key), getVoteComments(shop.key), resolveMapUrl(shop.mapUrl)]); const baseUrl = resolveExternalBaseUrl(ctx); const authorPrefs = await about.visibilityPrefs(shop.author).catch(() => null); @@ -4583,7 +5072,7 @@ router } const modelFilter = filter === "favorites" ? "all" : filter; const items = await chatsModel.listAll({ filter: modelFilter, q, viewerId }); - const fav = await mediaFavorites.getFavoriteSet('chats'); + const fav = await contentFavorites.getFavoriteSet('chats'); const myTribeIds = await getUserTribeIds(viewerId); const enriched = items.filter(x => !x.tribeId).map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) })); let finalList = filter === "favorites" ? enriched.filter(x => x.isFavorite) : enriched; @@ -4591,7 +5080,14 @@ router try { finalList = await lifetime.enrichAndFilter(finalList, { getKey: (x) => x.rootId || x.key }); } catch (_) {} const spreadMap = await spreads.forMessages((finalList || []).map(x => x && (x.key || x.id))); await warmAuthorNames(finalList); - ctx.body = await chatsView(finalList, filter, null, { q, spreadMap }); + const uxChatsMode = getConfig().ux?.current === 'chats'; + if (uxChatsMode && filter === 'all' && !q && finalList.length) { + const actTs = (c) => Math.max(Number(c.lastMsgAt || 0), Date.parse(c.updatedAt || '') || 0, Date.parse(c.createdAt || '') || 0); + const first = finalList.slice().sort((a, b) => actTs(b) - actTs(a))[0]; + ctx.redirect(`/chats/${encodeURIComponent(first.rootId || first.key)}`); + return; + } + ctx.body = await chatsView(finalList, filter, null, { q, spreadMap, workspace: uxChatsMode }); }) .get("/chats/edit/:id", async (ctx) => { if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; } @@ -4602,7 +5098,7 @@ router .get("/chats/:chatId", async (ctx) => { if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; } await chatsModel.ingestKeys().catch(() => {}); - const { filter = 'all', q = '', replyTo = '' } = ctx.query; + const { filter = 'all', q = '' } = ctx.query; const uid = getViewerId(); let chat = await chatsModel.getChatById(ctx.params.chatId); if (!chat) { ctx.redirect('/chats'); return; } @@ -4619,11 +5115,23 @@ router const isOpen = String(chat.status || '').toUpperCase() === 'OPEN'; if (!isOpen && chat.author !== uid && !members.includes(uid)) { ctx.redirect('/chats?filter=all'); return; } } - const fav = await mediaFavorites.getFavoriteSet('chats'); + const fav = await contentFavorites.getFavoriteSet('chats'); const messages = await chatsModel.listMessages(chat.rootId || chat.key); const isTribeMember = !!parentTribe; - const chatListForSwitcher = process.env.OASIS_MOBILE === '1' ? await chatsModel.listAll({ viewerId: uid }).catch(() => []) : []; - ctx.body = await singleChatView({ ...chat, isFavorite: fav.has(String(chat.rootId || chat.key)), isTribeMember }, filter, messages, { q, returnTo: safeReturnTo(ctx, `/chats?filter=${encodeURIComponent(filter)}`, ['/chats']), replyTo: typeof replyTo === 'string' ? replyTo : '', chatList: chatListForSwitcher }); + const pollsEnabled = checkMod(ctx, 'pollsMod'); + const chatPolls = pollsEnabled + ? await pollsModel.listAll('ALL', { chatId: chat.rootId || chat.key }).catch(() => []) + : []; + const uxChats = getConfig().ux?.current === 'chats'; + // la rama usa la misma lista para el selector de chats en movil + const forkMobileChats = process.env.OASIS_MOBILE === '1'; + let allChats = []; + if (uxChats || forkMobileChats) { + try { + allChats = (await chatsModel.listAll({ filter: 'all', q: '', viewerId: uid })).filter(x => !x.tribeId); + } catch (_) { allChats = []; } + } + ctx.body = await singleChatView({ ...chat, isFavorite: fav.has(String(chat.rootId || chat.key)), isTribeMember }, filter, messages, { q, chatList: forkMobileChats ? allChats : [], polls: chatPolls, pollsEnabled, returnTo: safeReturnTo(ctx, `/chats?filter=${encodeURIComponent(filter)}`, ['/chats']), spreads: await spreads.forMessage(chat.key).catch(() => null), workspace: uxChats, allChats }); }) .get("/pads", async (ctx) => { if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; } @@ -4640,7 +5148,7 @@ router const q = String(ctx.query.q || "").trim(); const tribeId = ctx.query.tribeId || ""; const pads = await padsModel.listAll({ filter, viewerId: uid }); - const fav = await mediaFavorites.getFavoriteSet('pads'); + const fav = await contentFavorites.getFavoriteSet('pads'); let enriched = pads.filter(p => !p.tribeId).map(p => ({ ...p, isFavorite: fav.has(String(p.rootId)) })); enriched = await applyListFilters(enriched, ctx); try { enriched = await lifetime.enrichAndFilter(enriched, { getKey: (x) => x.rootId || x.key }); } catch (_) {} @@ -4667,7 +5175,7 @@ router const isOpen = String(pad.status || '').toUpperCase() === 'OPEN'; if (!isOpen && pad.author !== uid && !members.includes(uid)) { ctx.redirect('/pads?filter=all'); return; } } - const fav = await mediaFavorites.getFavoriteSet('pads'); + const fav = await contentFavorites.getFavoriteSet('pads'); const entries = await padsModel.getEntries(pad.rootId); const versionKey = ctx.query.version || null; const selectedVersion = versionKey @@ -4693,7 +5201,7 @@ router const tribeId = ctx.query.tribeId || ""; const modelFilter = filter === "favorites" ? "all" : filter; const calendars = await calendarsModel.listAll({ filter: modelFilter, viewerId: uid }); - const fav = await mediaFavorites.getFavoriteSet('calendars'); + const fav = await contentFavorites.getFavoriteSet('calendars'); const myTribeIds = await getUserTribeIds(uid); const enriched = calendars.filter(c => !c.tribeId).map(c => ({ ...c, isFavorite: fav.has(String(c.rootId)) })); let finalList = filter === "favorites" ? enriched.filter(c => c.isFavorite) : enriched; @@ -4728,7 +5236,7 @@ router for (const d of dates) { notesByDate[d.key] = await calendarsModel.getNotesForDate(cal.rootId, d.key); } - const fav = await mediaFavorites.getFavoriteSet('calendars'); + const fav = await contentFavorites.getFavoriteSet('calendars'); const month = String(ctx.query.month || "").trim() || null; const day = String(ctx.query.day || "").trim() || null; await enrichItemLifetime(cal, { key: cal.rootId }); @@ -4747,15 +5255,16 @@ router ctx.body = await projectsView([], "CREATE", null, { viewerPrefs, prefill }) return } + const q = String(ctx.query.q || '').trim() const modelFilter = filter === "BACKERS" ? "ALL" : filter - let projects = await projectsModel.listProjects(modelFilter) + let projects = await projectsModel.listProjects(modelFilter, { q }) await enrichWithComments(projects) projects = await applyListFilters(projects, ctx) try { projects = await lifetime.enrichAndFilter(projects); } catch (_) {} await enrichMsgSize(projects) const spreadMap = await spreads.forMessages((projects || []).map(x => x && (x.id || x.key))); await warmAuthorNames(projects); - ctx.body = await projectsView(projects, filter, null, { viewerPrefs, spreadMap }) + ctx.body = await projectsView(projects, filter, null, { viewerPrefs, spreadMap, q }) }) .get("/projects/edit/:id", async (ctx) => { if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; } @@ -4772,7 +5281,7 @@ router const [comments, mapData] = await Promise.all([getVoteComments(projectId), resolveMapUrl(project.mapUrl)]) await enrichMsgSize([project]) await enrichItemLifetime(project, { key: project.id || project.key }) - ctx.body = await singleProjectView(withCount(project, comments), filter, comments, { mapData, zoom, baseUrl: resolveExternalBaseUrl(ctx), spreads: await spreads.forMessage(project.id).catch(() => null) }) + ctx.body = await singleProjectView(await withFavorite(withCount(project, comments), 'projects'), filter, comments, { mapData, zoom, baseUrl: resolveExternalBaseUrl(ctx), spreads: await spreads.forMessage(project.id).catch(() => null) }) }) .get("/c/projects/:projectId", async (ctx) => { let project; @@ -4905,7 +5414,7 @@ router await industryModel.joinFacility(ctx.params.id) if (before && before.membershipPolicy === 'vote') { const me = getViewerId() - for (const m of (before.members || [])) { if (m !== me) { try { await pmModel.sendMessage([m], "INDUSTRY_APPLICATION", `A new habitant requested to join "${before.name}" -> /industry/${ctx.params.id}`); } catch (_) {} } } + for (const m of (before.members || [])) { if (m !== me) { try { await pmModel.sendMessage([m], "INDUSTRY_APPLICATION", `A new habitant requested to join [${before.name || 'a facility'}](/industry/${encodeURIComponent(ctx.params.id)})`); } catch (_) {} } } } safeRefererRedirect(ctx, `/industry/${encodeURIComponent(ctx.params.id)}`) } catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }) } @@ -4920,7 +5429,7 @@ router const invitee = ctx.request.body.invitee try { await industryModel.inviteToFacility(ctx.params.id, invitee) - try { const fc = await industryModel.getFacilityById(ctx.params.id); await pmModel.sendMessage([invitee], "INDUSTRY_INVITED", `You have been invited to join "${fc.name}" -> /industry/${ctx.params.id}`); } catch (_) {} + try { const fc = await industryModel.getFacilityById(ctx.params.id); await pmModel.sendMessage([invitee], "INDUSTRY_INVITED", `You have been invited to join [${fc.name || 'a facility'}](/industry/${encodeURIComponent(ctx.params.id)})`); } catch (_) {} safeRefererRedirect(ctx, `/industry/${encodeURIComponent(ctx.params.id)}`) } catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }) } }) @@ -4933,10 +5442,10 @@ router const after = await industryModel.getFacilityById(ctx.params.id).catch(() => null) const me = getViewerId() if (subject === 'admit' && ref && before && after && !before.members.includes(ref) && after.members.includes(ref)) { - try { await pmModel.sendMessage([ref], "INDUSTRY_ADMITTED", `You have been admitted to "${after.name}" -> /industry/${ctx.params.id}`); } catch (_) {} + try { await pmModel.sendMessage([ref], "INDUSTRY_ADMITTED", `You have been admitted to [${after.name || 'a facility'}](/industry/${encodeURIComponent(ctx.params.id)})`); } catch (_) {} } if (subject === 'dissolve' && before && after && before.status !== 'DISSOLVED' && after.status === 'DISSOLVED') { - for (const m of (after.members || [])) { if (m !== me) { try { await pmModel.sendMessage([m], "INDUSTRY_DISSOLVED", `The facility "${after.name}" has been dissolved by collective vote -> /industry/${ctx.params.id}`); } catch (_) {} } } + for (const m of (after.members || [])) { if (m !== me) { try { await pmModel.sendMessage([m], "INDUSTRY_DISSOLVED", `The facility [${after.name || 'a facility'}](/industry/${encodeURIComponent(ctx.params.id)}) has been dissolved by collective vote`); } catch (_) {} } } } safeRefererRedirect(ctx, `/industry/${encodeURIComponent(ctx.params.id)}`) } catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }) } @@ -4997,7 +5506,7 @@ router await industryModel.voteBuild(ctx.params.id, ctx.request.body.choice) const after = await industryModel.getBuild(ctx.params.id).catch(() => null) if (before && after && before.status !== 'APPROVED' && after.status === 'APPROVED' && after.proposer !== getViewerId()) { - try { await pmModel.sendMessage([after.proposer], "INDUSTRY_BUILD_APPROVED", `Your build "${after.title}" has been approved -> /industry/build/${ctx.params.id}`); } catch (_) {} + try { await pmModel.sendMessage([after.proposer], "INDUSTRY_BUILD_APPROVED", `Your build [${after.title || 'a build'}](/industry/build/${encodeURIComponent(ctx.params.id)}) has been approved`); } catch (_) {} } safeRefererRedirect(ctx, `/industry/build/${encodeURIComponent(ctx.params.id)}`) } catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }) } @@ -5022,7 +5531,7 @@ router const res = await industryModel.distributeBuild(ctx.params.id, { outputValue: ctx.request.body.outputValue }) for (const alloc of res.plan.allocations) { if (!alloc || !alloc.to || alloc.to === viewer || !(alloc.amount > 0)) continue - try { await pmModel.sendMessage([alloc.to], "INDUSTRY_DISTRIBUTED", `Build "${res.build.title}" allocated you ${alloc.amount.toFixed(2)} ECO -> /industry/build/${ctx.params.id}`) } catch (_) {} + try { await pmModel.sendMessage([alloc.to], "INDUSTRY_DISTRIBUTED", `Build [${res.build.title || 'a build'}](/industry/build/${encodeURIComponent(ctx.params.id)}) allocated you ${alloc.amount.toFixed(2)} ECO`) } catch (_) {} } safeRefererRedirect(ctx, `/industry/build/${encodeURIComponent(ctx.params.id)}`) } catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }) } @@ -5147,8 +5656,10 @@ router ctx.body = renderEpochView(epoch, allocations); }) .get("/favorites", async (ctx) => { - const filter = qf(ctx), data = await favoritesModel.listAll({ filter }); - ctx.body = await favoritesView(data.items, filter, data.counts); + const filter = qf(ctx), q = String(ctx.query.q || '').trim(); + const data = await favoritesModel.listAll({ filter }); + const items = applyTextSearch(data.items, q, ['title', 'name', 'description', 'category', 'url', 'tags']); + ctx.body = await favoritesView(items, filter, data.counts, q); }) .get("/logs", async (ctx) => { if (!checkMod(ctx, 'logsMod')) { ctx.redirect('/modules'); return; } @@ -5159,13 +5670,6 @@ router ctx.body = logsView([], 'today', mode, { view: 'create', aiModOn }); return; } - if (view === 'edit') { - const id = String(ctx.query.id || ''); - const entry = id ? await logsModel.getLogById(id) : null; - if (!entry) { ctx.redirect('/logs'); return; } - ctx.body = logsView([], 'today', entry.mode, { view: 'edit', aiModOn, entry }); - return; - } const filter = qf(ctx, 'today'); const q = String(ctx.query.q || '').trim().toLowerCase(); const typeQ = String(ctx.query.type || '').trim().toLowerCase(); @@ -5182,7 +5686,8 @@ router }) .get("/logs/view/:id", async (ctx) => { if (!checkMod(ctx, 'logsMod')) { ctx.redirect('/modules'); return; } - const entry = await logsModel.getLogById(ctx.params.id); + const entries = await logsModel.listLogs('always'); + const entry = entries.find(e => e.key === ctx.params.id); if (!entry) { ctx.redirect('/logs'); return; } const aiModOn = logsModel.isAImodOn(); ctx.body = logsView([], 'today', entry.mode, { view: 'detail', aiModOn, entry }); @@ -5197,11 +5702,17 @@ router } catch (_) {} ctx.redirect('/logs'); }) - .post("/logs/edit/:id", koaBody(), async (ctx) => { + .get("/logs/edit/:id", async (ctx) => { + if (!checkMod(ctx, 'logsMod')) { ctx.redirect('/modules'); return; } + const entry = await logsModel.getLogById(ctx.params.id); + if (!entry) { ctx.redirect('/logs'); return; } + ctx.body = logsView([], 'today', entry.mode, { view: 'edit', aiModOn: logsModel.isAImodOn(), entry }); + }) + .post("/logs/update/:id", koaBody(), async (ctx) => { if (!checkMod(ctx, 'logsMod')) { ctx.redirect('/modules'); return; } const b = ctx.request.body || {}; try { await logsModel.updateLog(ctx.params.id, { label: b.label || '', text: b.text || '' }); } catch (_) {} - ctx.redirect('/logs'); + ctx.redirect(`/logs/view/${encodeURIComponent(ctx.params.id)}`); }) .post("/logs/delete/:id", koaBody(), async (ctx) => { if (!checkMod(ctx, 'logsMod')) { ctx.redirect('/modules'); return; } @@ -5235,6 +5746,10 @@ router }) .get("/thread/:message", async (ctx) => { const { message } = ctx.params; + if (checkMod(ctx, 'blogsMod')) { + const target = await blogModel.blogHrefFor(message).catch(() => null); + if (target) { ctx.redirect(target); return; } + } const thread = async (message) => { const messages = await post.fromThread(message); const spreadMap = await spreads.forMessages((messages || []).map(m => m && m.key)).catch(() => new Map()); @@ -5252,7 +5767,7 @@ router ctx.body = await subtopicView({ messages, myFeedId, spreadMap }); }) .get("/publish", async (ctx) => { - ctx.body = await publishView(); + ctx.redirect('/blogs?filter=CREATE'); }) .get("/comment/:message", async (ctx) => { const { messages, myFeedId, parentMessage } = @@ -5359,6 +5874,46 @@ router const tr = await transfersModel.getTransferById(ctx.params.id, getViewerId()); ctx.body = await transferView([tr], 'edit', ctx.params.id, {}); }) + .post('/transfers/:id/share', koaBody(), async ctx => { + if (!checkMod(ctx, 'transfersMod')) { ctx.redirect('/modules'); return; } + const transfer = await transfersModel.getTransferById(ctx.params.id, getViewerId()).catch(() => null); + if (!transfer) { ctx.redirect('/transfers'); return; } + const pdf = buildSmartContractPdf({ transfer, block: null, viewerId: null }); + const slug = String(transfer.concept || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40); + await sharePdfBuffer(ctx, pdf, `oasis-smart-contract-${slug || 'transfer'}.pdf`, transfer.concept || ''); + }) + .get('/reports/:id/pdf', async ctx => sendContentPdf(ctx, 'reports', ctx.params.id)) + .post('/reports/:id/share', koaBody(), async ctx => sharePdfAsPm(ctx, 'reports', ctx.params.id)) + .get('/votes/:id/pdf', async ctx => sendContentPdf(ctx, 'votes', ctx.params.id)) + .post('/votes/:id/share', koaBody(), async ctx => sharePdfAsPm(ctx, 'votes', ctx.params.id)) + .get('/events/:id/pdf', async ctx => sendContentPdf(ctx, 'events', ctx.params.id)) + .post('/events/:id/share', koaBody(), async ctx => sharePdfAsPm(ctx, 'events', ctx.params.id)) + .get('/tasks/:id/pdf', async ctx => sendContentPdf(ctx, 'tasks', ctx.params.id)) + .post('/tasks/:id/share', koaBody(), async ctx => sharePdfAsPm(ctx, 'tasks', ctx.params.id)) + .get('/calendars/:id/pdf', async ctx => sendContentPdf(ctx, 'calendars', ctx.params.id)) + .post('/calendars/:id/share', koaBody(), async ctx => sharePdfAsPm(ctx, 'calendars', ctx.params.id)) + .get('/cv/pdf', async ctx => sendContentPdf(ctx, 'cv', null)) + .get('/cv/pdf/:id', async ctx => { + const targetId = ctx.params.id; + const viewerId = getViewerId(); + const cv = await cvModel.getCVByUserId(targetId).catch(() => null); + const hidden = cv && String(cv.visibility || '').toUpperCase() === 'HIDDEN'; + if (!cv || (hidden && String(targetId) !== String(viewerId))) { ctx.redirect('/inhabitants?filter=CVs'); return; } + const pdf = buildContentPdf('cv', cv, {}, String(targetId) === String(viewerId) ? viewerId : null); + ctx.set('Content-Type', 'application/pdf'); + ctx.set('Content-Disposition', `attachment; filename="${pdfFilename('cv', cv)}"`); + ctx.body = pdf; + }) + .post('/cv/share', koaBody(), async ctx => sharePdfAsPm(ctx, 'cv', null)) + .post('/cv/share/:id', koaBody(), async ctx => { + const targetId = ctx.params.id; + const viewerId = getViewerId(); + const cv = await cvModel.getCVByUserId(targetId).catch(() => null); + const hidden = cv && String(cv.visibility || '').toUpperCase() === 'HIDDEN'; + if (!cv || (hidden && String(targetId) !== String(viewerId))) { ctx.redirect('/inhabitants?filter=CVs'); return; } + const pdf = buildContentPdf('cv', cv, {}, null); + await sharePdfBuffer(ctx, pdf, pdfFilename('cv', cv), cv.name || targetId); + }) .get('/transfers/contract/:transferId', async ctx => { if (!checkMod(ctx, 'transfersMod')) { ctx.redirect('/modules'); return; } const transfer = await transfersModel.getTransferById(ctx.params.transferId, getViewerId()); @@ -5409,7 +5964,10 @@ router } catch (_) { block = null; } } await enrichItemLifetime(transfer, { author: transfer.from }); - ctx.body = await singleTransferView(transfer, filter, { q: ctx.query.q || '', minAmount: ctx.query.minAmount ?? '', maxAmount: ctx.query.maxAmount ?? '', sort: ctx.query.sort || 'recent', returnTo: safeReturnTo(ctx, `/transfers?filter=${encodeURIComponent(filter)}`, ['/transfers']), block, spreads: await spreads.forMessage(transfer.id).catch(() => null) }); + const comments = await getVoteComments(transfer.id); + const fav = await contentFavorites.getFavoriteSet('transfers'); + await warmAuthorNames([transfer], comments); + ctx.body = await singleTransferView({ ...transfer, isFavorite: fav.has(String(transfer.id)) }, filter, { q: ctx.query.q || '', minAmount: ctx.query.minAmount ?? '', maxAmount: ctx.query.maxAmount ?? '', sort: ctx.query.sort || 'recent', returnTo: safeReturnTo(ctx, `/transfers?filter=${encodeURIComponent(filter)}`, ['/transfers']), block, comments, spreads: await spreads.forMessage(transfer.id).catch(() => null) }); }) .post('/ai', koaBody(), async (ctx) => { const { input } = ctx.request.body; @@ -5597,7 +6155,7 @@ router const raw = String(ctx.request.body?.q || ctx.request.body?.prompt || '').trim(); if (!raw) { ctx.redirect('/'); return; } const ssbRefLib = require('../server/node_modules/ssb-ref'); - const hashtagMatch = raw.match(/^#([0-9A-Za-z_À-￿-]+)/); + const hashtagMatch = raw.match(/^#([\p{L}\p{N}_-]+)/u); if (hashtagMatch) { ctx.redirect('/search?query=' + encodeURIComponent('#' + hashtagMatch[1])); return; @@ -5616,18 +6174,17 @@ router try { const embedder = require('../AI/embedder'); const routesIndex = require('../AI/routes_index'); - if (!embedder.isInstalled()) { - ctx.redirect('/search?query=' + encodeURIComponent(raw)); - return; - } - const vec = await embedder.embed(raw); - if (!vec) { - ctx.redirect('/search?query=' + encodeURIComponent(raw)); - return; - } const isModuleEnabled = (modName) => checkMod(ctx, modName); - const best = await routesIndex.resolveBest(vec, { isModuleEnabled, embed: embedder.embed }); - if (best && best.path) { ctx.redirect(best.path); return; } + if (embedder.isInstalled()) { + const vec = await embedder.embed(raw); + if (vec) { + const best = await routesIndex.resolveBest(vec, { isModuleEnabled, embed: embedder.embed }); + if (best && best.path) { ctx.redirect(best.path); return; } + } + } + const { aiNavResultsView } = require('../views/main_views'); + const results = routesIndex.resolveKeywordTopK({ isModuleEnabled }, raw, 8); + if (Array.isArray(results) && results.length) { ctx.body = await aiNavResultsView({ query: raw, results }); return; } } catch (_) {} ctx.redirect('/search?query=' + encodeURIComponent(raw)); }) @@ -5727,7 +6284,6 @@ router cleanup(); ctx.redirect('/pm?fileerror=mutual#fileshare'); return; } } - const fmtB = (n) => { const x = Number(n) || 0; if (x < 1024) return x + ' B'; const u = ['KB','MB','GB','TB']; let v = x / 1024, i = 0; while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; } return v.toFixed(v >= 100 || i === 0 ? 0 : 1) + ' ' + u[i]; }; if (!file || !file.filepath || !file.size) { cleanup(); ctx.redirect('/pm?fileerror=nofile#fileshare'); return; } if (file.size > FILESHARE_MAX_SIZE) { cleanup(); ctx.redirect('/pm?fileerror=size#fileshare'); return; } let pointer; @@ -5745,7 +6301,7 @@ router recipient, subject: stripDangerousTags(b.subject || ''), manifestBlobId: pointer.manifestBlobId, keyHex: pointer.key, filename: pointer.filename, mime: pointer.mime, size: pointer.size, - sizeLabel: fmtB(pointer.size), crypter: useCrypter, sharedKey + sizeLabel: formatFileSize(pointer.size), crypter: useCrypter, sharedKey }); }) .post('/pm/file', koaBodyFileshare, async ctx => { @@ -5808,22 +6364,14 @@ router const messages = await buildInboxMessages(); const msg = messages.find(m => m && m.key === ctx.params.id); const fileShare = msg && msg.value && msg.value.content && msg.value.content.fileShare; - if (!fileShare) { ctx.redirect('/inbox'); return; } - let pointer = fileShare; - if (fileShare.crypter) { - const key = ctx.query && ctx.query.key; - if (typeof key !== 'string' || !key) { ctx.redirect('/inbox'); return; } - try { - pointer = { ...fileShare, key: cipherModel.decryptData(fileShare.key, key) }; - } catch (_) { ctx.redirect('/inbox?filekey=bad'); return; } - } - if (!(await fileshareModel.ensureAvailable(pointer))) { ctx.redirect('/inbox?filestatus=unavailable'); return; } + if (!fileShare || fileShare.crypter) { ctx.redirect('/inbox'); return; } + if (!(await fileshareModel.ensureAvailable(fileShare))) { ctx.redirect('/inbox?filestatus=unavailable'); return; } const safeName = String(fileShare.filename || 'file').replace(/["\r\n\\]/g, ''); ctx.type = fileShare.mime || 'application/octet-stream'; ctx.set('Content-Disposition', `attachment; filename="${safeName}"`); - const stream = fileshareModel.readShareStream(pointer); + const stream = fileshareModel.readShareStream(fileShare); if (msg.value.author && msg.value.author !== getViewerId()) { - stream.on('end', () => { fileshareModel.removeLocalBlobs(pointer).catch(() => {}); }); + stream.on('end', () => { fileshareModel.removeLocalBlobs(fileShare).catch(() => {}); }); } ctx.body = stream; }) @@ -5868,6 +6416,7 @@ router const applySearchPrivacy = (msgs) => msgs.filter(msg => { const c = msg.value?.content; if (!c) return true; + if (c.type === 'pub') return false; if (Array.isArray(c.recps)) return false; if (c.type === 'post' && (c.private === true || c.recps)) return false; if (c.tribeId && anonTribeIds.has(c.tribeId)) return false; @@ -5955,40 +6504,6 @@ router }); ctx.redirect(`/thread/${encodeURIComponent(parent.key)}`); }) - .post("/publish/preview", koaBody({multipart: true, formidable: { multiples: false, maxFileSize: maxSize }, urlencoded: true }), async (ctx) => { - const cw = stripDangerousTags(ctx.request.body.contentWarning?.toString().trim() || ""); - ctx.body = await previewView({ previewData: await preparePreview(ctx), contentWarning: cw.length > 0 ? cw : undefined }); - }) - .post("/publish", koaBody({ multipart: true, urlencoded: true, formidable: { multiples: false, maxFileSize: maxSize } }), async (ctx) => { - const b = ctx.request.body, text = stripDangerousTags(b.text?.toString().trim() || ""), cw = stripDangerousTags(b.contentWarning?.toString().trim() || ""); - let mentions = []; - try { mentions = JSON.parse(b.mentions || "[]"); } catch { mentions = await extractMentions(text); } - const approxContent = { type: 'post', text, ...(Array.isArray(mentions) && mentions.length ? { mentions } : {}), ...(cw.length > 0 ? { contentWarning: cw } : {}) }; - const tooLongMsg = require('../views/main_views').i18n.publishTooLong || 'Your post is too long. Please shorten it.'; - if (exceedsSsbLimit(approxContent)) { - sendErrorPage(ctx, tooLongMsg, { status: 400 }); - return; - } - try { - await post.root({ text, mentions, contentWarning: cw.length > 0 ? cw : undefined }); - } catch (e) { - if (isSsbTooLargeError(e)) { sendErrorPage(ctx, tooLongMsg, { status: 400 }); return; } - throw e; - } - try { activityModel.invalidateCache(); } catch (_) {} - ctx.redirect("/public/latest"); - }) - .post("/publish/custom", koaBody(), async (ctx) => { - const text = String(ctx.request.body.text); - const obj = JSON.parse(text); - const ALLOWED_TYPES = ['post','about','contact','vote','pub','channel']; - if (!obj.type || !ALLOWED_TYPES.includes(obj.type)) { ctx.throw(400, 'Invalid message type'); return; } - const sanitizeObj = (o) => { for (const k of Object.keys(o)) { if (typeof o[k] === 'string') o[k] = stripDangerousTags(o[k]); else if (o[k] && typeof o[k] === 'object') sanitizeObj(o[k]); } }; - sanitizeObj(obj); - ctx.body = await post.publishCustom(obj); - try { activityModel.invalidateCache(); } catch (_) {} - ctx.redirect(`/public/latest`); - }) .post("/follow/:feed", koaBody(), async (ctx) => { ctx.body = await friend.follow(ctx.params.feed); safeRefererRedirect(ctx, '/inhabitants'); @@ -6008,7 +6523,7 @@ router .post("/spread/:message", koaBody(), async (ctx) => { const { message } = ctx.params; const ref = ctx.request.header.referer; - let target = '/public/latest'; + let target = '/blogs'; try { if (ref) { const u = new URL(ref); @@ -6093,7 +6608,7 @@ router .post("/like/:message", koaBody(), async (ctx) => { const { message } = ctx.params, voteValue = Number(ctx.request.body.voteValue); const ref = ctx.request.header.referer; - let target = '/public/latest'; + let target = '/blogs'; try { if (ref) { const u = new URL(ref); @@ -6118,9 +6633,8 @@ router .post('/forum/:id/message', koaBody(), async ctx => { const { message, parentId } = ctx.request.body; const cleanedMsg = stripDangerousTags(message); - const subtopic = stripDangerousTags(String(ctx.request.body.subtopic || '').trim()).slice(0, 40); const mentions = await extractMentions(cleanedMsg); - await forumModel.addMessageToForum(ctx.params.id, { text: cleanedMsg, author: getViewerId(), timestamp: new Date().toISOString(), mentions: mentions.length > 0 ? mentions : undefined, subtopic: subtopic || undefined }, parentId); + await forumModel.addMessageToForum(ctx.params.id, { text: cleanedMsg, author: getViewerId(), timestamp: new Date().toISOString(), mentions: mentions.length > 0 ? mentions : undefined }, parentId); ctx.redirect(`/forum/${encodeURIComponent(ctx.params.id)}`); }) .post('/forum/:forumId/vote', koaBody(), async ctx => { @@ -6162,6 +6676,19 @@ router ctx.redirect(safeReturnTo(ctx, '/forum', ['/forum'])); } }) + .post('/legacy/export', koaBody(), async (ctx) => { + if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; } + const pw = ctx.request.body.password; + if (!pw || pw.length < 32) return ctx.redirect('/legacy'); + try { + const { filename, data } = await legacyModel.exportData({ password: pw }); + ctx.set('Content-Type', 'application/octet-stream'); + ctx.set('Content-Disposition', `attachment; filename="${filename}"`); + ctx.set('Content-Length', String(data.length)); + ctx.body = data; + try { onboardingModel.markStep('backup'); } catch (_) {} + } catch (error) { sendErrorPage(ctx, error.message); } + }) .post('/legacy/import', koaBody({ multipart: true, formidable: { @@ -6230,13 +6757,13 @@ router .post("/bookmarks/create", koaBody(), async (ctx) => { if (!checkMod(ctx, 'bookmarksMod')) { ctx.redirect('/modules'); return; } const b = ctx.request.body; - await bookmarksModel.createBookmark(stripDangerousTags(b.url), b.tags, stripDangerousTags(b.description), b.category, b.lastVisit); + await bookmarksModel.createBookmark(stripDangerousTags(b.url), b.tags, stripDangerousTags(b.description), b.lastVisit); ctx.redirect(safeReturnTo(ctx, '/bookmarks?filter=all', ['/bookmarks'])); }) .post("/bookmarks/update/:id", koaBody(), async (ctx) => { if (!checkMod(ctx, 'bookmarksMod')) { ctx.redirect('/modules'); return; } const b = ctx.request.body; - await bookmarksModel.updateBookmarkById(ctx.params.id, { url: stripDangerousTags(b.url), tags: b.tags, description: stripDangerousTags(b.description), category: b.category, lastVisit: b.lastVisit }); + await bookmarksModel.updateBookmarkById(ctx.params.id, { url: stripDangerousTags(b.url), tags: b.tags, description: stripDangerousTags(b.description), lastVisit: b.lastVisit }); ctx.redirect(safeReturnTo(ctx, '/bookmarks?filter=mine', ['/bookmarks'])); }) .post("/bookmarks/delete/:id", koaBody(), async ctx => deleteAction(ctx, 'bookmarks')) @@ -6247,13 +6774,13 @@ router .post("/images/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { if (!checkMod(ctx, 'imagesMod')) { ctx.redirect('/modules'); return; } const blob = await handleBlobUpload(ctx, 'image'), b = ctx.request.body; - await imagesModel.createImage(blob, b.tags, stripDangerousTags(b.title), stripDangerousTags(b.description), parseBool01(b.meme), stripDangerousTags(b.mapUrl || "")); + await imagesModel.createImage(blob, b.tags, stripDangerousTags(b.title), stripDangerousTags(b.description), stripDangerousTags(b.mapUrl || "")); ctx.redirect(safeReturnTo(ctx, '/images?filter=all', ['/images'])); }) .post("/images/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { if (!checkMod(ctx, 'imagesMod')) { ctx.redirect('/modules'); return; } const b = ctx.request.body, blob = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null; - await imagesModel.updateImageById(ctx.params.id, blob, b.tags, stripDangerousTags(b.title), stripDangerousTags(b.description), parseBool01(b.meme), stripDangerousTags(b.mapUrl || "")); + await imagesModel.updateImageById(ctx.params.id, blob, b.tags, stripDangerousTags(b.title), stripDangerousTags(b.description), stripDangerousTags(b.mapUrl || "")); ctx.redirect(safeReturnTo(ctx, '/images?filter=mine', ['/images'])); }) .post("/images/delete/:id", koaBody(), async ctx => deleteAction(ctx, 'images')) @@ -6301,7 +6828,7 @@ router if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; } try { const code = await mapsModel.generateInvite(ctx.params.id); - ctx.body = `

Map invite code: ${code}

Back

`; + ctx.body = renderMapInvitePage(code); } catch (e) { ctx.redirect(`/maps/${encodeURIComponent(ctx.params.id)}`); } @@ -6608,6 +7135,22 @@ router await tribesContentModel.updateStatus(ctx.params.taskId, ctx.request.body.status); ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=tasks`); }) + .post('/tribe/:id/polls/create', koaBody(), async ctx => { + if (!checkMod(ctx, 'tribesMod') || !checkMod(ctx, 'pollsMod')) { ctx.redirect('/modules'); return; } + const tribe = await tribesModel.getTribeById(ctx.params.id); + if (!tribe || !tribe.members.includes(getViewerId())) { sendErrorPage(ctx, 'Forbidden', { status: 403 }); return; } + const b = ctx.request.body || {}; + try { + await pollsModel.createPoll({ + question: stripDangerousTags(b.question), + options: stripDangerousTags(String(b.options || '')).split('\n'), + anonymous: [].concat(b.anonymous).includes('1'), + multiple: [].concat(b.multiple).includes('1'), + tribeId: tribe.id + }); + } catch (err) { sendErrorPage(ctx, err.message || String(err), { status: 400 }); return; } + ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=polls`); + }) .post('/tribe/:id/votations/create', koaBody(), async ctx => { if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; } const tribe = await tribesModel.getTribeById(ctx.params.id); @@ -6723,18 +7266,22 @@ router }) .post('/tasks/create', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => { const b = ctx.request.body; - const imageMarkdown = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null; - let desc = stripDangerousTags(b.description); - if (imageMarkdown) desc = (desc ? desc + '\n' : '') + imageMarkdown; - await tasksModel.createTask(stripDangerousTags(b.title), desc, b.startTime, b.endTime, b.priority, stripDangerousTags(b.location), b.tags, b.isPublic); + const media = await readGalleryUpload(ctx); + const draftImages = mergeGallery(media.keep, media.uploaded, media.removeIndex); + if (media.isMediaAction) { + ctx.body = await taskView([], 'create', null, b.returnTo, { draft: { ...b, images: draftImages } }); + return; + } + await tasksModel.createTask(stripDangerousTags(b.title), stripDangerousTags(b.description), b.startTime, b.endTime, b.priority, stripDangerousTags(b.location), b.tags, b.isPublic, { images: draftImages, video: media.clip || '' }); ctx.redirect(safeReturnTo(ctx, '/tasks?filter=mine', ['/tasks'])); }) .post('/tasks/update/:id', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => { const b = ctx.request.body, tags = Array.isArray(b.tags) ? b.tags.filter(Boolean) : (typeof b.tags === 'string' ? b.tags.split(',').map(t => t.trim()).filter(Boolean) : []); - const imageMarkdown = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null; - let desc = stripDangerousTags(b.description); - if (imageMarkdown) desc = (desc ? desc + '\n' : '') + imageMarkdown; - await tasksModel.updateTaskById(ctx.params.id, { title: stripDangerousTags(b.title), description: desc, startTime: b.startTime, endTime: b.endTime, priority: b.priority, location: stripDangerousTags(b.location), tags, isPublic: b.isPublic }); + const media = await readGalleryUpload(ctx); + let current = []; + try { current = (await tasksModel.getTaskById(ctx.params.id)).images || []; } catch (_) { current = []; } + await tasksModel.updateTaskById(ctx.params.id, { title: stripDangerousTags(b.title), description: stripDangerousTags(b.description), startTime: b.startTime, endTime: b.endTime, priority: b.priority, location: stripDangerousTags(b.location), tags, isPublic: b.isPublic, ...galleryPatch(media, current) }); + if (media.isMediaAction) { ctx.redirect(`/tasks/edit/${encodeURIComponent(ctx.params.id)}`); return; } ctx.redirect(safeReturnTo(ctx, '/tasks?filter=mine', ['/tasks'])); }) .post('/tasks/assign/:id', koaBody(), async ctx => { @@ -6751,26 +7298,37 @@ router }) .post('/tasks/:taskId/comments', koaBodyMiddleware, async ctx => commentAction(ctx, 'tasks', 'taskId')) .post('/reports/create', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => { - const b = ctx.request.body, image = await handleBlobUpload(ctx, 'image'); - await reportsModel.createReport(stripDangerousTags(b.title), stripDangerousTags(b.description), b.category, image, b.tags, b.severity, { + const b = ctx.request.body; + const media = await readGalleryUpload(ctx); + const draftImages = mergeGallery(media.keep, media.uploaded, media.removeIndex); + if (media.isMediaAction) { + ctx.body = await reportView([], 'create', null, b.category || '', { draft: { ...b, images: draftImages } }); + return; + } + await reportsModel.createReport(stripDangerousTags(b.title), stripDangerousTags(b.description), b.category, null, b.tags, b.severity, { stepsToReproduce: stripDangerousTags(b.stepsToReproduce), expectedBehavior: stripDangerousTags(b.expectedBehavior), actualBehavior: stripDangerousTags(b.actualBehavior), environment: stripDangerousTags(b.environment), reproduceRate: b.reproduceRate, problemStatement: stripDangerousTags(b.problemStatement), userStory: stripDangerousTags(b.userStory), acceptanceCriteria: stripDangerousTags(b.acceptanceCriteria), whatHappened: stripDangerousTags(b.whatHappened), reportedUser: b.reportedUser, evidenceLinks: stripDangerousTags(b.evidenceLinks), - contentLocation: stripDangerousTags(b.contentLocation), whyInappropriate: stripDangerousTags(b.whyInappropriate), requestedAction: stripDangerousTags(b.requestedAction) - }); + contentLocation: stripDangerousTags(b.contentLocation), whyInappropriate: stripDangerousTags(b.whyInappropriate) + }, { images: draftImages, video: media.clip || '' }); ctx.redirect('/reports'); }) .post('/reports/update/:id', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => { - const b = ctx.request.body, image = await handleBlobUpload(ctx, 'image'); + const b = ctx.request.body; + const media = await readGalleryUpload(ctx); + let current = []; + try { current = (await reportsModel.getReportById(ctx.params.id)).images || []; } catch (_) { current = []; } await reportsModel.updateReportById(ctx.params.id, { - title: stripDangerousTags(b.title), description: stripDangerousTags(b.description), category: b.category, image, tags: b.tags, severity: b.severity, + title: stripDangerousTags(b.title), description: stripDangerousTags(b.description), category: b.category, tags: b.tags, severity: b.severity, + ...galleryPatch(media, current), template: { stepsToReproduce: stripDangerousTags(b.stepsToReproduce), expectedBehavior: stripDangerousTags(b.expectedBehavior), actualBehavior: stripDangerousTags(b.actualBehavior), environment: stripDangerousTags(b.environment), reproduceRate: b.reproduceRate, problemStatement: stripDangerousTags(b.problemStatement), userStory: stripDangerousTags(b.userStory), acceptanceCriteria: stripDangerousTags(b.acceptanceCriteria), whatHappened: stripDangerousTags(b.whatHappened), reportedUser: stripDangerousTags(b.reportedUser), evidenceLinks: stripDangerousTags(b.evidenceLinks), - contentLocation: stripDangerousTags(b.contentLocation), whyInappropriate: stripDangerousTags(b.whyInappropriate), requestedAction: stripDangerousTags(b.requestedAction) + contentLocation: stripDangerousTags(b.contentLocation), whyInappropriate: stripDangerousTags(b.whyInappropriate) } }); + if (media.isMediaAction) { ctx.redirect(`/reports/edit/${encodeURIComponent(ctx.params.id)}`); return; } ctx.redirect('/reports?filter=mine'); }) .post('/reports/delete/:id', async ctx => { @@ -6788,10 +7346,21 @@ router .post('/reports/:reportId/comments', koaBodyMiddleware, async ctx => commentAction(ctx, 'reports', 'reportId')) .post('/events/create', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { const b = ctx.request.body; - const imageMarkdown = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null; - let desc = stripDangerousTags(b.description); - if (imageMarkdown) desc = (desc ? desc + '\n' : '') + imageMarkdown; - const evResult = await eventsModel.createEvent(stripDangerousTags(b.title), desc, b.date, stripDangerousTags(b.location), b.price, b.url, b.attendees || [], b.tags, b.isPublic, stripDangerousTags(b.mapUrl), b.clearnetPublic); + const media = await readGalleryUpload(ctx); + const draftImages = mergeGallery(media.keep, media.uploaded, media.removeIndex); + if (media.isMediaAction) { + const viewerPrefsDraft = await about.visibilityPrefs(getViewerId()).catch(() => null); + ctx.body = await eventView([], 'create', null, b.returnTo, { viewerPrefs: viewerPrefsDraft, draft: { ...b, images: draftImages } }); + return; + } + const { intervalWeekly, intervalMonthly, intervalYearly } = readInterval(b); + const recurrence = { + weekly: intervalWeekly, + monthly: intervalMonthly, + yearly: intervalYearly, + until: b.intervalDeadline || b.recurrenceUntil || '' + }; + const evResult = await eventsModel.createEvent(stripDangerousTags(b.title), stripDangerousTags(b.description), b.date, stripDangerousTags(b.location), b.price, b.url, b.attendees || [], b.tags, b.isPublic, stripDangerousTags(b.mapUrl), b.clearnetPublic, { images: draftImages, video: media.clip || '' }, recurrence); if ([].concat(b.addToCalendar).includes("1") && evResult && evResult.key) { try { await calendarsModel.createCalendar({ @@ -6812,10 +7381,9 @@ router }) .post('/events/update/:id', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { const b = ctx.request.body, existing = await eventsModel.getEventById(ctx.params.id); - const imageMarkdown = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null; - let desc = stripDangerousTags(b.description); - if (imageMarkdown) desc = (desc ? desc + '\n' : '') + imageMarkdown; - await eventsModel.updateEventById(ctx.params.id, { title: stripDangerousTags(b.title), description: desc, date: b.date, location: stripDangerousTags(b.location), price: b.price, url: b.url, attendees: b.attendees, tags: b.tags, isPublic: b.isPublic, createdAt: existing.createdAt, organizer: existing.organizer, mapUrl: stripDangerousTags(b.mapUrl), clearnetPublic: b.clearnetPublic }); + const media = await readGalleryUpload(ctx); + await eventsModel.updateEventById(ctx.params.id, { title: stripDangerousTags(b.title), description: stripDangerousTags(b.description), date: b.date, location: stripDangerousTags(b.location), price: b.price, url: b.url, attendees: b.attendees, tags: b.tags, isPublic: b.isPublic, createdAt: existing.createdAt, organizer: existing.organizer, mapUrl: stripDangerousTags(b.mapUrl), clearnetPublic: b.clearnetPublic, ...readInterval(b), recurrenceUntil: b.intervalDeadline || b.recurrenceUntil || '', ...galleryPatch(media, existing.images || []) }); + if (media.isMediaAction) { ctx.redirect(`/events/edit/${encodeURIComponent(ctx.params.id)}`); return; } ctx.redirect(safeReturnTo(ctx, '/events?filter=mine', ['/events'])); }) .post('/events/attend/:id', koaBody(), async ctx => { @@ -6859,12 +7427,22 @@ router .post('/votes/create', koaBody(), async ctx => { const b = ctx.request.body, defaultOptions = ['YES', 'NO', 'ABSTENTION', 'CONFUSED', 'FOLLOW_MAJORITY', 'NOT_INTERESTED']; const parsedOptions = b.options ? b.options.split(',').map(o => o.trim()).filter(Boolean) : defaultOptions; - await votesModel.createVote(stripDangerousTags(b.question), b.deadline, parsedOptions, String(b.tags || '').split(',').map(t => t.trim()).filter(Boolean)); + try { + await votesModel.createVote(stripDangerousTags(b.question), b.deadline, parsedOptions, String(b.tags || '').split(',').map(t => t.trim()).filter(Boolean)); + } catch (err) { + ctx.redirect(voteFormRedirect('create', null, err, b)); + return; + } ctx.redirect(safeReturnTo(ctx, '/votes?filter=mine', ['/votes'])); }) .post('/votes/update/:id', koaBody(), async ctx => { const b = ctx.request.body, parsedOptions = b.options ? b.options.split(',').map(o => o.trim()).filter(Boolean) : undefined; - await votesModel.updateVoteById(ctx.params.id, { question: stripDangerousTags(b.question), deadline: b.deadline, options: parsedOptions, tags: b.tags ? b.tags.split(',').map(t => t.trim()).filter(Boolean) : [] }); + try { + await votesModel.updateVoteById(ctx.params.id, { question: stripDangerousTags(b.question), deadline: b.deadline, options: parsedOptions, tags: b.tags ? b.tags.split(',').map(t => t.trim()).filter(Boolean) : [] }); + } catch (err) { + ctx.redirect(voteFormRedirect('edit', ctx.params.id, err, b)); + return; + } ctx.redirect(safeReturnTo(ctx, '/votes?filter=mine', ['/votes'])); }) .post('/votes/delete/:id', koaBody(), async ctx => { @@ -7165,7 +7743,7 @@ router } catch (_) {} if (item.item_type === "exchange") await marketModel.setItemAsSold(ctx.params.id); else await marketModel.decrementStock(ctx.params.id); - try { await mediaFavorites.addFavorite('market', item.id || ctx.params.id); } catch (_) {} + try { await contentFavorites.addFavorite('market', item.id || ctx.params.id); } catch (_) {} if (item.shopProductId && checkMod(ctx, 'shopsMod')) { try { await shopsModel.buyProduct(item.shopProductId); } catch (_) {} } @@ -7191,7 +7769,137 @@ router await marketModel.addBidToAuction(ctx.params.id, getViewerId(), ctx.request.body.bidAmount) ctx.redirect(safeReturnTo(ctx, "/market?filter=auctions", ["/market"])) }) + .post("/market/opinions/:itemId/:category", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'marketMod')) { ctx.redirect('/modules'); return; } + try { await marketModel.createOpinion(ctx.params.itemId, ctx.params.category) } catch (_) {} + ctx.redirect(safeReturnTo(ctx, `/market/${encodeURIComponent(ctx.params.itemId)}`, ['/market'])) + }) .post("/market/:itemId/comments", koaBodyMiddleware, async ctx => commentAction(ctx, 'market', 'itemId')) + .post('/housing/create', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { + if (!checkMod(ctx, 'housingMod')) { ctx.redirect('/modules'); return; } + const b = ctx.request.body + const media = await readGalleryUpload(ctx) + const images = mergeGallery(media.keep, media.uploaded, media.removeIndex) + const clip = media.clip + if (media.isMediaAction) { + ctx.body = await housingView([], 'CREATE', { maxImages: housingModel.MAX_IMAGES, draft: { ...b, images } }) + return + } + let created = null + try { + created = await housingModel.createHousing({ + housing_type: stripDangerousTags(b.housing_type), + property_type: stripDangerousTags(b.property_type), + title: stripDangerousTags(b.title), + description: stripDangerousTags(b.description), + rules: stripDangerousTags(b.rules), + place: stripDangerousTags(b.place), + mapUrl: stripDangerousTags(b.mapUrl), + price: b.price, + rooms: b.rooms, + size: b.size, + capacity: b.capacity, + availableFrom: b.availableFrom, + availableTo: b.availableTo, + images, + video: clip || '', + tags: b.tags, + visibility: b.visibility + }) + } catch (err) { sendErrorPage(ctx, err.message || String(err), { status: 400 }); return } + ctx.redirect(safeReturnTo(ctx, '/housing?filter=MINE', ['/housing'])) + }) + .post('/housing/update/:id', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { + if (!checkMod(ctx, 'housingMod')) { ctx.redirect('/modules'); return; } + const b = ctx.request.body + const uploaded = await handleBlobUploads(ctx, 'images') + const newClip = ctx.request.files?.video ? await handleBlobUpload(ctx, 'video') : null + const patch = { + housing_type: stripDangerousTags(b.housing_type), + property_type: stripDangerousTags(b.property_type), + title: stripDangerousTags(b.title), + description: stripDangerousTags(b.description), + rules: stripDangerousTags(b.rules), + place: stripDangerousTags(b.place), + mapUrl: stripDangerousTags(b.mapUrl), + price: b.price, + rooms: b.rooms, + size: b.size, + capacity: b.capacity, + availableFrom: b.availableFrom, + availableTo: b.availableTo, + tags: b.tags, + visibility: b.visibility + } + const removeIndex = b.removePhoto !== undefined && b.removePhoto !== '' ? parseInt(b.removePhoto, 10) : -1 + if (uploaded.length || removeIndex >= 0) { + let current = [] + try { current = (await housingModel.getHousingById(ctx.params.id, getViewerId())).images || [] } catch (_) { current = [] } + if (removeIndex >= 0) current = current.filter((_, i) => i !== removeIndex) + patch.images = [...current, ...uploaded] + } + if (newClip) patch.video = newClip + else if (b.action === 'removeVideo') patch.video = '' + try { await housingModel.updateHousing(ctx.params.id, patch) } + catch (err) { sendErrorPage(ctx, err.message || String(err), { status: 400 }); return } + if (b.action === 'addPhoto' || b.action === 'addVideo' || b.action === 'removeVideo' || removeIndex >= 0) { ctx.redirect(`/housing/edit/${encodeURIComponent(ctx.params.id)}`); return } + ctx.redirect(safeReturnTo(ctx, '/housing?filter=MINE', ['/housing'])) + }) + .post('/housing/delete/:id', koaBody(), async (ctx) => { + if (!checkMod(ctx, 'housingMod')) { ctx.redirect('/modules'); return; } + let item = null + try { item = await housingModel.getHousingById(ctx.params.id, getViewerId()) } catch (_) {} + try { await housingModel.deleteHousing(ctx.params.id) } catch (_) {} + await notifyHousingRequesters(item, 'deleted') + ctx.redirect(safeReturnTo(ctx, '/housing?filter=MINE', ['/housing'])) + }) + .post('/housing/status/:id', koaBody(), async (ctx) => { + if (!checkMod(ctx, 'housingMod')) { ctx.redirect('/modules'); return; } + const nextStatus = String(ctx.request.body.status || '').toUpperCase() + let item = null + try { item = await housingModel.getHousingById(ctx.params.id, getViewerId()) } catch (_) {} + try { await housingModel.updateHousingStatus(ctx.params.id, nextStatus) } catch (_) {} + if (nextStatus === 'CLOSED') await notifyHousingRequesters(item, 'closed') + ctx.redirect(safeReturnTo(ctx, '/housing?filter=MINE', ['/housing'])) + }) + .post('/housing/visibility/:id', koaBody(), async (ctx) => { + if (!checkMod(ctx, 'housingMod')) { ctx.redirect('/modules'); return; } + const next = String(ctx.request.body?.visibility || '').toUpperCase() === 'HIDDEN' ? 'HIDDEN' : 'PUBLIC' + try { await housingModel.updateHousing(ctx.params.id, { visibility: next }) } catch (_) {} + ctx.redirect(safeReturnTo(ctx, `/housing/${encodeURIComponent(ctx.params.id)}`, ['/housing'])) + }) + .post('/housing/request/:id', koaBody(), async (ctx) => { + if (!checkMod(ctx, 'housingMod')) { ctx.redirect('/modules'); return; } + let item = null + try { item = await housingModel.getHousingById(ctx.params.id, getViewerId()) } catch (_) {} + if (!item) { ctx.redirect(safeReturnTo(ctx, '/housing', ['/housing'])); return } + try { + const res = await housingModel.requestHousing(ctx.params.id) + if (!res || !res.alreadyRequested) { + try { await pmModel.sendMessage([item.author], 'HOUSING_REQUESTED', `A new habitant has requested your place [${item.title || 'a place'}](/housing/${encodeURIComponent(item.id)})`) } catch (_) {} + } + } catch (_) {} + ctx.redirect(safeReturnTo(ctx, '/housing', ['/housing'])) + }) + .post('/housing/cancel/:id', koaBody(), async (ctx) => { + if (!checkMod(ctx, 'housingMod')) { ctx.redirect('/modules'); return; } + let item = null + try { item = await housingModel.getHousingById(ctx.params.id, getViewerId()) } catch (_) {} + if (!item) { ctx.redirect(safeReturnTo(ctx, '/housing', ['/housing'])); return } + try { + const res = await housingModel.cancelRequest(ctx.params.id) + if (!res || !res.notRequested) { + try { await pmModel.sendMessage([item.author], 'HOUSING_CANCELLED', `A habitant has cancelled the request on your place [${item.title || 'a place'}](/housing/${encodeURIComponent(item.id)})`) } catch (_) {} + } + } catch (_) {} + ctx.redirect(safeReturnTo(ctx, '/housing', ['/housing'])) + }) + .post('/housing/opinions/:housingId/:category', koaBody(), async (ctx) => { + if (!checkMod(ctx, 'housingMod')) { ctx.redirect('/modules'); return; } + try { await housingModel.createOpinion(ctx.params.housingId, ctx.params.category) } catch (_) {} + ctx.redirect(safeReturnTo(ctx, `/housing/${encodeURIComponent(ctx.params.housingId)}`, ['/housing'])) + }) + .post('/housing/:housingId/comments', koaBodyMiddleware, async ctx => commentAction(ctx, 'housing', 'housingId')) .post('/jobs/create', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { if (!checkMod(ctx, 'jobsMod')) { ctx.redirect('/modules'); return; } const b = ctx.request.body, imageBlob = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null; @@ -7320,6 +8028,8 @@ router } }) .post("/shops/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'shops', 'add')) + .post("/shopProducts/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'shopProducts', 'add')) + .post("/shopProducts/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'shopProducts', 'remove')) .post("/shops/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'shops', 'remove')) .post("/shops/opinions/:shopId/:category", koaBody(), async (ctx) => { if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; } @@ -7376,7 +8086,7 @@ router await pmModel.sendMessage([pr.author], "SHOP_SOLD", `product "${pr.title}" has been sold -> /shops/product/${ctx.params.id} OASIS ID: ${getViewerId()} for: ${pr.price} ECO`); } catch (_) {} } - await mediaFavorites.addFavorite('shopProducts', (pr && pr.rootId) || ctx.params.id); + await contentFavorites.addFavorite('shopProducts', (pr && pr.rootId) || ctx.params.id); } catch (_) {} if (checkMod(ctx, 'marketMod')) { try { const mi = await marketModel.getItemByShopProductId(ctx.params.id); if (mi) await marketModel.decrementStock(mi.id); } catch (_) {} @@ -7478,6 +8188,36 @@ router }) .post("/chats/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'chats', 'add')) .post("/chats/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'chats', 'remove')) + .post("/chats/:chatId/polls/create", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'chatsMod') || !checkMod(ctx, 'pollsMod')) { ctx.redirect('/modules'); return; } + const uid = getViewerId(); + const chat = await chatsModel.getChatById(ctx.params.chatId).catch(() => null); + if (!chat) { ctx.redirect('/chats'); return; } + const members = Array.isArray(chat.members) ? chat.members : []; + const isOpen = String(chat.status || '').toUpperCase() === 'OPEN'; + if (String(chat.status || '').toUpperCase() === 'CLOSED') { ctx.redirect(`/chats/${encodeURIComponent(ctx.params.chatId)}`); return; } + if (chat.tribeId) { + try { + const t = await tribesModel.getTribeById(chat.tribeId); + if (!t.members.includes(uid)) { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; } + } catch { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; } + } else if (!isOpen && chat.author !== uid && !members.includes(uid)) { + sendErrorPage(ctx, "Forbidden", { status: 403 }); return; + } + const b = ctx.request.body || {}; + try { + await pollsModel.createPoll({ + question: stripDangerousTags(b.question), + options: stripDangerousTags(String(b.options || '')).split('\n'), + anonymous: [].concat(b.anonymous).includes('1'), + multiple: [].concat(b.multiple).includes('1'), + chatId: chat.rootId || chat.key, + tribeId: chat.tribeId || null + }); + } catch (err) { sendErrorPage(ctx, err.message || String(err), { status: 400 }); return; } + try { activityModel.invalidateCache(); } catch (_) {} + ctx.redirect(`/chats/${encodeURIComponent(ctx.params.chatId)}`); + }) .post("/chats/:chatId/message", koaBody({ multipart: true }), async (ctx) => { if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; } const uid = getViewerId(); @@ -7490,10 +8230,17 @@ router } const text = stripDangerousTags(String(ctx.request.body.text || '').trim()); const imageBlob = ctx.request.files?.image ? extractBlobId(await handleBlobUpload(ctx, 'image')) : null; - const replyToRaw = String(ctx.request.body.replyTo || '').trim(); - const replyTo = replyToRaw.startsWith('%') ? replyToRaw : null; if (!text && !imageBlob) { ctx.redirect(`/chats/${encodeURIComponent(ctx.params.chatId)}`); return; } - await chatsModel.sendMessage(ctx.params.chatId, text, imageBlob, replyTo); + try { + await chatsModel.sendMessage(ctx.params.chatId, text, imageBlob); + } catch (err) { + if (err && err.code === 'CHAT_RATE_LIMIT') { + const { i18n } = require('../views/main_views'); + sendErrorPage(ctx, i18n.chatRateLimitMessage, { title: i18n.chatRateLimitTitle, status: 429 }); + return; + } + throw err; + } ctx.redirect(safeReturnTo(ctx, `/chats/${encodeURIComponent(ctx.params.chatId)}`, ['/chats'])); }) .post("/pads/create", koaBody(), async (ctx) => { @@ -7595,6 +8342,27 @@ router }) .post("/pads/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'pads', 'add')) .post("/pads/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'pads', 'remove')) + .post("/forum/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'forum', 'add')) + .post("/forum/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'forum', 'remove')) + .post("/events/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'events', 'add')) + .post("/events/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'events', 'remove')) + .post("/tasks/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'tasks', 'add')) + .post("/tasks/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'tasks', 'remove')) + .post("/reports/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'reports', 'add')) + .post("/reports/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'reports', 'remove')) + .post("/votes/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'votes', 'add')) + .post("/votes/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'votes', 'remove')) + .post("/jobs/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'jobs', 'add')) + .post("/jobs/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'jobs', 'remove')) + .post("/housing/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'housing', 'add')) + .post("/housing/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'housing', 'remove')) + .post("/projects/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'projects', 'add')) + .post("/projects/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'projects', 'remove')) + .post("/transfers/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'transfers', 'add')) + .post("/transfers/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'transfers', 'remove')) + .post("/transfers/:transferId/comments", koaBodyMiddleware, async ctx => commentAction(ctx, 'transfers', 'transferId')) + .post("/market/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'market', 'add')) + .post("/market/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'market', 'remove')) .post("/calendars/create", koaBody(), async (ctx) => { if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; } const b = ctx.request.body || {}; @@ -7603,9 +8371,7 @@ router const t = await tribesModel.getTribeById(tribeId).catch(() => null); if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } } - const intervalWeekly = [].concat(b.intervalWeekly).includes("1"); - const intervalMonthly = [].concat(b.intervalMonthly).includes("1"); - const intervalYearly = [].concat(b.intervalYearly).includes("1"); + const { intervalWeekly, intervalMonthly, intervalYearly } = readInterval(b); try { const msg = await calendarsModel.createCalendar({ title: stripDangerousTags(b.title || ""), @@ -7616,6 +8382,8 @@ router firstDateLabel: stripDangerousTags(b.firstDateLabel || ""), firstNote: stripDangerousTags(b.firstNote || ""), intervalWeekly, intervalMonthly, intervalYearly, + intervalDeadline: b.intervalDeadline || "", + mapUrl: stripDangerousTags(b.mapUrl || ""), tribeId }); ctx.redirect(tribeId ? `/tribe/${encodeURIComponent(tribeId)}?section=calendars` : `/calendars/${encodeURIComponent(msg.key)}`); @@ -7637,7 +8405,8 @@ router title: stripDangerousTags(b.title || ""), status: b.status || "OPEN", deadline: b.deadline || "", - tags: b.tags || "" + tags: b.tags || "", + mapUrl: stripDangerousTags(b.mapUrl || "") }); } catch (_) {} ctx.redirect(`/calendars/${encodeURIComponent(ctx.params.id)}`); @@ -7713,9 +8482,7 @@ router } catch { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; } } const b = ctx.request.body || {}; - const intervalWeekly = [].concat(b.intervalWeekly).includes("1"); - const intervalMonthly = [].concat(b.intervalMonthly).includes("1"); - const intervalYearly = [].concat(b.intervalYearly).includes("1"); + const { intervalWeekly, intervalMonthly, intervalYearly } = readInterval(b); try { const dateMsgs = await calendarsModel.addDate(ctx.params.id, b.date || "", stripDangerousTags(b.label || ""), intervalWeekly, intervalMonthly, intervalYearly, b.intervalDeadline || ""); const noteText = stripDangerousTags(String(b.text || "").trim()); @@ -7962,6 +8729,20 @@ router console.log("oasis@version: running install.sh...", shOut, shErr); safeRefererRedirect(ctx, '/settings'); }) + .post("/settings/workflow", koaBody(), async (ctx) => { + if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; } + const workflow = workflowsModel.getWorkflow(String(ctx.request.body.workflow || '').trim()); + if (!workflow) { ctx.redirect('/settings'); return; } + const cfg = getConfig(); + const enabled = new Set(workflowsModel.modulesOf(workflow)); + workflowsModel.ALL_MODULES.forEach(mod => { cfg.modules[`${mod}Mod`] = enabled.has(mod) ? 'on' : 'off'; }); + cfg.themes.current = workflow.theme; + if (workflow.homePage) cfg.homePage = workflow.homePage; + if (!enabled.has('aiNav') && cfg.ux) cfg.ux.current = 'blocks'; + saveConfig(cfg); + ctx.cookies.set("theme", cfg.themes.current, { httpOnly: true, sameSite: 'strict', secure: ctx.secure }); + ctx.redirect("/settings"); + }) .post("/settings/theme", koaBody(), async (ctx) => { const theme = String(ctx.request.body.theme || "").trim(), cfg = getConfig(); cfg.themes.current = theme || "Dark-SNH"; @@ -8379,10 +9160,11 @@ router const cfg = getConfig(); const v = String(ctx.request.body.ux || "").trim().toLowerCase(); const aiNavEnabled = cfg.modules && cfg.modules.aiNavMod === 'on'; - const next = (v === "ainav" && aiNavEnabled) ? "ainav" : "blocks"; + const chatsEnabled = cfg.modules && cfg.modules.chatsMod === 'on'; + const next = (v === "ainav" && aiNavEnabled) ? "ainav" : (v === "chats" && chatsEnabled) ? "chats" : "blocks"; cfg.ux = { ...(cfg.ux && typeof cfg.ux === 'object' ? cfg.ux : {}), current: next }; saveConfig(cfg); - ctx.redirect(next === "ainav" ? "/" : "/settings"); + ctx.redirect(next === "ainav" ? "/" : next === "chats" ? "/chats" : "/settings"); }) .post("/settings/lan-broadcasting", koaBody(), async (ctx) => { const enabled = !!(ctx.request.body && (ctx.request.body.lanBroadcasting === 'on' || ctx.request.body.lanBroadcasting === '1' || ctx.request.body.lanBroadcasting === 'true')); @@ -8433,11 +9215,12 @@ router .post("/settings/rebuild", async ctx => { meta.rebuild(); ctx.redirect("/settings"); }) .post("/modules/preset", koaBody(), async (ctx) => { if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; } - const ALL_MODULES = ['popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'fediverse', 'invites', 'wallet', 'legacy', 'dev', 'cipher', 'bookmarks', 'calendars', 'chats', 'videos', 'docs', 'audios', 'tags', 'images', 'maps', 'trending', 'events', 'tasks', 'market', 'tribes', 'larp', 'votes', 'reports', 'opinions', 'pads', 'transfers', 'feed', 'pixelia', 'melody', 'agenda', 'favorites', 'ai', 'forum', 'games', 'jobs', 'projects', 'industry', 'shops', 'banking', 'parliament', 'courts', 'logs', 'torrents']; + const ALL_MODULES = workflowsModel.ALL_MODULES; const PRESETS = { - minimal: ['feed', 'forum', 'games', 'images', 'videos', 'audios', 'bookmarks', 'tags', 'trending', 'popular', 'latest', 'threads', 'opinions', 'cipher', 'legacy'], - social: ['agenda', 'audios', 'bookmarks', 'calendars', 'chats', 'cipher', 'courts', 'docs', 'events', 'favorites', 'fediverse', 'feed', 'forum', 'games', 'images', 'invites', 'larp', 'legacy', 'logs', 'maps', 'multiverse', 'opinions', 'pads', 'parliament', 'pixelia', 'melody', 'projects', 'reports', 'tags', 'tasks', 'threads', 'trending', 'tribes', 'videos', 'votes'], - economy: ['agenda', 'audios', 'bookmarks', 'calendars', 'chats', 'cipher', 'courts', 'docs', 'events', 'favorites', 'fediverse', 'feed', 'forum', 'games', 'images', 'invites', 'larp', 'legacy', 'logs', 'maps', 'multiverse', 'opinions', 'pads', 'parliament', 'pixelia', 'melody', 'projects', 'reports', 'tags', 'tasks', 'threads', 'trending', 'tribes', 'videos', 'votes', 'banking', 'wallet', 'transfers', 'market', 'jobs', 'shops', 'industry'], + minimal: ['feed', 'forum', 'games', 'images', 'videos', 'audios', 'bookmarks', 'tags', 'trending', 'blogs', 'polls', 'opinions', 'cipher', 'legacy'], + social: ['agenda', 'audios', 'bookmarks', 'calendars', 'chats', 'cipher', 'courts', 'docs', 'events', 'favorites', 'fediverse', 'feed', 'forum', 'games', 'images', 'invites', 'larp', 'legacy', 'logs', 'maps', 'blogs', 'polls', 'opinions', 'pads', 'parliament', 'pixelia', 'melody', 'projects', 'reports', 'tags', 'tasks', 'trending', 'tribes', 'videos', 'votes'], + economy: ['agenda', 'audios', 'bookmarks', 'calendars', 'chats', 'cipher', 'courts', 'docs', 'events', 'favorites', 'fediverse', 'feed', 'forum', 'games', 'images', 'invites', 'larp', 'legacy', 'logs', 'maps', 'blogs', 'polls', 'opinions', 'pads', 'parliament', 'pixelia', 'melody', 'projects', 'reports', 'tags', 'tasks', 'trending', 'tribes', 'videos', 'votes', 'banking', 'wallet', 'transfers', 'market', 'housing', 'jobs', 'shops', 'industry'], + mobile: workflowsModel.MOBILE_MODULES, full: ALL_MODULES }; const preset = String(ctx.request.body.preset || ''); @@ -8450,7 +9233,7 @@ router }) .post("/save-modules", koaBody(), async (ctx) => { if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; } - const modules = ['popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'fediverse', 'invites', 'wallet', 'legacy', 'dev', 'cipher', 'bookmarks', 'calendars', 'chats', 'videos', 'docs', 'audios', 'tags', 'images', 'maps', 'trending', 'events', 'tasks', 'market', 'tribes', 'larp', 'votes', 'reports', 'opinions', 'pads', 'transfers', 'feed', 'pixelia', 'melody', 'agenda', 'favorites', 'ai', 'forum', 'games', 'graphos', 'jobs', 'projects', 'industry', 'shops', 'banking', 'parliament', 'courts', 'logs', 'torrents']; + const modules = workflowsModel.ALL_MODULES; const cfg = getConfig(); modules.forEach(mod => cfg.modules[`${mod}Mod`] = ctx.request.body[`${mod}Form`] === 'on' ? 'on' : 'off'); saveConfig(cfg); @@ -8460,14 +9243,7 @@ router const aiPrompt = String(ctx.request.body.ai_prompt || "").trim(); if (aiPrompt.length > 128) { sendErrorPage(ctx, "Prompt too long. Must be 128 characters or fewer.", { status: 400 }); return; } const cfg = getConfig(); - cfg.ai = { ...(cfg.ai || {}), prompt: aiPrompt }; - saveConfig(cfg); - ctx.redirect("/settings"); - }) - .post("/settings/pub-id", koaBody(), async (ctx) => { - if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; } - const b = ctx.request.body, cfg = getConfig(); - cfg.walletPub = { pubId: String(b.pub_id || "").trim() }; + cfg.ai = { ...(cfg.ai || {}), prompt: aiPrompt, suggestions: ctx.request.body.ai_suggestions === 'on' }; saveConfig(cfg); ctx.redirect("/settings"); }) @@ -8561,22 +9337,24 @@ const middleware = [ const { i18n } = require('../views/main_views'); if (err.name === 'FileTooLargeError' || (err.message && err.message.includes('maxFileSize'))) { sendErrorPage(ctx, i18n.fileTooLargeMessage, { title: i18n.fileTooLargeTitle, status: 413 }); - } else { - sendErrorPage(ctx, err.message || 'Internal Server Error', { status: err.status || 500 }); + return; } + const moduleHome = moduleHomeFor(ctx); + if (moduleHome && isMissingContentError(err)) { ctx.redirect(moduleHome); return; } + sendErrorPage(ctx, err.message || 'Internal Server Error', { status: err.status || 500 }); } } }, async (ctx, next) => { if (!ctx.path.startsWith('/assets/') && !ctx.path.startsWith('/image/') && !ctx.path.startsWith('/blob/') && !ctx.path.startsWith('/qr') && !ctx.path.startsWith('/c/')) { - // filtro del hive (Personal/Community) del home; lo leen la topbar (enlaces) y renderHiveNav (categorías mostradas) + // filtro del hive (Personal/Community); lo leen la topbar y renderHiveNav global.__OASIS_HIVE_FILTER__ = (ctx.path === '/activity' && (ctx.query.hive === 'personal' || ctx.query.hive === 'community')) ? ctx.query.hive : ''; - // el hive (hexágonos) SOLO en la home; en páginas de contenido el top queda limpio (solo topbar) + // los hexagonos SOLO en la home; en el resto de paginas el top queda limpio global.__OASIS_SHOW_HIVE__ = (ctx.path === '/activity' || ctx.path === '/'); const now = Date.now(); if (now - sharedState.getLastRefresh() > 60000) { sharedState.setLastRefresh(now); try { - // avatar propio para el círculo del topbar móvil (lo lee renderMobileTopbar) + // avatar propio para el circulo de la topbar movil (lo lee renderMobileTopbar) const meId = getViewerId(); const selfImg = meId ? await about.image(meId) : null; const NULL_IMG = '&' + '0'.repeat(43) + '=.sha256'; @@ -8597,10 +9375,17 @@ const middleware = [ sharedState.setCarbonHcT(hcT); sharedState.setCarbonHcH(hcH); } catch (_) {} + try { + const dataRes = await dataModel.listMatches('ALL'); + const top = (dataRes.matches || [])[0] || null; + sharedState.setBestMatch(top ? { href: top.href, title: top.title || top.id, kind: top.kind, score: top.score } : null); + } catch (_) {} try { await refreshInboxCount(); } catch (_) {} + try { await refreshMentionsCount(); } catch (_) {} try { await calendarsModel.checkDueReminders(); } catch (_) {} try { await tasksModel.checkDueReminders(); } catch (_) {} try { await checkPoliticalChanges(); } catch (_) {} + try { await checkJobMatches(); } catch (_) {} try { const peers = await meta.connectedPeers(); sharedState.setOnlinePeerCount(Array.isArray(peers) ? peers.length : 0); @@ -8655,7 +9440,7 @@ const middleware = [ } await next(); }, - // rutas propias de la rama (Karvan): fuera de la cadena de upstream + // rutas propias de la rama (Karvan, barra inferior, exportacion): fuera de la cadena de upstream require('./fork_routes')({ cooler, pull, pmModel, checkMod, getViewerId, getConfig, saveConfig, legacyModel, onboardingModel, isLoopbackRequest, sendErrorPage }), @@ -8748,15 +9533,22 @@ function buildLarpRulingMsg(data = {}, t = {}) { house: `[${name}](/larp/${encodeURIComponent(data.houseKey || '')})`, cycle: `[${data.cycleFormatted || ''}](/larp)` }); - const lines = [ - intro, '', name, house.description || '', - `${t.larpRolesLabel || 'Roles'}: ${house.roles || ''}`, - `${t.larpFunctionLabel || 'Function'}: ${house.function || ''}`, - `${t.larpMonthLabel || 'Governance cycle'}: ${house.month != null ? house.month : ''}`, - `${t.larpMembersCount || 'Members'}: ${data.memberCount || 0}`, - house.motto ? `“${house.motto}”` : '' + const blocks = [ + [intro], + [name, house.description || ''], + [ + `${t.larpRolesLabel || 'Roles'}: ${house.roles || ''}`, + `${t.larpFunctionLabel || 'Function'}: ${house.function || ''}`, + `${t.larpMonthLabel || 'Governance cycle'}: ${house.month != null ? house.month : ''}`, + `${t.larpMembersCount || 'Members'}: ${data.memberCount || 0}` + ], + [house.motto ? `“${house.motto}”` : ''] ]; - return { subject: 'LARP_RULING', body: lines.filter(l => l != null && l !== '').join('\n') }; + const body = blocks + .map(block => block.filter(l => l != null && String(l).trim() !== '').join('\n')) + .filter(Boolean) + .join('\n\n'); + return { subject: 'LARP_RULING', body }; } function pbGovBlock(gov = {}, t = {}) { const lines = [`${t.parliamentGovernmentCard || 'Current Government'}: ${String(gov.method || '').toUpperCase()}`]; @@ -8782,6 +9574,49 @@ function buildTribeGovMsg(gov = {}, t = {}) { : pbFill(t.politicalBotTribeIntro || 'The current government in the Tribe {tribe} is {gov}', vars); return { subject: 'TRIBE_GOV', body: [intro, ''].concat(pbGovBlock(gov, t)).join('\n') }; } +let jobsBotRunning = false; +async function checkJobMatches() { + if (jobsBotRunning) return; + jobsBotRunning = true; + try { + const viewerId = getViewerId(); + if (!viewerId) return; + const cv = await cvModel.getCVByUserId().catch(() => null); + if (!cv || cv.aiManaged === false) return; + const threshold = Math.min(100, Math.max(0, Number(cv.matchThreshold) || 80)) / 100; + const matches = await dataModel.jobMatchesFor(viewerId, { minScore: threshold }).catch(() => []); + if (!matches.length) return; + const announced = await pmModel.sentRefs().catch(() => new Set()); + const i18nAll = require('../client/assets/translations/i18n'); + const lang = (getConfig() && getConfig().language) || 'en'; + const t = i18nAll[lang] || i18nAll.en; + for (const m of matches.slice(0, 5)) { + const ref = `${m.id}|${Math.round(m.score * 100)}`; + if (announced.has(`JOB_MATCH|${ref}`)) continue; + const pctText = `${Math.round(m.score * 100)}%`; + const body = [ + (t.jobsBotMatchIntro || 'A job matches your curriculum.'), + '', + `${t.jobsBotMatchJob || 'Job'}: [${m.title || m.id}](${m.href})`, + `${t.matchScore || 'Match'}: ${pctText}`, + m.common.length ? `${t.commonSkills || 'Common skills'}: ${m.common.slice(0, 10).join(', ')}` : '', + '', + (t.jobsBotMatchHint || 'You receive this because your curriculum is AI managed. You can turn it off in your CV.') + ].filter(Boolean).join('\n'); + try { + await pmModel.sendMessage([], 'JOB_MATCH', body, false, ref); + announced.add(`JOB_MATCH|${ref}`); + } catch (e) { + if (config.debug) console.error('[jobs-bot] send failed:', e && e.message); + } + } + } catch (e) { + if (config.debug) console.error('[jobs-bot] failed:', e && e.message); + } finally { + jobsBotRunning = false; + } +} + let politicalCheckRunning = false; async function checkPoliticalChanges() { if (politicalCheckRunning) return; @@ -8795,10 +9630,19 @@ async function checkPoliticalChanges() { const t = i18nAll[lang] || i18nAll.en; const announced = await pmModel.sentRefs().catch(() => new Set()); const alreadyAnnounced = (subject, ref) => announced.has(`${subject}|${ref}`); + const seen = pmPolicy.readAnnounceSeen(); const send = async (subject, body, ref) => { - if (!ref || alreadyAnnounced(subject, ref)) return; - try { await pmModel.sendMessage([], subject, body, false, ref); announced.add(`${subject}|${ref}`); } - catch (e) { console.error('[political-bot] send failed:', e && e.message); } + const { send: shouldSend, remember } = pmPolicy.decideAnnouncement({ subject, ref, announced, seen }); + if (!shouldSend) { + if (remember) { seen[subject] = String(ref); pmPolicy.writeAnnounceSeen(seen); } + return; + } + try { + await pmModel.sendMessage([], subject, body, false, ref); + announced.add(`${subject}|${ref}`); + seen[subject] = String(ref); + pmPolicy.writeAnnounceSeen(seen); + } catch (e) { console.error('[political-bot] send failed:', e && e.message); } }; try { @@ -8806,7 +9650,7 @@ async function checkPoliticalChanges() { if (houseKey) { let cycleFormatted = ''; try { cycleFormatted = larpModel.computeCycle().formatted; } catch (_) {} - const ref = `${houseKey}:${cycleFormatted}`; + const ref = larpModel.getGoverningPeriodId(); if (!alreadyAnnounced('LARP_RULING', ref)) { const house = (larpModel.getHouse && larpModel.getHouse(houseKey)) || {}; let memberCount = 0; @@ -8820,7 +9664,7 @@ async function checkPoliticalChanges() { try { const card = await parliamentModel.getLatestGovernmentCard().catch(() => null); if (card) { - const ref = String(card.id || card.since || card.method || ''); + const ref = [String(card.id || card.method || ''), String(card.since || '')].filter(Boolean).join(':'); if (ref && !alreadyAnnounced('PARLIAMENT_GOV', ref)) { let population = 0; try { const inh = await inhabitantsModel.listInhabitants({ filter: 'all', includeInactive: true }); population = Array.isArray(inh) ? inh.length : 0; } catch (_) {} diff --git a/src/backend/contentPdf.js b/src/backend/contentPdf.js new file mode 100644 index 0000000..c84e05b --- /dev/null +++ b/src/backend/contentPdf.js @@ -0,0 +1,280 @@ +const { buildDocumentPdf } = require('./pdfDocument'); + +const fmtDate = v => { + if (!v) return ''; + const d = new Date(v); + return isNaN(d.getTime()) ? String(v) : d.toISOString().replace('T', ' ').slice(0, 19) + ' UTC'; +}; + +const fmtDay = v => { + if (!v) return ''; + const d = new Date(v); + return isNaN(d.getTime()) ? String(v) : d.toISOString().slice(0, 10); +}; + +const asList = v => (Array.isArray(v) ? v : []); +const txt = v => (v == null ? '' : String(v)); + +const humanLabel = (key) => String(key || '') + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/^./, c => c.toUpperCase()); + +const pushMeta = (out, item) => { + out.push({ kind: 'blank' }); + out.push({ kind: 'section', text: 'METADATA' }); + if (item.id) out.push({ kind: 'kv', label: 'Content ID', value: item.id }); + const author = item.author || item.organizer || item.createdBy || item.from || ''; + if (author) out.push({ kind: 'kv', label: 'Author', value: author }); + if (item.createdAt) out.push({ kind: 'kv', label: 'Created At', value: fmtDate(item.createdAt) }); + if (item.updatedAt) out.push({ kind: 'kv', label: 'Updated At', value: fmtDate(item.updatedAt) }); +}; + +const pushOpinions = (out, item) => { + const op = item.opinions && typeof item.opinions === 'object' ? item.opinions : {}; + const entries = Object.entries(op).filter(([, n]) => Number(n) > 0); + if (!entries.length) return; + out.push({ kind: 'blank' }); + out.push({ kind: 'section', text: 'OPINIONS' }); + for (const [cat, n] of entries) out.push({ kind: 'kv', label: humanLabel(cat), value: String(n) }); +}; + +const pushTags = (out, item) => { + const tags = asList(item.tags).filter(Boolean); + if (tags.length) out.push({ kind: 'kv', label: 'Tags', value: tags.join(', ') }); +}; + +const reportSections = (report) => { + const out = []; + out.push({ kind: 'title', text: `Report: ${txt(report.title) || '-'}` }); + out.push({ kind: 'blank' }); + + out.push({ kind: 'section', text: 'CLASSIFICATION' }); + out.push({ kind: 'kv', label: 'Category', value: txt(report.category).toUpperCase() }); + out.push({ kind: 'kv', label: 'Severity', value: txt(report.severity).toUpperCase() }); + out.push({ kind: 'kv', label: 'Status', value: txt(report.status).toUpperCase() }); + pushTags(out, report); + + if (txt(report.description).trim()) { + out.push({ kind: 'blank' }); + out.push({ kind: 'section', text: 'DESCRIPTION' }); + out.push({ kind: 'text', text: report.description }); + } + + const tpl = report.template && typeof report.template === 'object' ? report.template : {}; + const tplEntries = Object.entries(tpl).filter(([, v]) => txt(v).trim()); + if (tplEntries.length) { + out.push({ kind: 'blank' }); + out.push({ kind: 'section', text: 'DETAILS' }); + for (const [k, v] of tplEntries) out.push({ kind: 'kv', label: humanLabel(k), value: v }); + } + + const confirmations = asList(report.confirmations); + out.push({ kind: 'blank' }); + out.push({ kind: 'section', text: 'CONFIRMATIONS' }); + out.push({ kind: 'kv', label: 'Confirmed', value: String(confirmations.length) }); + for (const c of confirmations) out.push({ kind: 'kv', label: 'Confirmed by', value: c }); + + pushOpinions(out, report); + pushMeta(out, report); + return out; +}; + +const voteSections = (vote) => { + const out = []; + out.push({ kind: 'title', text: `Votation: ${txt(vote.question) || '-'}` }); + out.push({ kind: 'blank' }); + + out.push({ kind: 'section', text: 'TERMS' }); + out.push({ kind: 'kv', label: 'Status', value: txt(vote.status).toUpperCase() }); + if (vote.deadline) out.push({ kind: 'kv', label: 'Deadline', value: fmtDate(vote.deadline) }); + pushTags(out, vote); + + const votes = vote.votes && typeof vote.votes === 'object' ? vote.votes : {}; + const total = Number(vote.totalVotes) || asList(vote.voters).length; + out.push({ kind: 'blank' }); + out.push({ kind: 'section', text: 'RESULTS' }); + out.push({ kind: 'kv', label: 'Total votes', value: String(total) }); + for (const [choice, n] of Object.entries(votes)) { + const count = Number(n) || 0; + const pct = total > 0 ? Math.round((count / total) * 100) : 0; + out.push({ kind: 'kv', label: choice, value: `${count} (${pct}%)` }); + } + + pushOpinions(out, vote); + pushMeta(out, vote); + return out; +}; + +const eventSections = (event) => { + const out = []; + out.push({ kind: 'title', text: `Event: ${txt(event.title) || '-'}` }); + out.push({ kind: 'blank' }); + + out.push({ kind: 'section', text: 'SCHEDULE' }); + out.push({ kind: 'kv', label: 'Date', value: fmtDate(event.date) }); + out.push({ kind: 'kv', label: 'Status', value: txt(event.status).toUpperCase() }); + if (event.location) out.push({ kind: 'kv', label: 'Location', value: event.location }); + if (event.mapUrl) out.push({ kind: 'kv', label: 'Map', value: event.mapUrl }); + if (Number(event.price) > 0) out.push({ kind: 'kv', label: 'Price', value: `${Number(event.price)} ECO` }); + out.push({ kind: 'kv', label: 'Privacy', value: txt(event.isPublic).toUpperCase() }); + if (event.url) out.push({ kind: 'kv', label: 'Url', value: event.url }); + pushTags(out, event); + + if (txt(event.description).trim()) { + out.push({ kind: 'blank' }); + out.push({ kind: 'section', text: 'DESCRIPTION' }); + out.push({ kind: 'text', text: event.description }); + } + + const attendees = asList(event.attendees); + out.push({ kind: 'blank' }); + out.push({ kind: 'section', text: 'ATTENDEES' }); + out.push({ kind: 'kv', label: 'Total', value: String(attendees.length) }); + for (const a of attendees) out.push({ kind: 'kv', label: 'Attendee', value: a }); + + pushOpinions(out, event); + pushMeta(out, event); + return out; +}; + +const taskSections = (task) => { + const out = []; + out.push({ kind: 'title', text: `Task: ${txt(task.title) || '-'}` }); + out.push({ kind: 'blank' }); + + out.push({ kind: 'section', text: 'SCHEDULE' }); + out.push({ kind: 'kv', label: 'Status', value: txt(task.status).toUpperCase() }); + out.push({ kind: 'kv', label: 'Priority', value: txt(task.priority).toUpperCase() }); + if (task.startTime) out.push({ kind: 'kv', label: 'Starts', value: fmtDate(task.startTime) }); + if (task.endTime) out.push({ kind: 'kv', label: 'Ends', value: fmtDate(task.endTime) }); + if (task.location) out.push({ kind: 'kv', label: 'Location', value: task.location }); + out.push({ kind: 'kv', label: 'Privacy', value: txt(task.isPublic).toUpperCase() }); + pushTags(out, task); + + if (txt(task.description).trim()) { + out.push({ kind: 'blank' }); + out.push({ kind: 'section', text: 'DESCRIPTION' }); + out.push({ kind: 'text', text: task.description }); + } + + const assignees = asList(task.assignees); + out.push({ kind: 'blank' }); + out.push({ kind: 'section', text: 'ASSIGNEES' }); + out.push({ kind: 'kv', label: 'Total', value: String(assignees.length) }); + for (const a of assignees) out.push({ kind: 'kv', label: 'Assignee', value: a }); + + pushOpinions(out, task); + pushMeta(out, task); + return out; +}; + +const calendarSections = (calendar, extra = {}) => { + const dates = asList(extra.dates); + const notesByDate = extra.notesByDate && typeof extra.notesByDate === 'object' ? extra.notesByDate : {}; + + const out = []; + out.push({ kind: 'title', text: `Calendar: ${txt(calendar.title) || '-'}` }); + out.push({ kind: 'blank' }); + + out.push({ kind: 'section', text: 'SUMMARY' }); + out.push({ kind: 'kv', label: 'Status', value: calendar.isClosed ? 'CLOSED' : txt(calendar.status).toUpperCase() }); + if (calendar.deadline) out.push({ kind: 'kv', label: 'Deadline', value: fmtDate(calendar.deadline) }); + if (calendar.mapUrl) out.push({ kind: 'kv', label: 'Map', value: calendar.mapUrl }); + out.push({ kind: 'kv', label: 'Participants', value: String(asList(calendar.participants).length) }); + out.push({ kind: 'kv', label: 'Dates', value: String(dates.length) }); + const noteTotal = Object.values(notesByDate).reduce((n, arr) => n + asList(arr).length, 0); + out.push({ kind: 'kv', label: 'Notes', value: String(noteTotal) }); + pushTags(out, calendar); + + const participants = asList(calendar.participants); + if (participants.length) { + out.push({ kind: 'blank' }); + out.push({ kind: 'section', text: 'PARTICIPANTS' }); + for (const p of participants) out.push({ kind: 'kv', label: 'Participant', value: p }); + } + + if (dates.length) { + out.push({ kind: 'blank' }); + out.push({ kind: 'section', text: 'DATES' }); + for (const d of dates) { + const day = fmtDay(d && d.date) || txt(d && d.date); + out.push({ kind: 'kv', label: day, value: txt(d && d.label) }); + for (const note of asList(notesByDate[d && d.key])) { + const noteText = txt(note && note.text); + if (noteText) out.push({ kind: 'text', text: ` - ${noteText}` }); + } + } + } + + pushMeta(out, calendar); + return out; +}; + +const cvSections = (cv) => { + const out = []; + out.push({ kind: 'title', text: `Curriculum: ${txt(cv.name) || txt(cv.author) || '-'}` }); + out.push({ kind: 'blank' }); + + out.push({ kind: 'section', text: 'PROFILE' }); + if (cv.location) out.push({ kind: 'kv', label: 'Location', value: cv.location }); + if (cv.status) out.push({ kind: 'kv', label: 'Status', value: txt(cv.status).toUpperCase() }); + if (cv.preferences) out.push({ kind: 'kv', label: 'Preferences', value: txt(cv.preferences).toUpperCase() }); + if (cv.languages) out.push({ kind: 'kv', label: 'Languages', value: cv.languages }); + + if (txt(cv.description).trim()) { + out.push({ kind: 'blank' }); + out.push({ kind: 'section', text: 'DESCRIPTION' }); + out.push({ kind: 'text', text: cv.description }); + } + + const blocks = [ + ['PERSONAL', cv.personalExperiences, cv.personalSkills], + ['EDUCATION', cv.educationExperiences, cv.educationalSkills], + ['PROFESSIONAL', cv.professionalExperiences, cv.professionalSkills], + ['OASIS', cv.oasisExperiences, cv.oasisSkills] + ]; + + for (const [heading, experiences, skills] of blocks) { + const skillList = asList(skills).filter(Boolean); + const body = txt(experiences).trim(); + if (!body && !skillList.length) continue; + out.push({ kind: 'blank' }); + out.push({ kind: 'section', text: heading }); + if (body) out.push({ kind: 'text', text: body }); + if (skillList.length) out.push({ kind: 'kv', label: 'Skills', value: skillList.join(', ') }); + } + + pushMeta(out, cv); + return out; +}; + +const BUILDERS = { + reports: { title: 'OASIS - Report', sections: reportSections, name: item => item.title }, + votes: { title: 'OASIS - Votation', sections: voteSections, name: item => item.question }, + events: { title: 'OASIS - Event', sections: eventSections, name: item => item.title }, + tasks: { title: 'OASIS - Task', sections: taskSections, name: item => item.title }, + calendars: { title: 'OASIS - Calendar', sections: calendarSections, name: item => item.title }, + cv: { title: 'OASIS - Curriculum', sections: cvSections, name: item => item.name || item.author } +}; + +const isSupported = kind => Object.prototype.hasOwnProperty.call(BUILDERS, kind); + +const pdfFilename = (kind, item) => { + const b = BUILDERS[kind]; + const raw = b ? txt(b.name(item || {})) : ''; + const slug = raw.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40); + return `oasis-${kind}-${slug || 'document'}.pdf`; +}; + +const buildContentPdf = (kind, item, extra = {}, viewerId = null) => { + const b = BUILDERS[kind]; + if (!b) throw new Error('Unsupported pdf kind'); + return buildDocumentPdf({ + title: b.title, + issuedToLabel: 'Issued to', + issuedTo: viewerId || null, + sections: b.sections(item || {}, extra) + }); +}; + +module.exports = { buildContentPdf, pdfFilename, isSupported }; diff --git a/src/configs/config-manager.js b/src/configs/config-manager.js index 5143845..b76bad5 100644 --- a/src/configs/config-manager.js +++ b/src/configs/config-manager.js @@ -13,6 +13,7 @@ if (!fs.existsSync(configFilePath)) { }, "modules": { "blogsMod": "on", + "housingMod": "off", "karvanMod": "on", "popularMod": "on", "topicsMod": "on", diff --git a/src/configs/oasis-config.json b/src/configs/oasis-config.json index 3321f2d..f5b6c5a 100644 --- a/src/configs/oasis-config.json +++ b/src/configs/oasis-config.json @@ -53,7 +53,8 @@ "chatsMod": "on", "torrentsMod": "off", "graphosMod": "on", - "larpMod": "on" + "larpMod": "on", + "housingMod": "off" }, "wallet": { "url": "http://localhost:7474", diff --git a/src/models/housing_model.js b/src/models/housing_model.js new file mode 100644 index 0000000..0b7bbb3 --- /dev/null +++ b/src/models/housing_model.js @@ -0,0 +1,554 @@ +const pull = require("../server/node_modules/pull-stream") +const moment = require("../server/node_modules/moment") +const categories = require("../backend/opinion_categories") +const { getConfig } = require("../configs/config-manager.js") +const { dedupeByPreferring } = require('../backend/dedupe') +const logLimit = getConfig().ssbLogStream?.limit || 1000 + +const HOUSING_TYPES = ["sale", "rent", "couchsurfing"] +const PROPERTY_TYPES = ["apartment", "house", "room", "land", "other"] + +const norm = (s) => String(s || "").trim().toLowerCase() +const safeArr = (v) => (Array.isArray(v) ? v : []) + +const toNum = (v) => { + const n = parseFloat(String(v ?? "").replace(",", ".")) + return Number.isFinite(n) ? n : NaN +} + +const toInt = (v, fallback = 0) => { + const n = parseInt(String(v ?? ""), 10) + return Number.isFinite(n) ? n : fallback +} + +const nonNeg = (v) => { + const n = toNum(v) + return Number.isFinite(n) && n >= 0 ? n : 0 +} + +const safeDate = (v) => { + const s = String(v || "").trim() + if (!s) return "" + const d = new Date(s) + return Number.isFinite(d.getTime()) ? s : "" +} + +const normalizeTags = (raw) => { + if (raw === undefined || raw === null) return [] + if (Array.isArray(raw)) return raw.map(t => String(t || "").trim()).filter(Boolean) + return String(raw).split(",").map(t => t.trim()).filter(Boolean) +} + +const safeType = (v) => { + const t = norm(v) + return HOUSING_TYPES.includes(t) ? t : "" +} + +const safeProperty = (v) => { + const t = norm(v) + return PROPERTY_TYPES.includes(t) ? t : "other" +} + +const { MAX_IMAGES, normalizeImages, normalizeVideo } = require('./media_gallery') + +const matchSearch = (item, q) => { + const qq = norm(q) + if (!qq) return true + const hay = [ + item.title, + item.description, + item.place, + item.rules, + item.housing_type, + item.property_type, + safeArr(item.tags).join(" ") + ].map(x => norm(x)).join(" ") + return hay.includes(qq) +} + +module.exports = ({ cooler, tribeCrypto }) => { + let ssb + const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb } + + const isEncrypted = (c) => !!(c && c.encryptedPayload) + + const keysForRoot = (rootId) => { + if (!tribeCrypto || !rootId) return [] + const ks = (tribeCrypto.getKeys && tribeCrypto.getKeys(rootId)) || [] + if (ks.length) return ks + const k = tribeCrypto.getKey ? tribeCrypto.getKey(rootId) : null + return k ? [k] : [] + } + + const decryptHousing = (c, rootId) => { + if (!isEncrypted(c)) return c + if (!tribeCrypto) return { ...c, _undecryptable: true } + const keys = keysForRoot(rootId) + if (!keys.length) return { ...c, _undecryptable: true } + return tribeCrypto.decryptContent(c, keys.map(k => [k])) + } + + const publishHousing = (ssbClient, content, rootId) => new Promise((resolve, reject) => { + const hidden = String(content.visibility || "PUBLIC").toUpperCase() === "HIDDEN" + if (!hidden || !tribeCrypto) { + ssbClient.publish(content, (err, msg) => { + if (err) return reject(err) + if (msg && msg.key && tribeCrypto && !rootId) { + try { tribeCrypto.setKey(msg.key, tribeCrypto.generateTribeKey(), 1) } catch (_) {} + } + resolve(msg) + }) + return + } + let key = rootId ? (keysForRoot(rootId)[0] || null) : null + if (!key) key = tribeCrypto.generateTribeKey() + let envelope + try { envelope = tribeCrypto.encryptContent(content, [key], true) } catch (err) { return reject(err) } + ssbClient.publish(envelope, (err, msg) => { + if (err) return reject(err) + try { tribeCrypto.setKey(rootId || (msg && msg.key), key, 1) } catch (_) {} + resolve(msg) + }) + }) + + const readAll = async (ssbClient) => + new Promise((resolve, reject) => + pull( + ssbClient.createLogStream({ limit: logLimit }), + pull.collect((err, msgs) => err ? reject(err) : resolve(msgs)) + ) + ) + + const buildIndex = (messages, ssbClient) => { + const tomb = new Set() + const nodes = new Map() + const parent = new Map() + const child = new Map() + const naiveReplaces = new Map() + const requestLatest = new Map() + const opinionMsgs = [] + + for (const m of messages) { + const key = m.key + const v = m.value || {} + const c = v.content + if (!c || typeof c !== "object") continue + + if (c.type === "tombstone" && c.target) { tomb.add(c.target); continue } + + if (c.type === "housing") { + nodes.set(key, { key, ts: v.timestamp || m.timestamp || 0, c, author: v.author }) + if (c.replaces) naiveReplaces.set(key, c.replaces) + continue + } + + if (c.type === "housingOpinion" && c.target) { + opinionMsgs.push({ target: c.target, author: v.author, category: c.category }) + continue + } + + if (c.type === "housingRequest" && c.housingId) { + const author = v.author + if (!author) continue + const ts = v.timestamp || m.timestamp || 0 + const k = `${c.housingId}::${author}` + const prev = requestLatest.get(k) + if (!prev || ts >= prev.ts) requestLatest.set(k, { ts, value: !!c.value, author, housingId: c.housingId }) + continue + } + } + + for (const [key, replacesId] of naiveReplaces.entries()) { + const node = nodes.get(key) + if (!node) continue + const orig = nodes.get(replacesId) + if (!orig) continue + if (String(orig.author) !== String(node.author)) { nodes.delete(key); continue } + parent.set(key, replacesId) + child.set(replacesId, key) + } + + const rootOf = (id) => { + let cur = id, guard = 0 + while (parent.has(cur) && guard++ < 100000) cur = parent.get(cur) + return cur + } + + const tipOf = (id) => { + let cur = id, guard = 0 + while (child.has(cur) && guard++ < 100000) cur = child.get(cur) + return cur + } + + const roots = new Set() + for (const id of nodes.keys()) roots.add(rootOf(id)) + + const tipByRoot = new Map() + for (const r of roots) tipByRoot.set(r, tipOf(r)) + + if (ssbClient) { + for (const m of messages) { + if (typeof m.value?.content !== 'string') continue + try { + const dec = ssbClient.private.unbox({ key: m.key, value: m.value, timestamp: m.value?.timestamp || m.timestamp || 0 }) + const c = dec?.value?.content + if (!c || c.type !== 'housingRequest' || !c.housingId) continue + const author = dec.value.author + if (!author) continue + const ts = dec.value.timestamp || m.timestamp || 0 + const k = `${c.housingId}::${author}` + const prev = requestLatest.get(k) + if (!prev || ts >= prev.ts) requestLatest.set(k, { ts, value: !!c.value, author, housingId: c.housingId }) + } catch {} + } + } + + const requestsByRoot = new Map() + const everRequestedByRoot = new Map() + for (const { housingId, author, value } of requestLatest.values()) { + if (!requestsByRoot.has(housingId)) requestsByRoot.set(housingId, new Set()) + if (!everRequestedByRoot.has(housingId)) everRequestedByRoot.set(housingId, new Set()) + everRequestedByRoot.get(housingId).add(author) + const set = requestsByRoot.get(housingId) + if (value) set.add(author) + else set.delete(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 aggregateFor = (rootId, content, ownerId) => { + const opinions = { ...((content && content.opinions) || {}) } + const voters = safeArr(content && content.opinions_inhabitants).slice() + const voterSet = new Set(voters) + for (const op of (opinionsByRoot.get(rootId) || [])) { + if (!op.author || op.author === ownerId) continue + if (voterSet.has(op.author)) continue + if (!categories.includes(op.category)) continue + voterSet.add(op.author); voters.push(op.author) + opinions[op.category] = (opinions[op.category] || 0) + 1 + } + return { opinions, opinions_inhabitants: voters } + } + + return { tomb, nodes, parent, child, rootOf, tipOf, tipByRoot, requestsByRoot, everRequestedByRoot, aggregateFor } + } + + const buildObject = (node, rootId, idx, viewer) => { + const raw = node.c || {} + const c = decryptHousing(raw, rootId) + if (c._undecryptable) return null + const author = node.author || c.author + const requests = Array.from(idx.requestsByRoot.get(rootId) || []) + const visibleRequests = (author === viewer) ? requests : (requests.includes(viewer) ? [viewer] : []) + const agg = idx.aggregateFor(rootId, c, author) + return { + id: node.key, + rootId, + housing_type: safeType(c.housing_type), + property_type: safeProperty(c.property_type), + title: String(c.title || ""), + description: String(c.description || ""), + rules: String(c.rules || ""), + place: String(c.place || ""), + mapUrl: String(c.mapUrl || ""), + price: Number.isFinite(toNum(c.price)) ? toNum(c.price).toFixed(6) : "0.000000", + rooms: toInt(c.rooms, 0), + size: nonNeg(c.size), + capacity: toInt(c.capacity, 0), + availableFrom: safeDate(c.availableFrom), + availableTo: safeDate(c.availableTo), + images: normalizeImages(c.images && c.images.length ? c.images : c.image), + image: normalizeImages(c.images && c.images.length ? c.images : c.image)[0] || null, + video: normalizeVideo(c.video), + author, + createdAt: c.createdAt || new Date(node.ts).toISOString(), + updatedAt: c.updatedAt || null, + status: String(c.status || "OPEN").toUpperCase() === "CLOSED" ? "CLOSED" : "OPEN", + tags: Array.isArray(c.tags) ? c.tags : normalizeTags(c.tags), + requests: visibleRequests, + requestCount: requests.length, + opinions: agg.opinions, + opinions_inhabitants: agg.opinions_inhabitants, + requestedByViewer: requests.includes(viewer), + everRequestedByViewer: (idx.everRequestedByRoot.get(rootId) || new Set()).has(viewer), + ratedByViewer: agg.opinions_inhabitants.includes(viewer), + visibility: String(c.visibility || "PUBLIC").toUpperCase() === "HIDDEN" ? "HIDDEN" : "PUBLIC" + } + } + + const buildContent = (data, opts = {}) => { + const housing_type = safeType(data.housing_type) + if (!housing_type) throw new Error("Invalid housing type") + + const title = String(data.title || "").trim() + if (!title) throw new Error("Invalid title") + const description = String(data.description || "").trim() + if (!description) throw new Error("Invalid description") + + const availableFrom = safeDate(data.availableFrom) + const availableTo = safeDate(data.availableTo) + if (!availableFrom) throw new Error("The start date is required") + if (opts.enforceFutureStart) { + const [y, m, d] = String(availableFrom).split("-").map(Number) + const startsAt = new Date(y, (m || 1) - 1, d || 1) + const today = new Date() + today.setHours(0, 0, 0, 0) + if (startsAt < today) throw new Error("The start date cannot be earlier than today") + } + if (availableTo && new Date(availableTo) < new Date(availableFrom)) { + throw new Error("The end date cannot be earlier than the start date") + } + + const images = normalizeImages(data.images !== undefined ? data.images : data.image) + const video = normalizeVideo(data.video) + + return { + type: "housing", + housing_type, + property_type: safeProperty(data.property_type), + title, + description, + rules: String(data.rules || "").trim(), + place: String(data.place || "").trim(), + mapUrl: String(data.mapUrl || "").trim(), + price: housing_type === "couchsurfing" ? "0.000000" : nonNeg(data.price).toFixed(6), + rooms: Math.max(0, toInt(data.rooms, 0)), + size: nonNeg(data.size), + capacity: Math.max(0, toInt(data.capacity, 0)), + availableFrom, + availableTo, + images, + image: images[0] || null, + video, + tags: normalizeTags(data.tags), + visibility: String(data.visibility || "PUBLIC").toUpperCase() === "HIDDEN" ? "HIDDEN" : "PUBLIC" + } + } + + return { + type: "housing", + HOUSING_TYPES, + PROPERTY_TYPES, + MAX_IMAGES, + + async createHousing(data) { + const ssbClient = await openSsb() + const content = { + ...buildContent(data, { enforceFutureStart: true }), + author: ssbClient.id, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + status: "OPEN", + opinions: {}, + opinions_inhabitants: [] + } + return publishHousing(ssbClient, content, null) + }, + + async resolveCurrentId(id) { + const ssbClient = await openSsb() + const idx = buildIndex(await readAll(ssbClient), ssbClient) + const tip = idx.tipOf(id) + if (idx.tomb.has(tip)) throw new Error("Housing not found") + return tip + }, + + async resolveRootId(id) { + const ssbClient = await openSsb() + const idx = buildIndex(await readAll(ssbClient), ssbClient) + const tip = idx.tipOf(id) + if (idx.tomb.has(tip)) throw new Error("Housing not found") + return idx.rootOf(tip) + }, + + async updateHousing(id, data) { + const ssbClient = await openSsb() + const idx = buildIndex(await readAll(ssbClient), ssbClient) + const tipId = idx.tipOf(id) + if (idx.tomb.has(tipId)) throw new Error("Housing not found") + const node = idx.nodes.get(tipId) + if (!node || !node.c) throw new Error("Housing not found") + if ((node.author || node.c.author) !== ssbClient.id) throw new Error("Unauthorized") + + const rootId = idx.rootOf(tipId) + const current = decryptHousing(node.c, rootId) + if (current._undecryptable) throw new Error("Cannot decrypt this listing") + const startChanged = data.availableFrom !== undefined && safeDate(data.availableFrom) !== safeDate(current.availableFrom) + const merged = buildContent({ + housing_type: data.housing_type === undefined ? current.housing_type : data.housing_type, + property_type: data.property_type === undefined ? current.property_type : data.property_type, + title: data.title === undefined ? current.title : data.title, + description: data.description === undefined ? current.description : data.description, + rules: data.rules === undefined ? current.rules : data.rules, + place: data.place === undefined ? current.place : data.place, + mapUrl: data.mapUrl === undefined ? current.mapUrl : data.mapUrl, + price: data.price === undefined ? current.price : data.price, + rooms: data.rooms === undefined ? current.rooms : data.rooms, + size: data.size === undefined ? current.size : data.size, + capacity: data.capacity === undefined ? current.capacity : data.capacity, + availableFrom: data.availableFrom === undefined ? current.availableFrom : data.availableFrom, + availableTo: data.availableTo === undefined ? current.availableTo : data.availableTo, + images: data.images === undefined + ? (current.images && current.images.length ? current.images : current.image) + : data.images, + video: data.video === undefined ? current.video : data.video, + tags: data.tags === undefined ? current.tags : data.tags, + visibility: data.visibility === undefined ? current.visibility : data.visibility + }, { enforceFutureStart: startChanged }) + + let status = String(current.status || "OPEN").toUpperCase() + if (data.status !== undefined) { + const s = String(data.status || "").toUpperCase() + if (!["OPEN", "CLOSED"].includes(s)) throw new Error("Invalid status") + status = s + } + + const next = { + ...merged, + status, + author: current.author, + createdAt: current.createdAt, + updatedAt: new Date().toISOString(), + opinions: current.opinions || {}, + opinions_inhabitants: safeArr(current.opinions_inhabitants), + replaces: tipId + } + + const tomb = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: ssbClient.id } + await new Promise((res, rej) => ssbClient.publish(tomb, (e) => e ? rej(e) : res())) + return publishHousing(ssbClient, next, rootId) + }, + + async updateHousingStatus(id, status) { + return this.updateHousing(id, { status: String(status || "").toUpperCase() }) + }, + + async deleteHousing(id) { + const ssbClient = await openSsb() + const idx = buildIndex(await readAll(ssbClient), ssbClient) + const tipId = idx.tipOf(id) + if (idx.tomb.has(tipId)) throw new Error("Housing not found") + const node = idx.nodes.get(tipId) + if (!node) throw new Error("Housing not found") + if ((node.author || node.c.author) !== ssbClient.id) throw new Error("Unauthorized") + const tomb = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: ssbClient.id } + return new Promise((res, rej) => ssbClient.publish(tomb, (e, r) => e ? rej(e) : res(r))) + }, + + async requestHousing(id) { + const ssbClient = await openSsb() + const me = ssbClient.id + const item = await this.getHousingById(id) + if (item.author === me) throw new Error("Cannot request your own listing") + if (item.status !== "OPEN") throw new Error("This listing is closed") + if (safeArr(item.requests).includes(me)) return { alreadyRequested: true } + const msg = { type: "housingRequest", housingId: item.rootId, value: true, createdAt: new Date().toISOString() } + return new Promise((res, rej) => ssbClient.private.publish(msg, [me, item.author], (e, m) => e ? rej(e) : res(m))) + }, + + async cancelRequest(id) { + const ssbClient = await openSsb() + const me = ssbClient.id + const item = await this.getHousingById(id) + if (item.author === me) throw new Error("Cannot cancel a request on your own listing") + if (!safeArr(item.requests).includes(me)) return { notRequested: true } + const msg = { type: "housingRequest", housingId: item.rootId, value: false, createdAt: new Date().toISOString() } + return new Promise((res, rej) => ssbClient.private.publish(msg, [me, item.author], (e, m) => e ? rej(e) : res(m))) + }, + + async createOpinion(id, category) { + if (!categories.includes(category)) throw new Error("Invalid category") + const ssbClient = await openSsb() + const me = ssbClient.id + const idx = buildIndex(await readAll(ssbClient), ssbClient) + const tipId = idx.tipOf(id) + if (idx.tomb.has(tipId)) throw new Error("Housing not found") + const node = idx.nodes.get(tipId) + if (!node) throw new Error("Housing not found") + const rootId = idx.rootOf(tipId) + const owner = node.author || node.c.author + if (owner === me) throw new Error("You cannot rate your own listing") + + const agg = idx.aggregateFor(rootId, decryptHousing(node.c, rootId), owner) + if (agg.opinions_inhabitants.includes(me)) throw new Error("Already voted") + + const everRequested = idx.everRequestedByRoot.get(rootId) || new Set() + if (!everRequested.has(me)) throw new Error("You can rate only a place you have requested") + + const content = { type: "housingOpinion", target: rootId, category, createdAt: new Date().toISOString() } + return new Promise((res, rej) => ssbClient.publish(content, (e, m) => e ? rej(e) : res(m))) + }, + + async listHousing(filter = "ALL", viewerId = null, query = {}) { + const ssbClient = await openSsb() + const viewer = viewerId || ssbClient.id + const idx = buildIndex(await readAll(ssbClient), ssbClient) + + const items = [] + for (const [rootId, tipId] of idx.tipByRoot.entries()) { + if (idx.tomb.has(tipId)) continue + const node = idx.nodes.get(tipId) + if (!node) continue + const item = buildObject(node, rootId, idx, viewer) + if (!item) continue + if (item.visibility === "HIDDEN" && item.author !== viewer) continue + items.push(item) + } + + const F = String(filter || "ALL").toUpperCase() + let list = dedupeByPreferring( + items, + (h) => (h.author && h.createdAt) ? [norm(h.author), norm(h.createdAt), norm(h.title)].join("|") : null, + (h) => h.requestCount + ) + + if (F === "MINE") list = list.filter(h => h.author === viewer) + else if (F === "REQUESTED") list = list.filter(h => safeArr(h.requests).includes(viewer)) + else if (HOUSING_TYPES.includes(norm(F))) list = list.filter(h => h.housing_type === norm(F)) + else if (F === "OPEN") list = list.filter(h => h.status === "OPEN") + else if (F === "CLOSED") list = list.filter(h => h.status === "CLOSED") + else if (F === "RECENT") list = list.filter(h => moment(h.createdAt).isAfter(moment().subtract(24, "hours"))) + + const search = String(query.search || query.q || "").trim() + if (search) list = list.filter(h => matchSearch(h, search)) + + const minP = toNum(query.minPrice) + const maxP = toNum(query.maxPrice) + if (Number.isFinite(minP)) list = list.filter(h => toNum(h.price) >= minP) + if (Number.isFinite(maxP)) list = list.filter(h => toNum(h.price) <= maxP) + + const place = String(query.place || "").trim() + if (place) list = list.filter(h => norm(h.place).includes(norm(place))) + + const ratingOf = (h) => safeArr(h.opinions_inhabitants).length + const sort = String(query.sort || "").trim() + if (F === "TOP" || sort === "rating") list.sort((a, b) => ratingOf(b) - ratingOf(a)) + else if (sort === "price") list.sort((a, b) => toNum(a.price) - toNum(b.price)) + else if (sort === "requests") list.sort((a, b) => b.requestCount - a.requestCount) + else list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) + + return list + }, + + async getHousingById(id, viewerId = null) { + const ssbClient = await openSsb() + const viewer = viewerId || ssbClient.id + const idx = buildIndex(await readAll(ssbClient), ssbClient) + const tipId = idx.tipOf(id) + if (idx.tomb.has(tipId)) throw new Error("Housing not found") + const node = idx.nodes.get(tipId) + if (!node) throw new Error("Housing not found") + const rootId = idx.rootOf(tipId) + const item = buildObject(node, rootId, idx, viewer) + if (!item) throw new Error("Housing not found") + if (item.visibility === "HIDDEN" && item.author !== viewer) throw new Error("Housing not found") + return item + } + } +} diff --git a/src/views/fork/hive_nav.js b/src/views/fork/hive_nav.js index bbf3887..870fcc6 100644 --- a/src/views/fork/hive_nav.js +++ b/src/views/fork/hive_nav.js @@ -7,7 +7,7 @@ // cada integracion. Aqui solo depende de i18n, que se inyecta porque main_views // lo muta en cada peticion sin reasignarlo. -const { a, div, img, input, label, nav, span } = require("../../server/node_modules/hyperaxe"); +const { a, button, div, form, h2, h3, img, input, label, nav, p, section, span } = require("../../server/node_modules/hyperaxe"); const { getConfig } = require("../../configs/config-manager.js"); const sharedState = require("../../configs/shared-state"); @@ -180,5 +180,59 @@ module.exports = ({ i18n }) => { return div({ class: 'hive-nav' }, ...radios, grid, ...panels); }; - return { renderMobileTopbar, renderBottomBar, renderHiveNav, PINNABLE_MODULES }; + + // pantalla para personalizar la barra inferior (/settings/bottombar) + const bottomBarPickerView = (template) => { + const cfg = getConfig(); + const modOn = (m) => !m || cfg.modules[m] === 'on' || cfg.modules[m] === undefined; + const MAX = 4; + const pinnedKeys = cfg.bottomBarPins || []; + const pinned = pinnedKeys + .map(key => PINNABLE_MODULES.find(m => m.key === key)) + .filter(Boolean); + const full = pinned.length >= MAX; + const actionForm = (action, key, cls, node) => form({ method: 'POST', action: '/settings/bottombar', class: cls }, + input({ type: 'hidden', name: 'action', value: action }), + input({ type: 'hidden', name: 'key', value: key }), + node + ); + const pinnedRow = (m) => actionForm('remove', m.key, 'bb-manage-form', + button({ type: 'submit', class: 'bb-manage-btn', 'aria-label': i18n.bbRemove || 'Remove' }, + span({ class: 'bb-manage-ico' }, m.glyph), + span({ class: 'bb-manage-lbl' }, i18n[m.labelKey] || m.key), + span({ class: 'bb-manage-x' }, '✕') + ) + ); + const addBtn = (m) => actionForm('add', m.key, 'bb-pick-form', + button({ type: 'submit', class: 'bb-pick-btn' }, + span({ class: 'bb-pick-ico' }, m.glyph), + span({ class: 'bb-pick-lbl' }, i18n[m.labelKey] || m.key) + ) + ); + const addable = PINNABLE_MODULES.filter(m => modOn(m.mod) && !pinnedKeys.includes(m.key)); + return template( + i18n.bbManage || 'Edit bar', + section( + div({ class: 'tags-header' }, + h2(i18n.bbPickTitle || 'Customize the bar'), + p(i18n.bbPickDesc || 'Choose up to 4 shortcuts for the bottom bar.') + ), + div({ class: 'bb-manage' }, + h3(`${i18n.bbYourBar || 'Your bar'} (${pinned.length}/${MAX})`), + pinned.length + ? div({ class: 'bb-manage-list' }, ...pinned.map(pinnedRow)) + : p({ class: 'bb-manage-empty' }, i18n.bbPickNone || 'None') + ), + div({ class: 'bb-manage-add' }, + h3(i18n.bbAdd || 'Add'), + full + ? p({ class: 'bb-manage-full' }, i18n.bbFull || 'Maximum reached') + : div({ class: 'bb-pick-grid' }, ...addable.map(addBtn)) + ), + a({ class: 'filter-btn bb-manage-done', href: '/activity' }, i18n.bbDone || 'Done') + ) + ); + }; + + return { renderMobileTopbar, renderBottomBar, renderHiveNav, PINNABLE_MODULES, bottomBarPickerView }; }; diff --git a/src/views/housing_view.js b/src/views/housing_view.js new file mode 100644 index 0000000..92c5ab3 --- /dev/null +++ b/src/views/housing_view.js @@ -0,0 +1,496 @@ +const { form, button, div, h2, p, section, input, label, textarea, br, a, span, select, option, img, video, table, tr, td } = require("../server/node_modules/hyperaxe") +const { renderCommentsSection: renderSharedCommentsSection } = require("./comments_view"); +const { template, i18n, userLink, renderOpenClosedChip, renderStateChip, renderVisibilityChip, renderLifespanChip, renderEcoTax, renderSpreadButton, renderContentActions, renderOpinionsVoting, renderEngagement, renderSpreadEditWarning } = require("./main_views") +const { blobUrl, blobIdOf, isVideoEntry, imagesOf, renderMediaThumb, renderPhotoGallery, renderGalleryFields } = require("./gallery_view") +const moment = require("../server/node_modules/moment") +const { config } = require("../server/SSB_server.js") +const { renderUrl } = require("../backend/renderUrl") +const opinionCategories = require("../backend/opinion_categories") +const { renderMapEmbed, renderMapLocationVisitLabel } = require("./maps_view") + +const userId = config.keys.id + +const FILTERS = [ + { key: "ALL", i18n: "housingFilterAll" }, + { key: "RECENT", i18n: "housingFilterRecent" }, + { key: "MINE", i18n: "housingFilterMine" }, + { key: "TOP", i18n: "housingFilterTop" }, + { key: "REQUESTED", i18n: "housingFilterRequested" }, + { key: "SALE", i18n: "housingFilterSale" }, + { key: "RENT", i18n: "housingFilterRent" }, + { key: "COUCHSURFING", i18n: "housingFilterCouchsurfing" }, + { key: "OPEN", i18n: "housingFilterOpen" }, + { key: "CLOSED", i18n: "housingFilterClosed" } +] + +const MAX_IMAGES = 8 + +const safeArr = (v) => (Array.isArray(v) ? v : []) +const safeText = (v) => String(v || "").trim() + +const parseNum = (v) => { + const n = parseFloat(String(v ?? "").replace(",", ".")) + return Number.isFinite(n) ? n : NaN +} + +const fmtPrice = (v) => { + const n = parseNum(v) + return Number.isFinite(n) ? n.toFixed(2) : String(v ?? "") +} + + + +const priceLabel = (item) => { + if (item.housing_type === "couchsurfing") return i18n.housingFree || "FREE" + const suffix = item.housing_type === "rent" ? ` ${i18n.housingPerMonth || "/month"}` : "" + return `${fmtPrice(item.price)} ECO${suffix}` +} + +const sumCats = (opinions = {}, cats = []) => (cats || []).reduce((s, c) => s + (Number((opinions || {})[c]) || 0), 0) + +const renderStarRating = (opinions, voterCount) => { + const pos = sumCats(opinions, opinionCategories.positive) + const neg = sumCats(opinions, opinionCategories.constructive) + sumCats(opinions, opinionCategories.moderation) + const total = pos + neg + const full = total > 0 ? Math.round((pos / total) * 5) : 0 + const stars = "★".repeat(full) + "☆".repeat(5 - full) + return span({ class: "housing-stars" }, `${stars} (${voterCount})`) +} + +const buildReturnTo = (filter, params = {}) => { + const parts = [`filter=${encodeURIComponent(safeText(filter || "ALL"))}`] + const q = safeText(params.search || "") + if (q) parts.push(`search=${encodeURIComponent(q)}`) + if (String(params.minPrice ?? "") !== "") parts.push(`minPrice=${encodeURIComponent(String(params.minPrice))}`) + if (String(params.maxPrice ?? "") !== "") parts.push(`maxPrice=${encodeURIComponent(String(params.maxPrice))}`) + if (safeText(params.place)) parts.push(`place=${encodeURIComponent(safeText(params.place))}`) + if (safeText(params.sort)) parts.push(`sort=${encodeURIComponent(safeText(params.sort))}`) + return `/housing?${parts.join("&")}` +} + +const renderTags = (tags = []) => { + const arr = safeArr(tags).map(t => String(t || "").trim()).filter(Boolean) + return arr.length + ? div({ class: "card-tags" }, arr.map(tag => a({ class: "tag-link", href: `/search?query=%23${encodeURIComponent(tag)}` }, `#${tag}`))) + : null +} + +const renderTypeChip = (item) => { + const t = String(item.housing_type || "").toUpperCase() + const emoji = item.housing_type === "sale" ? "🏷" : item.housing_type === "rent" ? "🔑" : "🛋" + return renderStateChip("whole", emoji, i18n["housingType" + t] || t) +} + +const renderStatusChip = (status) => { + const s = String(status || "").toUpperCase() + return renderOpenClosedChip(s, { + statusChipOPEN: i18n.housingStatusOPEN || "OPEN", + statusChipCLOSED: i18n.housingStatusCLOSED || "CLOSED" + }) +} + +const renderRequestedChip = () => renderStateChip("whole", "★", i18n.housingRequestedBadge || "REQUESTED") + +const renderInfoTable = (item) => { + const rows = [] + const pushRow = (labelText, valueNode, valueClass) => + rows.push(tr( + td({ class: "tribe-info-label" }, labelText), + td({ class: `tribe-info-value${valueClass ? " " + valueClass : ""}` }, valueNode) + )) + pushRow(i18n.housingProperty, i18n["housingProperty" + String(item.property_type || "").toUpperCase()] || "—") + pushRow(i18n.housingPrice, priceLabel(item), "card-salary") + if (item.rooms > 0) pushRow(i18n.housingRooms, String(item.rooms)) + if (item.size > 0) pushRow(i18n.housingSize, `${item.size} m²`) + if (item.capacity > 0) pushRow(i18n.housingCapacity, String(item.capacity)) + if (item.availableFrom) pushRow(i18n.housingAvailableFrom, moment(item.availableFrom).format("YYYY/MM/DD")) + if (item.availableTo) pushRow(i18n.housingAvailableTo, moment(item.availableTo).format("YYYY/MM/DD")) + if (safeText(item.place)) pushRow(i18n.housingPlace, safeText(item.place)) + if (item.mapUrl) { + const mapNode = renderMapLocationVisitLabel(item.mapUrl) + if (mapNode) pushRow(i18n.mapLocationTitle || "Map", mapNode) + } + return table({ class: "tribe-info-table housing-info-table" }, ...rows) +} + +const today = () => moment().format("YYYY-MM-DD") + +const renderStatusRow = (item, returnTo) => { + if (String(item.author) !== String(userId)) return null + const isOpen = item.status === "OPEN" + return div({ class: "tribe-side-actions housing-status-row" }, + span({ class: "card-label" }, `${i18n.housingStatus}: `), + renderStatusChip(item.status), + form({ method: "POST", action: `/housing/status/${encodeURIComponent(item.id)}`, class: "inline-form" }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ class: "tribe-action-btn", type: "submit", name: "status", value: isOpen ? "CLOSED" : "OPEN" }, + isOpen ? (i18n.housingSetClosed || "Close") : (i18n.housingSetOpen || "Reopen")) + ) + ) +} + +const renderOwnerActions = (item, returnTo) => { + if (String(item.author) !== String(userId)) return [] + return [ + form({ method: "GET", action: `/housing/edit/${encodeURIComponent(item.id)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ class: "update-btn", type: "submit" }, i18n.housingUpdateButton) + ), + form({ method: "POST", action: `/housing/delete/${encodeURIComponent(item.id)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ class: "delete-btn", type: "submit" }, i18n.housingDeleteButton) + ) + ] +} + +const renderRequestToggle = (item, returnTo) => { + if (String(item.author) === String(userId)) return null + if (item.status !== "OPEN") return null + const requested = safeArr(item.requests).includes(userId) + return requested + ? form({ method: "POST", action: `/housing/cancel/${encodeURIComponent(item.id)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ type: "submit", class: "filter-btn" }, i18n.housingCancelButton) + ) + : form({ method: "POST", action: `/housing/request/${encodeURIComponent(item.id)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ type: "submit", class: "filter-btn" }, i18n.housingRequestButton) + ) +} + +const renderHousingList = (items, filter, params = {}) => { + const list = safeArr(items) + if (!list.length) return p(i18n.housingNoItems) + + return div({ class: "housing-grid" }, + list.map((item) => { + const clip = safeText(item.video) + const cover = imagesOf(item)[0] + const isOwn = item.author && String(item.author) === String(userId) + const requested = safeArr(item.requests).includes(userId) + const chips = [ + renderTypeChip(item), + renderStatusChip(item.status), + item.visibility === "HIDDEN" ? renderVisibilityChip("HIDDEN", i18n) : null, + requested ? renderRequestedChip() : null, + renderLifespanChip(item.lifetime, i18n) + ].filter(Boolean) + + return div({ class: "trending-card housing-card" + (isOwn ? " own-content" : "") }, + div({ class: "card-header activity-card-header" }, + span(), + renderContentActions(item.id, `/housing/${encodeURIComponent(item.id)}`, { spread: params.spreadMap && params.spreadMap.get(item.id) || null, author: item.author, favKind: 'housing', isFavorite: item.isFavorite, reportTitle: item.title }) + ), + div({ class: "card-section housing-card-body" }, + clip || (cover && isVideoEntry(cover)) + ? div({ class: "tribe-card-image-wrapper housing-card-video" }, + video({ controls: true, class: "housing-card-hero-video", src: blobUrl(blobIdOf(clip || cover)) }) + ) + : cover + ? div({ class: "tribe-card-image-wrapper" }, + a({ href: `/housing/${encodeURIComponent(item.id)}` }, + img({ src: blobUrl(blobIdOf(cover), 256), class: "tribe-card-hero-image", alt: "" }) + ) + ) + : null, + div({ class: "tribe-card-body" }, + div({ class: "shop-title-row" }, + h2({ class: "tribe-card-title" }, + a({ href: `/housing/${encodeURIComponent(item.id)}` }, safeText(item.title) || i18n.housingTitle) + ) + ), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, + renderStarRating(item.opinions, safeArr(item.opinions_inhabitants).length), + div({ class: "card-date-highlight" }, priceLabel(item)), + safeText(item.place) + ? div({ class: "card-field" }, + span({ class: "card-label" }, `${i18n.housingPlace}: `), + span({ class: "card-value" }, safeText(item.place)) + ) + : null, + div({ class: "tribe-card-members" }, + span({ class: "tribe-members-count" }, `${i18n.housingRequests}: ${item.requestCount || 0}`) + ), + imagesOf(item).length > 1 + ? div({ class: "card-field" }, + span({ class: "card-label" }, `${i18n.galleryPhotos}: `), + span({ class: "card-value" }, String(imagesOf(item).length)) + ) + : null + ) + ) + ) + }) + ) +} + +const renderHousingForm = (item = {}, mode = "create", maxImages = MAX_IMAGES, spreadWarning = null) => { + const isEdit = mode === "edit" + const type = String(item.housing_type || "").toLowerCase() + return div({ class: "div-center housing-form" }, + form({ + action: isEdit ? `/housing/update/${encodeURIComponent(item.id)}` : "/housing/create", + method: "POST", + enctype: "multipart/form-data" + }, + input({ type: "hidden", name: "returnTo", value: "/housing?filter=MINE" }), + isEdit ? spreadWarning : null, + label(i18n.housingType), + br(), + select({ name: "housing_type", required: true }, + option({ value: "sale", selected: type === "sale" ? "selected" : undefined }, i18n.housingTypeSALE), + option({ value: "rent", selected: type === "rent" ? "selected" : undefined }, i18n.housingTypeRENT), + option({ value: "couchsurfing", selected: type === "couchsurfing" ? "selected" : undefined }, i18n.housingTypeCOUCHSURFING) + ), + br(), + br(), + label(i18n.housingProperty), + br(), + select({ name: "property_type" }, + option({ value: "apartment", ...(item.property_type === "apartment" ? { selected: true } : {})}, i18n.housingPropertyAPARTMENT), + option({ value: "house", ...(item.property_type === "house" ? { selected: true } : {})}, i18n.housingPropertyHOUSE), + option({ value: "room", ...(item.property_type === "room" ? { selected: true } : {})}, i18n.housingPropertyROOM), + option({ value: "land", ...(item.property_type === "land" ? { selected: true } : {})}, i18n.housingPropertyLAND), + option({ value: "other", ...(item.property_type === "other" ? { selected: true } : {})}, i18n.housingPropertyOTHER) + ), + br(), + br(), + label(i18n.housingTitleLabel), + br(), + input({ type: "text", name: "title", maxlength: "100", required: true, placeholder: i18n.housingTitlePlaceholder, value: item.title || "" }), + br(), + br(), + ...renderGalleryFields(item, isEdit, maxImages), + label(i18n.housingDescription), + br(), + textarea({ name: "description", rows: "6", required: true, placeholder: i18n.housingDescriptionPlaceholder }, item.description || ""), + br(), + br(), + label(i18n.housingRules), + br(), + textarea({ name: "rules", rows: "4", placeholder: i18n.housingRulesPlaceholder }, item.rules || ""), + br(), + br(), + label(i18n.housingPlace), + br(), + input({ type: "text", name: "place", placeholder: i18n.housingPlacePlaceholder, value: item.place || "" }), + br(), + br(), + label(i18n.mapLocationTitle || "Map Location"), + br(), + input({ type: "text", name: "mapUrl", placeholder: i18n.mapUrlPlaceholder || "/maps/MAP_ID", value: item.mapUrl || "" }), + br(), + br(), + label(i18n.housingPriceLabel), + br(), + input({ type: "number", name: "price", step: "0.01", min: "0", value: item.price || "" }), + br(), + br(), + label(i18n.housingRooms), + br(), + input({ type: "number", name: "rooms", min: "0", value: item.rooms || "" }), + br(), + br(), + label(i18n.housingSizeLabel), + br(), + input({ type: "number", name: "size", step: "0.01", min: "0", value: item.size || "" }), + br(), + br(), + label(i18n.housingCapacity), + br(), + input({ type: "number", name: "capacity", min: "0", value: item.capacity || "" }), + br(), + br(), + label(i18n.housingAvailableFrom), + br(), + input({ type: "date", name: "availableFrom", required: true, min: isEdit ? undefined : today(), value: item.availableFrom ? String(item.availableFrom).slice(0, 10) : "" }), + br(), + br(), + label(i18n.housingAvailableTo), + br(), + input({ type: "date", name: "availableTo", min: item.availableFrom ? String(item.availableFrom).slice(0, 10) : today(), value: item.availableTo ? String(item.availableTo).slice(0, 10) : "" }), + br(), + br(), + label(i18n.housingTags), + br(), + input({ type: "text", name: "tags", value: Array.isArray(item.tags) ? item.tags.join(", ") : (item.tags || "") }), + br(), + br(), + label(i18n.visibilityLabel || "Visibility"), + br(), + select({ name: "visibility" }, + option({ value: "PUBLIC", ...((item.visibility || "PUBLIC") === "PUBLIC" ? { selected: true } : {})}, i18n.visibilityPublic || "Public"), + option({ value: "HIDDEN", ...(item.visibility === "HIDDEN" ? { selected: true } : {})}, i18n.visibilityHidden || "Hidden") + ), + br(), + br(), + button({ type: "submit" }, isEdit ? i18n.housingUpdateButton : i18n.housingCreateButton) + ) + ) +} + +const renderFiltersBar = (filter, params = {}) => + div({ class: "filters" }, + form({ method: "GET", action: "/housing", class: "ui-toolbar ui-toolbar--filters" }, + input({ type: "hidden", name: "search", value: safeText(params.search || "") }), + input({ type: "hidden", name: "minPrice", value: String(params.minPrice ?? "") }), + input({ type: "hidden", name: "maxPrice", value: String(params.maxPrice ?? "") }), + input({ type: "hidden", name: "place", value: safeText(params.place || "") }), + input({ type: "hidden", name: "sort", value: safeText(params.sort || "") }), + ...FILTERS.map(f => + button({ type: "submit", name: "filter", value: f.key, class: filter === f.key ? "filter-btn active" : "filter-btn" }, String(i18n[f.i18n]).toUpperCase()) + ), + button({ type: "submit", name: "filter", value: "CREATE", class: "create-button" }, i18n.housingCreateButton) + ) + ) + +exports.housingView = async (items, filter = "ALL", params = {}) => { + const search = safeText(params.search || "") + const minPrice = params.minPrice ?? "" + const maxPrice = params.maxPrice ?? "" + const place = safeText(params.place || "") + const sort = safeText(params.sort || "recent") + + const isForm = filter === "CREATE" || filter === "EDIT" + + return template( + i18n.housingTitle, + section( + div({ class: "tags-header" }, + h2(i18n.housingTitle), + p(i18n.housingDescriptionText) + ), + renderFiltersBar(filter, { search, minPrice, maxPrice, place, sort }) + ), + section( + isForm + ? renderHousingForm(filter === "EDIT" ? (Array.isArray(items) ? items[0] : items) || {} : (params.draft || {}), filter === "EDIT" ? "edit" : "create", Number(params.maxImages) > 0 ? Number(params.maxImages) : MAX_IMAGES, await renderSpreadEditWarning(filter === "EDIT" ? ((Array.isArray(items) ? items[0] : items) || {}).id : null)) + : section( + div({ class: "housing-search" }, + form({ method: "GET", action: "/housing", class: "filter-box" }, + input({ type: "hidden", name: "filter", value: filter || "ALL" }), + input({ type: "text", name: "search", value: search, placeholder: i18n.housingSearchPlaceholder, class: "filter-box__input" }), + div({ class: "filter-box__controls" }, + input({ type: "text", name: "place", value: place, placeholder: i18n.housingPlacePlaceholder, class: "filter-box__input housing-place-input" }), + div({ class: "transfer-range" }, + input({ type: "number", name: "minPrice", step: "0.01", min: "0", value: String(minPrice ?? ""), placeholder: i18n.housingMinPrice, class: "filter-box__number transfer-amount-input" }), + input({ type: "number", name: "maxPrice", step: "0.01", min: "0", value: String(maxPrice ?? ""), placeholder: i18n.housingMaxPrice, class: "filter-box__number transfer-amount-input" }) + ), + select({ name: "sort", class: "filter-box__select" }, + option({ value: "recent", ...(sort === "recent" ? { selected: true } : {})}, i18n.housingSortRecent), + option({ value: "price", ...(sort === "price" ? { selected: true } : {})}, i18n.housingSortPrice), + option({ value: "requests", ...(sort === "requests" ? { selected: true } : {})}, i18n.housingSortRequests), + option({ value: "rating", ...(sort === "rating" ? { selected: true } : {})}, i18n.housingSortRating) + ), + button({ type: "submit", class: "filter-box__button" }, i18n.housingSearchButton) + ) + ) + ), + br(), + div({ class: "housing-list" }, renderHousingList(items, filter, params)) + ) + ) + ) +} + +const renderCommentsSection = (itemId, returnTo, comments = []) => { + return renderSharedCommentsSection({ + action: `/housing/${encodeURIComponent(itemId)}/comments`, + comments: comments, + returnTo: returnTo + }); +}; + +exports.singleHousingView = async (item, filter = "ALL", comments = [], params = {}) => { + const returnTo = safeText(params.returnTo) || buildReturnTo(filter, params) + const isAuthor = String(item.author) === String(userId) + const requested = safeArr(item.requests).includes(userId) + const voters = safeArr(item.opinions_inhabitants) + + const chips = [ + renderTypeChip(item), + renderStatusChip(item.status), + item.visibility === "HIDDEN" ? renderVisibilityChip("HIDDEN", i18n) : null, + requested ? renderRequestedChip() : null, + renderLifespanChip(item.lifetime, i18n), + renderEcoTax(item.msgSize, item.id) + ].filter(Boolean) + + const sideActions = [] + const requestToggle = renderRequestToggle(item, returnTo) + if (requestToggle) sideActions.push(requestToggle) + const ownerActions = renderOwnerActions(item, returnTo) + + const nextVisibility = item.visibility === "PUBLIC" ? "HIDDEN" : "PUBLIC" + const isHidden = item.visibility === "HIDDEN" + const visibilityRow = isAuthor + ? div({ class: "tribe-side-actions shop-visibility-row housing-visibility-row" }, + span({ class: "card-label" }, `${i18n.visibilityLabel || "Visibility"}: `), + isHidden + ? renderStateChip("encrypted", "🔒", i18n.encryptedChipLabel || "E2E") + : renderStateChip("mutuals", "👁", i18n.visibilityPublic || "PUBLIC"), + form({ method: "POST", action: `/housing/visibility/${encodeURIComponent(item.id)}`, class: "inline-form" }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + input({ type: "hidden", name: "visibility", value: nextVisibility }), + button({ type: "submit", class: "tribe-action-btn" }, + nextVisibility === "PUBLIC" ? (i18n.visibilityMakePublic || "Make public") : (i18n.visibilityMakeHidden || "Make hidden")) + ) + ) + : null + + const cover = imagesOf(item)[0] + const housingSide = div({ class: "tribe-side" }, + div({ class: "card-header activity-card-header" }, + renderContentActions(item.id, null, { spread: params.spreads || null, author: item.author, favKind: 'housing', isFavorite: item.isFavorite, reportTitle: item.title }) + ), + div({ class: "shop-title-row" }, + h2({ class: "tribe-card-title" }, safeText(item.title) || i18n.housingTitle) + ), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, + renderStarRating(item.opinions, voters.length), + cover && !isVideoEntry(cover) ? img({ src: blobUrl(blobIdOf(cover), 256), class: "tribe-detail-image", alt: "" }) : null, + renderInfoTable(item), + div({ class: "tribe-card-members" }, + span({ class: "tribe-members-count" }, `${i18n.housingRequests}: ${item.requestCount || 0}`) + ), + sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null, + renderStatusRow(item, returnTo), + ownerActions.length ? div({ class: "tribe-side-actions owner-actions" }, ...ownerActions) : null, + visibilityRow, + renderTags(item.tags) + ) + + const renderSection = (titleText, bodyText) => + safeText(bodyText) + ? div({ class: "job-section" }, + h2({ class: "job-section-title" }, titleText), + p({ class: "tribe-side-description" }, ...renderUrl(bodyText)) + ) + : null + + const housingMain = div({ class: "tribe-main" }, + renderPhotoGallery(item), + renderSection(i18n.housingDescription, item.description), + renderSection(i18n.housingRules, item.rules), + item.mapUrl ? div({ class: "job-section" }, renderMapEmbed(params.mapData, item.mapUrl)) : null, + p({ class: "card-footer" }, + span({ class: "date-link" }, `${moment(item.createdAt).format("YYYY/MM/DD HH:mm")} ${i18n.performed} `), + userLink(item.author) + ), + renderEngagement(item.id, + !isAuthor && item.everRequestedByViewer + ? renderOpinionsVoting("/housing/opinions", item.id, item.opinions, returnTo, voters) + : null, + renderCommentsSection(item.id, returnTo, comments) + ) + ) + + return template( + i18n.housingTitle, + section( + div({ class: "tags-header" }, h2(i18n.housingTitle), p(i18n.housingDescriptionText)), + renderFiltersBar(filter, params), + div({ class: "tribe-details" }, housingSide, housingMain) + ) + ) +} diff --git a/src/views/main_views.js b/src/views/main_views.js index 1c6e01f..f9058be 100644 --- a/src/views/main_views.js +++ b/src/views/main_views.js @@ -1186,6 +1186,8 @@ const renderBottomBar = forkNav.renderBottomBar; const renderHiveNav = forkNav.renderHiveNav; exports.PINNABLE_MODULES = forkNav.PINNABLE_MODULES; exports.renderHiveNav = renderHiveNav; +// se resuelve al llamarse, cuando template ya esta definido mas abajo +exports.bottomBarPickerView = () => forkNav.bottomBarPickerView(template); const template = (titlePrefix, ...elements) => { const currentConfig = getConfig();