"use strict"; const path = require("path"); const fs = require("fs"); const envPaths = require("../server/node_modules/env-paths"); const debug = require("../server/node_modules/debug")("oasis"); const highlightJs = require("../server/node_modules/highlight.js"); const prettyMs = require("../server/node_modules/pretty-ms"); const moment = require('../server/node_modules/moment'); const QRCode = require('../server/node_modules/qrcode'); const { renderUrl } = require('../backend/renderUrl'); const ssbClientGUI = require("../client/gui"); const config = require("../server/ssb_config"); const cooler = ssbClientGUI({ offline: config.offline }); const sharedState = require('../configs/shared-state'); let ssb, userId; const getUserId = async () => { if (!ssb) ssb = await cooler.open(); if (!userId) userId = ssb.id; return userId; }; const { a, article, br, body, button, details, div, em, footer, form, h1, h2, h3, head, header, hr, html, img, input, label, li, link, main, meta, nav, option, p, pre, section, select, span, summary, table, td, textarea, title, tr, ul, strong, video: videoHyperaxe, audio: audioHyperaxe } = require("../server/node_modules/hyperaxe"); const lodash = require("../server/node_modules/lodash"); const markdown = require("./markdown"); const { sanitizeHtml } = require('../backend/sanitizeHtml'); const nameCache = require('../backend/nameCache'); const userLinkLabel = (feedId, knownName) => { const id = String(feedId || ''); if (!id) return ''; let kn = (knownName && String(knownName).trim()) || ''; if (kn === id || /^@[A-Za-z0-9+/_-]{43}=\.ed25519$/.test(kn)) kn = ''; const name = kn || nameCache.get(id); if (name && name.length) return '@' + name; return id; }; const userLink = (feedId, knownName) => { if (!feedId) return null; return a({ class: 'user-link', href: `/author/${encodeURIComponent(feedId)}` }, userLinkLabel(feedId, knownName)); }; exports.userLink = userLink; exports.userLinkLabel = userLinkLabel; const renderInviteQrCard = ({ qrDataUrl }) => qrDataUrl ? div({ class: 'invite-qr-card' }, img({ src: qrDataUrl, alt: 'QR', class: 'invite-qr-img' })) : null; exports.renderInviteQrCard = renderInviteQrCard; const renderStateChip = (variant, icon, text) => span({ class: `pm-exposition-chip pm-exposition-${variant}` }, icon ? span({ class: "pm-exposition-icon" }, icon) : null, span({ class: "pm-exposition-text" }, String(text || "")) ); const renderOpenClosedChip = (status, i18nObj) => { const s = String(status || "").toUpperCase(); const isOpen = s === "OPEN"; const label = (i18nObj && i18nObj["statusChip" + s]) || s; return renderStateChip(isOpen ? "mutuals" : "closed", isOpen ? "โœ“" : "โœ—", label); }; const renderVisibilityChip = (visibility, i18nObj) => { const v = String(visibility || "").toUpperCase(); if (v === "HIDDEN") { return renderStateChip("hidden", "๐Ÿ™ˆ", (i18nObj && i18nObj.visibilityHidden) || "HIDDEN"); } return renderStateChip("mutuals", "๐Ÿ‘", (i18nObj && i18nObj.visibilityPublic) || "PUBLIC"); }; const renderPrivacyChip = (isPrivate, i18nObj) => isPrivate ? renderStateChip("closed", "๐Ÿ”’", (i18nObj && i18nObj.privacyPrivate) || "PRIVATE") : renderStateChip("mutuals", "๐ŸŒ", (i18nObj && i18nObj.privacyPublic) || "PUBLIC"); const renderModeChip = (mode, i18nObj) => { const m = String(mode || "").toLowerCase(); if (m === "strict") return renderStateChip("closed", null, (i18nObj && i18nObj.tribeStrict) || "STRICT"); return renderStateChip("mutuals", null, (i18nObj && i18nObj.tribeOpen) || "OPEN"); }; const renderLifespanChip = (lifetime, i18nObj) => { const lt = lifetime || null; if (!lt || !lt.bucket) return null; const range = lt.range || ""; if (range) { return span({ class: `pm-exposition-chip pm-exposition-lifespan-${lt.bucket}` }, span({ class: "pm-exposition-text" }, range) ); } return span({ class: `pm-exposition-chip pm-exposition-lifespan-${lt.bucket}` }, span({ class: `activity-dot ${lt.bucket}` }, "โ—"), span({ class: "pm-exposition-text" }, (i18nObj && i18nObj.lifespanLabel) || "Lifespan") ); }; const renderContentActions = (msgId, viewHref) => { const blockId = (typeof msgId === 'string' && msgId.startsWith('%')) ? msgId : null; const chainBtn = blockId ? a({ href: `/blockexplorer/block/${encodeURIComponent(blockId)}`, class: 'btn-singleview', title: i18n.blockchainViewBlockexplorer || 'View blockexplorer' }, 'โฆฟ') : null; const contentBtn = viewHref ? a({ href: viewHref, class: 'btn-singleview btn-content', title: i18n.visitContent || 'Visit content' }, 'โ†—') : null; if (!chainBtn && !contentBtn) return null; return div({ class: 'content-actions' }, chainBtn, contentBtn); }; exports.renderContentActions = renderContentActions; exports.renderStateChip = renderStateChip; exports.renderOpenClosedChip = renderOpenClosedChip; exports.renderVisibilityChip = renderVisibilityChip; exports.renderPrivacyChip = renderPrivacyChip; exports.renderModeChip = renderModeChip; exports.renderLifespanChip = renderLifespanChip; const formatCarbon = (bytes) => { const n = Number(bytes) || 0; if (!n) return '0 ยตg COโ‚‚'; const grams = (n / (1024 * 1024)) * 0.095; if (grams >= 1) return `${grams.toFixed(2)} g COโ‚‚`; const mg = grams * 1000; if (mg >= 1) return `${mg.toFixed(2)} mg COโ‚‚`; const ug = mg * 1000; return `${ug.toFixed(2)} ยตg COโ‚‚`; }; const renderEcoTax = (sizeBytes, blockId) => { const n = Number(sizeBytes); if (!Number.isFinite(n) || n <= 0) return null; const label = (i18n && i18n.ecoTaxLabel) ? i18n.ecoTaxLabel : 'ECO Tax'; const href = blockId ? `/blockexplorer?inspect=${encodeURIComponent(blockId)}#inspect` : '/blockexplorer#inspect'; let band = 'low'; let reducer = 1; let rawRatio = 0; let inhabitants = 1; try { const maxObserved = sharedState.getMaxBlockBytes ? Number(sharedState.getMaxBlockBytes()) : 0; const max = maxObserved > 0 ? maxObserved : n; rawRatio = max > 0 ? Math.min(1, n / max) : 0; inhabitants = Math.max(1, (sharedState.getInhabitantCount && sharedState.getInhabitantCount()) || 1); reducer = 1 + Math.log10(inhabitants); const adjusted = rawRatio / reducer; if (adjusted >= 0.66) band = 'high'; else if (adjusted >= 0.33) band = 'mid'; else band = 'low'; } catch (_) {} const reducerNote = inhabitants > 1 ? ` ยท reducer ร—${reducer.toFixed(2)} (${inhabitants} inhabitants)` : ''; const title = `${label} ยท ${formatCarbon(sizeBytes)}${reducerNote}`; return a({ href, class: `eco-tax-chip eco-tax-chip-${band}`, title }, span({ class: 'eco-tax-chip-label' }, label + ': '), span({ class: 'eco-tax-chip-value' }, formatCarbon(sizeBytes)) ); }; exports.formatCarbon = formatCarbon; exports.renderEcoTax = renderEcoTax; const errorView = ({ title, message, backHref }) => { const heading = title || i18n.errorPageTitle || 'Error'; return exports.template( heading, section( div({ class: 'tags-header' }, message ? p({ class: 'error-page-message' }, String(message)) : null, div({ class: 'error-page-actions' }, a({ href: backHref || '/', class: 'filter-btn' }, i18n.goBack || 'Go back') ) ) ) ); }; exports.errorView = errorView; const renderSpreadButton = (msgKey, opts) => { if (!msgKey || typeof msgKey !== 'string' || !msgKey.startsWith('%') || !/\.sha256$/.test(msgKey)) return null; const o = (opts && typeof opts === 'object') ? opts : {}; const voters = Array.isArray(o.voters) ? o.voters : []; const count = typeof o.count === 'number' ? o.count : voters.length; const alreadySpread = o.alreadySpread === true; const maxNames = 5; const maxLen = 16; const lastVoters = voters.slice(-maxNames); const tooltipNames = lastVoters .map(v => (v && typeof v === 'object' ? (v.name || v.key || '') : String(v || ''))) .filter(Boolean) .map(n => n.slice(0, maxLen)) .join(', '); const extra = count > maxNames ? ` +${count - maxNames} ${i18n.spreadMore || 'more'}` : ''; const tooltip = count > 0 ? `${tooltipNames}${extra}` : (i18n.spreadHint || 'Spread this to your supporters (replicates via your feed).'); return form( { method: 'POST', action: `/spread/${encodeURIComponent(msgKey)}`, class: 'spread-form' }, button( { type: 'submit', class: alreadySpread ? 'spread-btn spread-btn-on' : 'spread-btn', title: tooltip }, `๐Ÿ” ${count}` ) ); }; exports.renderSpreadButton = renderSpreadButton; const aiNavResultsView = ({ query, results }) => { const title = i18n.aiNavResultsTitle || 'AI navigation results'; const safeQuery = String(query || '').trim(); const safeResults = Array.isArray(results) ? results : []; const fmtScore = (s) => { const n = Number(s); return Number.isFinite(n) ? n.toFixed(2) : 'โ€”'; }; const splitTerms = (desc) => String(desc || '') .split(/[,;]/) .map(s => s.trim()) .filter(Boolean); return exports.template( title, section( div({ class: 'tags-header' }, h2(title), safeQuery ? p({ class: 'ai-nav-query' }, `${i18n.aiNavQueryLabel || 'Query'}: "${safeQuery}"`) : null ), safeResults.length ? div({ class: 'ai-nav-results' }, safeResults.map(r => div({ class: 'ai-nav-result-card card-section' }, div({ class: 'card-field' }, span({ class: 'card-label' }, `${(i18n.aiNavResultMatch || 'Match').toUpperCase()}: ${fmtScore(r.score)}`), span({ class: 'card-value' }, a({ href: r.path, class: 'filter-btn' }, r.path)) ) )) ) : p(i18n.aiNavResultsEmpty || 'No matching routes. Try /search instead.') ) ); }; exports.aiNavResultsView = aiNavResultsView; const i18nBase = require("../client/assets/translations/i18n"); let selectedLanguage = "en"; let i18n = {}; Object.assign(i18n, i18nBase[selectedLanguage]); exports.setLanguage = (language) => { selectedLanguage = language; const newLang = i18nBase[selectedLanguage] || i18nBase['en']; Object.keys(i18n).forEach(k => delete i18n[k]); Object.assign(i18n, newLang); }; exports.i18n = i18n; Object.defineProperty(exports, 'selectedLanguage', { get: () => selectedLanguage }); const opinionCategoriesList = require('../backend/opinion_categories'); const renderOpinionsVoting = (basePath, id, opinions, returnTo, voters) => { const ops = opinions || {}; const total = Object.values(ops).reduce((s, n) => s + (Number(n) || 0), 0); const myId = (config.keys && config.keys.id) ? config.keys.id : ''; const alreadyVoted = Array.isArray(voters) && myId ? voters.includes(myId) : false; return details({ class: 'opinions-voting-collapse' }, summary({ class: 'opinions-summary' }, `${i18n.opinionsTitle || 'Opinions'} (${total})`), div({ class: 'voting-buttons' }, opinionCategoriesList.map((category) => form({ method: 'POST', action: `${basePath}/${encodeURIComponent(id)}/${category}` }, returnTo ? input({ type: 'hidden', name: 'returnTo', value: returnTo }) : null, button({ class: alreadyVoted ? 'vote-btn disabled' : 'vote-btn', type: 'submit', ...(alreadyVoted ? { disabled: true } : {}) }, `${i18n['vote' + category.charAt(0).toUpperCase() + category.slice(1)] || category} [${ops[category] || 0}]`) ) ) ), alreadyVoted ? p({ class: 'muted' }, i18n.alreadyVoted) : null ); }; exports.renderOpinionsVoting = renderOpinionsVoting; // markdown const markdownUrl = "https://commonmark.org/help/"; const doctypeString = ""; const THREAD_PREVIEW_LENGTH = 3; const toAttributes = (obj) => Object.entries(obj) .map(([key, val]) => `${key}=${val}`) .join(", "); const nbsp = "\xa0"; const { getConfig } = require('../configs/config-manager.js'); // menu INIT const readPkg = () => { const file = path.resolve(__dirname, "..", "server", "package.json"); try { const txt = fs.readFileSync(file, "utf8"); const parsed = JSON.parse(txt || "{}"); return parsed && typeof parsed === "object" ? parsed : {}; } catch (_) { return {}; } }; const renderFooter = () => { const pkg = readPkg(); const year = moment().format("YYYY"); const pkgName = pkg?.name || "@krakenslab/oasis"; const pkgVersion = pkg?.version || "?"; const hcT = sharedState.getCarbonHcT(); const hcH = sharedState.getCarbonHcH(); const peersOnline = sharedState.getOnlinePeerCount ? sharedState.getOnlinePeerCount() : null; const inboxUnread = sharedState.getInboxUnreadCount ? sharedState.getInboxUnreadCount() : null; const lastSyncTs = sharedState.getLastSyncTs ? sharedState.getLastSyncTs() : null; const lastSyncLabel = lastSyncTs ? moment(lastSyncTs).fromNow() : 'โ€“'; const lastActivity = sharedState.getLastActivity ? sharedState.getLastActivity() : null; return div( { class: "oasis-footer" }, div( { class: "oasis-footer-center" }, a( { href: "/", class: "oasis-footer-logo-link" }, img({ class: "oasis-footer-logo", src: "/assets/images/snh-oasis.jpg", alt: "Oasis" }) ), (() => { const myId = (config.keys && config.keys.id) ? config.keys.id : ''; if (!myId) return null; if (process.env.OASIS_MOBILE === '1') { return [ br(), input({ class: "oasis-id-copy", type: "text", readonly: true, value: myId, "aria-label": "Oasis ID" }) ]; } return [ br(), a({ href: "/profile" }, span(myId)) ]; })(), br(), span({ class: "oasis-footer-carbon" }, span("HcT: "), a({ href: "/stats?filter=ALL" }, hcT != null ? String(hcT) : 'โ€“'), span(" | HcH: "), a({ href: "/stats?filter=MINE" }, hcH != null ? String(hcH) : 'โ€“') ), br(), a( { href: "https://code.03c8.net/krakenslab/oasis", target: "_blank", rel: "noreferrer noopener" }, span(pkgName), ), span("["), span({ class: "oasis-footer-version" }, pkgVersion), span("]"), br(), span(`${i18n.footerLicenseLabel || 'License'}: `), a( { href: "https://www.gnu.org/licenses/gpl-3.0.html", target: "_blank", rel: "noreferrer noopener" }, i18n.footerLicense ), span({ class: "oasis-footer-sep" }, " - "), span({ class: "oasis-footer-year" }, year) ) ); }; const navLink = ({ href, emoji, text, current, class: extraClass }) => li( a( { href, class: [current ? "current" : "", extraClass] .filter(Boolean) .join(" ") }, span({ class: "emoji" }, emoji), span({ class: "nav-text" }, text) ) ); const customCSS = (filename) => { const customStyleFile = path.join( envPaths("oasis", { suffix: "" }).config, filename ); try { if (fs.existsSync(customStyleFile)) { return link({ rel: "stylesheet", href: filename }); } } catch (error) { return ""; } }; const navGroup = ({ id, emoji, title, defaultOpen = false }, ...items) => { const active = items.filter(Boolean); if (!active.length) return null; return li( { class: "oasis-nav-group" }, input({ type: "checkbox", id: `oasis-nav-group-${id}`, class: "oasis-nav-toggle", ...(defaultOpen ? { checked: true } : {}) }), label( { for: `oasis-nav-group-${id}`, class: "oasis-nav-header" }, span({ class: "emoji" }, emoji), span({ class: "nav-text" }, title), span({ class: "oasis-nav-arrow" }, "โ–พ") ), ul({ class: "oasis-nav-list" }, ...active) ); }; const renderPopularLink = () => { const popularMod = getConfig().modules.popularMod === "on"; return popularMod ? navLink({ href: "/public/popular/day", emoji: "โŒ˜", text: i18n.popular, class: "popular-link enabled" }) : ""; }; const renderTopicsLink = () => { const topicsMod = getConfig().modules.topicsMod === "on"; return topicsMod ? navLink({ href: "/public/latest/topics", emoji: "ฯŸ", text: i18n.topics, class: "topics-link enabled" }) : ""; }; const renderSummariesLink = () => { const summariesMod = getConfig().modules.summariesMod === "on"; if (summariesMod) { return [ navLink({ href: "/public/latest/summaries", emoji: "โ€ป", text: i18n.summaries, class: "summaries-link enabled" }) ]; } return ""; }; const renderLatestLink = () => { const latestMod = getConfig().modules.latestMod === "on"; return latestMod ? navLink({ href: "/public/latest", emoji: "โ˜„", text: i18n.latest, class: "latest-link enabled" }) : ""; }; const renderThreadsLink = () => { const threadsMod = getConfig().modules.threadsMod === "on"; if (threadsMod) { return [ navLink({ href: "/public/latest/threads", emoji: "โ™บ", text: i18n.threads, class: "threads-link enabled" }) ]; } return ""; }; const renderInvitesLink = () => { const invitesMod = getConfig().modules.invitesMod === "on"; return invitesMod ? navLink({ href: "/invites", emoji: "๊”น", text: i18n.invites, class: "invites-link enabled" }) : ""; }; const renderWalletLink = () => { const walletMod = getConfig().modules.walletMod === "on"; if (walletMod) { return [ navLink({ href: "/wallet", emoji: "โ„", text: i18n.wallet, class: "wallet-link enabled" }) ]; } return ""; }; const renderDevLink = () => { const devMod = getConfig().modules.devMod === "on"; if (devMod) { return [ navLink({ href: "/dev", emoji: "โŒ˜", text: i18n.developer, class: "dev-link enabled" }) ]; } return ""; }; const renderLegacyLink = () => { const legacyMod = getConfig().modules.legacyMod === "on"; if (legacyMod) { return [ navLink({ href: "/legacy", emoji: "๊–ค", text: i18n.legacy, class: "legacy-link enabled" }) ]; } return ""; }; const renderCipherLink = () => { const cipherMod = getConfig().modules.cipherMod === "on"; if (cipherMod) { return [ navLink({ href: "/cipher", emoji: "๊—„", text: i18n.cipher, class: "cipher-link enabled" }) ]; } return ""; }; const renderGraphosLink = () => { const graphosMod = getConfig().modules.graphosMod === "on"; if (graphosMod) { return [ navLink({ href: "/graphos", emoji: "๊”ฏ", text: i18n.graphos, class: "graphos-link enabled" }) ]; } return ""; }; const renderBookmarksLink = () => { const bookmarksMod = getConfig().modules.bookmarksMod === "on"; return bookmarksMod ? navLink({ href: "/bookmarks", emoji: "๊”ช", text: i18n.bookmarksLabel, class: "bookmark-link enabled" }) : ""; }; const renderImagesLink = () => { const imagesMod = getConfig().modules.imagesMod === "on"; if (imagesMod) { return [ navLink({ href: "/images", emoji: "๊•ฅ", text: i18n.imagesLabel, class: "images-link enabled" }) ]; } return ""; }; const renderTorrentsLink = () => { const torrentsMod = getConfig().modules.torrentsMod === "on"; if (torrentsMod) { return [ navLink({ href: "/torrents", emoji: "๊–…", text: i18n.torrentsLabel, class: "torrents-link enabled" }) ]; } return ""; }; const renderMapsLink = () => { const mapsMod = getConfig().modules.mapsMod === "on"; if (mapsMod) { return [ navLink({ href: "/maps", emoji: "๊”Œ", text: i18n.mapsLabel, class: "maps-link enabled" }) ]; } return ""; }; const renderChatsLink = () => { const chatsMod = getConfig().modules.chatsMod === "on"; if (chatsMod) { return [ navLink({ href: "/chats", emoji: "๊–’", text: i18n.chatsTitle, class: "chats-link enabled" }) ]; } return ""; }; const renderVideosLink = () => { const videosMod = getConfig().modules.videosMod === "on"; if (videosMod) { return [ navLink({ href: "/videos", emoji: "๊—Ÿ", text: i18n.videosLabel, class: "videos-link enabled" }) ]; } return ""; }; const renderAudiosLink = () => { const audiosMod = getConfig().modules.audiosMod === "on"; if (audiosMod) { return [ navLink({ href: "/audios", emoji: "๊”ฟ", text: i18n.audiosLabel, class: "audios-link enabled" }) ]; } return ""; }; const renderDocsLink = () => { const docsMod = getConfig().modules.docsMod === "on"; if (docsMod) { return [ navLink({ href: "/documents", emoji: "๊•จ", text: i18n.docsLabel, class: "docs-link enabled" }) ]; } return ""; }; const renderTagsLink = () => { const tagsMod = getConfig().modules.tagsMod === "on"; return tagsMod ? [ navLink({ href: "/tags", emoji: "๊–ถ", text: i18n.tagsLabel, class: "tags-link enabled" }) ] : ""; }; const renderMultiverseLink = () => { const multiverseMod = getConfig().modules.multiverseMod === "on"; return multiverseMod ? navLink({ href: "/public/latest/extended", emoji: "โˆž", text: i18n.multiverse, class: "multiverse-link enabled" }) : ""; }; const renderMastodonLink = () => { const fediverseMod = getConfig().modules.fediverseMod === "on"; return fediverseMod ? navLink({ href: "/fediverse", emoji: "๊—ต", text: i18n.fediverseTimeline }) : ""; }; const renderMarketLink = () => { const marketMod = getConfig().modules.marketMod === "on"; return marketMod ? [ navLink({ href: "/market", emoji: "๊•ป", text: i18n.marketTitle }) ] : ""; }; const renderJobsLink = () => { const jobsMod = getConfig().modules.jobsMod === "on"; return jobsMod ? [ navLink({ href: "/jobs", emoji: "๊—’", text: i18n.jobsTitle }) ] : ""; }; const renderShopsLink = () => { const shopsMod = getConfig().modules.shopsMod === "on"; return shopsMod ? [ navLink({ href: "/shops", emoji: "๊”œ", text: i18n.shopsTitle }) ] : ""; }; const renderProjectsLink = () => { const projectsMod = getConfig().modules.projectsMod === "on"; return projectsMod ? [ navLink({ href: "/projects", emoji: "๊•ง", text: i18n.projectsTitle }) ] : ""; }; const renderIndustryLink = () => { const industryMod = getConfig().modules.industryMod === "on"; return industryMod ? [ navLink({ href: "/industry", emoji: "๊–ด", text: i18n.industryTitle }) ] : ""; }; const renderBankingLink = () => { const bankingMod = getConfig().modules.bankingMod === "on"; return bankingMod ? navLink({ href: "/banking", emoji: "๊—ด", text: i18n.bankingTitle }) : ""; }; const renderTribesLink = () => { const tribesMod = getConfig().modules.tribesMod === "on"; return tribesMod ? [ navLink({ href: "/tribes", emoji: "๊–ฅ", text: i18n.tribesTitle, class: "tribes-link enabled" }) ] : ""; }; const renderLarpLink = () => { const larpMod = getConfig().modules.larpMod === "on"; return larpMod ? [ navLink({ href: "/larp", emoji: "โœฆ", text: i18n.larpTitle || "L.A.R.P.", class: "larp-link enabled" }) ] : ""; }; const renderParliamentLink = () => { const parliamentMod = getConfig().modules.parliamentMod === "on"; return parliamentMod ? [ navLink({ href: "/parliament", emoji: "๊—ž", text: i18n.parliamentTitle, class: "parliament-link enabled" }) ] : ""; }; const renderCourtsLink = () => { const courtsMod = getConfig().modules.courtsMod === "on"; return courtsMod ? navLink({ href: "/courts", emoji: "๊–ป", text: i18n.courtsTitle, class: "courts-link enabled" }) : ""; }; const renderVotationsLink = () => { const votesMod = getConfig().modules.votesMod === "on"; return votesMod ? [ navLink({ href: "/votes", emoji: "๊”ฐ", text: i18n.votationsTitle, class: "votations-link enabled" }) ] : ""; }; const renderTrendingLink = () => { const trendingMod = getConfig().modules.trendingMod === "on"; return trendingMod ? [ navLink({ href: "/trending", emoji: "๊—", text: i18n.trendingLabel, class: "trending-link enabled" }) ] : ""; }; const renderReportsLink = () => { const reportsMod = getConfig().modules.reportsMod === "on"; return reportsMod ? [ navLink({ href: "/reports", emoji: "๊•ฅ", text: i18n.reportsTitle, class: "reports-link enabled" }) ] : ""; }; const renderOpinionsLink = () => { const opinionsMod = getConfig().modules.opinionsMod === "on"; return opinionsMod ? [ navLink({ href: "/opinions", emoji: "๊”", text: i18n.opinionsTitle, class: "opinions-link enabled" }) ] : ""; }; const renderPadsLink = () => { const padsMod = getConfig().modules.padsMod === "on"; return padsMod ? [ navLink({ href: "/pads", emoji: "๊”—", text: i18n.padsTitle, class: "pads-link enabled" }) ] : ""; }; const renderTransfersLink = () => { const transfersMod = getConfig().modules.transfersMod === "on"; return transfersMod ? [ navLink({ href: "/transfers", emoji: "๊˜‰", text: i18n.transfersTitle, class: "transfers-link enabled" }) ] : ""; }; const renderFeedLink = () => { const feedMod = getConfig().modules.feedMod === "on"; return feedMod ? navLink({ href: "/feed", emoji: "๊•ฟ", text: i18n.feedTitle, class: "feed-link enabled" }) : ""; }; const renderPixeliaLink = () => { const pixeliaMod = getConfig().modules.pixeliaMod === "on"; return pixeliaMod ? [ navLink({ href: "/pixelia", emoji: "๊”˜", text: i18n.pixeliaTitle, class: "pixelia-link enabled" }) ] : ""; }; const renderMelodyLink = () => { const melodyMod = getConfig().modules.melodyMod === "on"; return melodyMod ? [ navLink({ href: "/melody", emoji: "โ™ช", text: i18n.melodyTitle, class: "melody-link enabled" }) ] : ""; }; const renderGamesLink = () => { const gamesMod = getConfig().modules.gamesMod === "on"; return gamesMod ? [navLink({ href: "/games", emoji: "๊•‡", text: i18n.gamesTitle, class: "games-link enabled" })] : ""; }; const renderForumLink = () => { const forumMod = getConfig().modules.forumMod === "on"; return forumMod ? [ navLink({ href: "/forum", emoji: "๊•’", text: i18n.forumTitle, class: "forum-link enabled" }) ] : ""; }; const renderAgendaLink = () => { const agendaMod = getConfig().modules.agendaMod === "on"; return agendaMod ? [ navLink({ href: "/agenda", emoji: "๊—ค", text: i18n.agendaTitle, class: "agenda-link enabled" }) ] : ""; }; const renderFavoritesLink = () => { const favoritesMod = getConfig().modules.favoritesMod === "on"; return favoritesMod ? [ navLink({ href: "/favorites", emoji: "๊˜", text: i18n.favoritesTitle, class: "favorites-link enabled" }) ] : ""; }; const renderLogsLink = () => { const logsMod = getConfig().modules.logsMod === "on"; return logsMod ? [ navLink({ href: "/logs", emoji: "๊—ฏ", text: i18n.logsTitle || "Logs", class: "logs-link enabled" }) ] : ""; }; const renderAILink = () => { const aiMod = getConfig().modules.aiMod === "on"; return aiMod ? [ navLink({ href: "/ai", emoji: "๊˜œ", text: i18n.ai, class: "ai-link enabled" }) ] : ""; }; const renderEventsLink = () => { const eventsMod = getConfig().modules.eventsMod === "on"; return eventsMod ? [ navLink({ href: "/events", emoji: "๊•†", text: i18n.eventsLabel, class: "events-link enabled" }) ] : ""; }; const renderCalendarsLink = () => { const calendarsMod = getConfig().modules.calendarsMod === "on"; return calendarsMod ? [ navLink({ href: "/calendars", emoji: "\uA5AF", text: i18n.calendarsTitle || "Calendars", class: "calendars-link enabled" }) ] : ""; }; const renderTasksLink = () => { const tasksMod = getConfig().modules.tasksMod === "on"; return tasksMod ? [ navLink({ href: "/tasks", emoji: "๊–ง", text: i18n.tasksTitle, class: "tasks-link enabled" }) ] : ""; }; // === Oasis UX (portado de 0.8.7_UX): topbar + bottombar + hive === // topbar fija arriba: [logo] [Personal] [Community] [avatar] en una lรญnea (sin tรญtulo OASIS) const renderMobileTopbar = () => { // FORK_IA_UX v3: Personal/Community VUELVEN al topbar (ya funcionan: la hoja cerrada es // visibility:hidden y no bloquea los taps). Llena el top y da el filtro a un toque. const hiveFilter = (typeof global !== "undefined" && global.__OASIS_HIVE_FILTER__) || ""; return div( { class: "oasis-mobile-topbar" }, a({ class: "omt-logo", href: "/" }, img({ src: "/assets/images/snh-oasis.jpg", alt: "Oasis" })), div({ class: "omt-menus" }, a({ class: hiveFilter === "personal" ? "oasis-menu-btn active" : "oasis-menu-btn", href: hiveFilter === "personal" ? "/activity" : "/activity?hive=personal" }, span(i18n.menuPersonal || "Personal")), a({ class: hiveFilter === "community" ? "oasis-menu-btn active" : "oasis-menu-btn", href: hiveFilter === "community" ? "/activity" : "/activity?hive=community" }, span(i18n.menuCommunity || "Community")) ), a({ class: "omt-avatar", href: "/profile" }, img({ src: (typeof global !== "undefined" && global.__OASIS_SELF_AVATAR__) || "/assets/images/default-avatar.png", alt: i18n.profile || "Profile" })) ); }; const PINNABLE_MODULES = [ { key: 'search', href: '/search', glyph: '๊”…', labelKey: 'searchTitle', mod: null }, { key: 'inbox', href: '/inbox', glyph: 'โ˜‚', labelKey: 'inbox', mod: null }, { key: 'publish', href: '/publish', glyph: 'โ‚', labelKey: 'publish', mod: null }, { key: 'activity', href: '/activity', glyph: '๊”™', labelKey: 'activityTitle', mod: null }, { key: 'tribes', href: '/tribes', glyph: '๊–ฅ', labelKey: 'tribesTitle', mod: 'tribesMod' }, { key: 'chats', href: '/chats', glyph: '๊–’', labelKey: 'chatsTitle', mod: 'chatsMod' }, { key: 'karvan', href: '/karvan', glyph: 'โง—', labelKey: 'karvanTitle', mod: 'karvanMod' }, { key: 'market', href: '/market', glyph: '๊•ป', labelKey: 'marketTitle', mod: 'marketMod' }, { key: 'events', href: '/events', glyph: '๊•†', labelKey: 'eventsLabel', mod: 'eventsMod' }, { key: 'calendars', href: '/calendars', glyph: '๊–ฏ', labelKey: 'calendarsTitle', mod: 'calendarsMod' }, { key: 'banking', href: '/banking', glyph: '๊—ด', labelKey: 'bankingTitle', mod: 'bankingMod' }, { key: 'forum', href: '/forum', glyph: '๊•’', labelKey: 'forumTitle', mod: 'forumMod' }, { key: 'videos', href: '/videos', glyph: '๊—Ÿ', labelKey: 'videosLabel', mod: 'videosMod' }, { key: 'images', href: '/images', glyph: '๊•ฅ', labelKey: 'imagesLabel', mod: 'imagesMod' }, { key: 'audios', href: '/audios', glyph: '๊”ฟ', labelKey: 'audiosLabel', mod: 'audiosMod' }, { key: 'documents', href: '/documents', glyph: '๊•จ', labelKey: 'docsLabel', mod: 'docsMod' }, { key: 'bookmarks', href: '/bookmarks', glyph: '๊”ช', labelKey: 'bookmarksLabel', mod: 'bookmarksMod' }, { key: 'maps', href: '/maps', glyph: '๊”Œ', labelKey: 'mapsLabel', mod: 'mapsMod' }, { key: 'trending', href: '/trending', glyph: '๊—', labelKey: 'trendingLabel', mod: 'trendingMod' }, { key: 'jobs', href: '/jobs', glyph: '๊—’', labelKey: 'jobsTitle', mod: 'jobsMod' }, { key: 'agenda', href: '/agenda', glyph: '๊—ค', labelKey: 'agendaTitle', mod: 'agendaMod' }, { key: 'wallet', href: '/wallet', glyph: 'โ„', labelKey: 'wallet', mod: 'walletMod' }, { key: 'tasks', href: '/tasks', glyph: '๊–ง', labelKey: 'tasksTitle', mod: 'tasksMod' }, { key: 'torrents', href: '/torrents', glyph: '๊–…', labelKey: 'torrentsLabel', mod: 'torrentsMod' }, { key: 'tags', href: '/tags', glyph: '๊–ถ', labelKey: 'tagsLabel', mod: 'tagsMod' }, { key: 'pads', href: '/pads', glyph: '๊”—', labelKey: 'padsTitle', mod: 'padsMod' }, { key: 'projects', href: '/projects', glyph: '๊•ง', labelKey: 'projectsTitle', mod: 'projectsMod' }, { key: 'shops', href: '/shops', glyph: '๊”œ', labelKey: 'shopsTitle', mod: 'shopsMod' }, { key: 'transfers', href: '/transfers', glyph: '๊˜‰', labelKey: 'transfersTitle', mod: 'transfersMod' }, { key: 'feed', href: '/feed', glyph: '๊•ฟ', labelKey: 'feedTitle', mod: 'feedMod' }, { key: 'pixelia', href: '/pixelia', glyph: '๊”˜', labelKey: 'pixeliaTitle', mod: 'pixeliaMod' }, { key: 'melody', href: '/melody', glyph: 'โ™ช', labelKey: 'melodyTitle', mod: 'melodyMod' }, { key: 'games', href: '/games', glyph: '๊•‡', labelKey: 'gamesTitle', mod: 'gamesMod' }, { key: 'votes', href: '/votes', glyph: '๊”ฐ', labelKey: 'votationsTitle', mod: 'votesMod' }, { key: 'reports', href: '/reports', glyph: '๊•ฅ', labelKey: 'reportsTitle', mod: 'reportsMod' }, { key: 'opinions', href: '/opinions', glyph: '๊”', labelKey: 'opinionsTitle', mod: 'opinionsMod' }, { key: 'parliament',href: '/parliament',glyph: '๊—ž', labelKey: 'parliamentTitle',mod: 'parliamentMod' }, { key: 'courts', href: '/courts', glyph: '๊–ป', labelKey: 'courtsTitle', mod: 'courtsMod' }, { key: 'larp', href: '/larp', glyph: 'โœฆ', labelKey: 'larpTitle', mod: 'larpMod' }, { key: 'ai', href: '/ai', glyph: '๊˜œ', labelKey: 'ai', mod: 'aiMod' }, { key: 'cipher', href: '/cipher', glyph: '๊—„', labelKey: 'cipher', mod: 'cipherMod' }, { key: 'fediverse', href: '/fediverse', glyph: '๊—ต', labelKey: 'fediverseTimeline', mod: 'fediverseMod' }, { key: 'inhabitants', href: '/inhabitants', glyph: '๊–˜', labelKey: 'inhabitantsLabel', mod: null }, { key: 'cv', href: '/cv', glyph: '๊•›', labelKey: 'cvTitle', mod: null }, { key: 'stats', href: '/stats', glyph: '๊•ท', labelKey: 'statistics', mod: null }, { key: 'blockexplorer', href: '/blockexplorer', glyph: '๊–ธ', labelKey: 'blockchain', mod: null }, ]; exports.PINNABLE_MODULES = PINNABLE_MODULES; const renderBottomBar = () => { if (process.env.OASIS_MOBILE !== '1') return ""; const inboxN = sharedState.getInboxCount(); const cfg = getConfig(); const modOn = (m) => !m || cfg.modules[m] === 'on' || cfg.modules[m] === undefined; const bbLink = (href, ico, label, badge) => a( { class: "bb-item", href }, span({ class: "bb-ico" }, ico), (badge && badge > 0) ? span({ class: "bb-badge" }, String(badge)) : "", span({ class: "bb-lbl" }, label) ); // FORK_IA_UX v4: barra PERSONALIZABLE (pins) + lรกpiz โœŽ para editar (/settings/bottombar) // + par FIJO Peers/Invites a la derecha. Sin FAB "+". (restaurada de la versiรณn que gustaba) const MAX = 4; const pins = (cfg.bottomBarPins || []) .map(key => PINNABLE_MODULES.find(m => m.key === key)) .filter(m => m && modOn(m.mod)) .slice(0, MAX); const slots = pins.map(m => bbLink( m.href, m.glyph, i18n[m.labelKey] || m.key, m.key === 'inbox' ? inboxN : 0 )); const editSlot = a( { class: "bb-item bb-add", href: "/settings/bottombar" }, span({ class: "bb-ico" }, "โœŽ"), span({ class: "bb-lbl" }, i18n.bbManage || "Edit") ); const pair = [ bbLink("/peers", "โง–", i18n.peers || "Peers") ]; if (modOn('invitesMod')) pair.push(bbLink("/invites", "๊”ฎ", i18n.invites || "Invites")); return nav( { class: "oasis-bottombar", "aria-label": i18n.bbQuickActions || "Quick actions" }, ...slots, editSlot, div({ class: "bb-pair" }, ...pair) ); }; const renderHiveNav = () => { if (process.env.OASIS_MOBILE !== '1') return ""; const cfg = getConfig(); const modOn = (m) => !m || cfg.modules[m] === 'on' || cfg.modules[m] === undefined; const CATS = [ { id: 'personal', emoji: 'โš‰', title: i18n.menuPersonal, mods: [ { href: '/profile', glyph: 'โš‰', labelKey: 'profile', mod: null }, { href: '/cv', glyph: '๊•›', labelKey: 'cvTitle', mod: null }, { href: '/agenda', glyph: '๊—ค', labelKey: 'agendaTitle', mod: 'agendaMod' }, { href: '/favorites', glyph: '๊˜', labelKey: 'favoritesTitle', mod: 'favoritesMod' }, { href: '/logs', glyph: '๊—ฏ', labelKey: 'logsTitle', mod: 'logsMod' }, { href: '/wallet', glyph: 'โ„', labelKey: 'wallet', mod: 'walletMod' }, { href: '/modules', glyph: '๊—ฃ', labelKey: 'modules', mod: null }, { href: '/settings', glyph: 'โš™', labelKey: 'settings', mod: null } ] }, { id: 'governance', emoji: 'โš–', title: i18n.menuGovernance, mods: [ { href: '/inhabitants', glyph: '๊–˜', labelKey: 'inhabitantsLabel', mod: null }, { href: '/tribes', glyph: '๊–ฅ', labelKey: 'tribesTitle', mod: 'tribesMod' }, { href: '/larp', glyph: 'โœฆ', labelKey: 'larpTitle', mod: 'larpMod' }, { href: '/parliament', glyph: '๊—ž', labelKey: 'parliamentTitle', mod: 'parliamentMod' }, { href: '/courts', glyph: '๊–ป', labelKey: 'courtsTitle', mod: 'courtsMod' } ] }, { id: 'office', emoji: 'โŒ‚', title: i18n.menuOffice, mods: [ { href: '/votes', glyph: '๊”ฐ', labelKey: 'votationsTitle', mod: 'votesMod' }, { href: '/events', glyph: '๊•†', labelKey: 'eventsLabel', mod: 'eventsMod' }, { href: '/calendars', glyph: '๊–ฏ', labelKey: 'calendarsTitle', mod: 'calendarsMod' }, { href: '/tasks', glyph: '๊–ง', labelKey: 'tasksTitle', mod: 'tasksMod' }, { href: '/reports', glyph: '๊•ฅ', labelKey: 'reportsTitle', mod: 'reportsMod' } ] }, { id: 'economy', emoji: 'ยค', title: i18n.menuEconomy, mods: [ { href: '/banking', glyph: '๊—ด', labelKey: 'bankingTitle', mod: 'bankingMod' }, { href: '/market', glyph: '๊•ป', labelKey: 'marketTitle', mod: 'marketMod' }, { href: '/projects', glyph: '๊•ง', labelKey: 'projectsTitle', mod: 'projectsMod' }, { href: '/jobs', glyph: '๊—’', labelKey: 'jobsTitle', mod: 'jobsMod' }, { href: '/shops', glyph: '๊”œ', labelKey: 'shopsTitle', mod: 'shopsMod' }, { href: '/transfers', glyph: '๊˜‰', labelKey: 'transfersTitle', mod: 'transfersMod' } ] }, { id: 'tools', emoji: 'โš’', title: i18n.menuTools, mods: [ { href: '/ai', glyph: '๊˜œ', labelKey: 'ai', mod: 'aiMod' }, { href: '/blockexplorer', glyph: '๊–ธ', labelKey: 'blockchain', mod: null }, { href: '/cipher', glyph: '๊—„', labelKey: 'cipher', mod: 'cipherMod' }, { href: '/invites', glyph: '๊”น', labelKey: 'invites', mod: 'invitesMod' }, { href: '/legacy', glyph: '๊–ค', labelKey: 'legacy', mod: 'legacyMod' }, { href: '/stats', glyph: '๊•ท', labelKey: 'statistics', mod: null } ] }, { id: 'network', emoji: 'โ˜', title: i18n.menuNetwork, mods: [ { href: '/activity', glyph: '๊”™', labelKey: 'activityTitle', mod: null }, { href: '/tags', glyph: '๊–ถ', labelKey: 'tagsLabel', mod: 'tagsMod' }, { href: '/trending', glyph: '๊—', labelKey: 'trendingLabel', mod: 'trendingMod' }, { href: '/opinions', glyph: '๊”', labelKey: 'opinionsTitle', mod: 'opinionsMod' }, { href: '/pads', glyph: '๊”—', labelKey: 'padsTitle', mod: 'padsMod' }, { href: '/forum', glyph: '๊•’', labelKey: 'forumTitle', mod: 'forumMod' }, { href: '/maps', glyph: '๊”Œ', labelKey: 'mapsLabel', mod: 'mapsMod' }, { href: '/chats', glyph: '๊–’', labelKey: 'chatsTitle', mod: 'chatsMod' }, { href: '/karvan', glyph: 'โง—', labelKey: 'karvanTitle', mod: 'karvanMod' } ] }, { id: 'blogs', emoji: 'โœฆ', title: i18n.menuBlogs, mods: [ { href: '/mentions', glyph: 'โœบ', labelKey: 'mentions', mod: null }, { href: '/public/latest', glyph: 'โ˜„', labelKey: 'latest', mod: 'latestMod' }, { href: '/public/latest/threads', glyph: 'โ™บ', labelKey: 'threads', mod: 'threadsMod' }, { href: '/public/latest/topics', glyph: 'ฯŸ', labelKey: 'topics', mod: 'topicsMod' }, { href: '/public/latest/summaries', glyph: 'โ€ป', labelKey: 'summaries', mod: 'summariesMod' }, { href: '/public/popular/day', glyph: 'โŒ˜', labelKey: 'popular', mod: 'popularMod' }, { href: '/public/latest/extended', glyph: 'โˆž', labelKey: 'multiverse', mod: 'multiverseMod' } ] }, { id: 'creative', emoji: 'โœŽ', title: i18n.menuCreative, mods: [ { href: '/feed', glyph: '๊•ฟ', labelKey: 'feedTitle', mod: 'feedMod' }, { href: '/games', glyph: '๊•‡', labelKey: 'gamesTitle', mod: 'gamesMod' }, { href: '/pixelia', glyph: '๊”˜', labelKey: 'pixeliaTitle', mod: 'pixeliaMod' }, { href: '/melody', glyph: 'โ™ช', labelKey: 'melodyTitle', mod: 'melodyMod' } ] }, { id: 'media', emoji: 'โ–ค', title: i18n.menuMedia, mods: [ { href: '/audios', glyph: '๊”ฟ', labelKey: 'audiosLabel', mod: 'audiosMod' }, { href: '/bookmarks', glyph: '๊”ช', labelKey: 'bookmarksLabel', mod: 'bookmarksMod' }, { href: '/documents', glyph: '๊•จ', labelKey: 'docsLabel', mod: 'docsMod' }, { href: '/images', glyph: '๊•ฅ', labelKey: 'imagesLabel', mod: 'imagesMod' }, { href: '/torrents', glyph: '๊–…', labelKey: 'torrentsLabel', mod: 'torrentsMod' }, { href: '/videos', glyph: '๊—Ÿ', labelKey: 'videosLabel', mod: 'videosMod' } ] }, { id: 'fediverse', emoji: 'โŒฌ', title: i18n.fediverse, mods: [ { href: '/fediverse', glyph: '๊—ต', labelKey: 'fediverseTimeline', mod: 'fediverseMod' } ] }, ]; // filtro server-side (lo pone el middleware desde ?hive=): personal = CATS[0..4], community = CATS[5..9], vacรญo = las 10 const hiveFilter = (typeof global !== "undefined" && global.__OASIS_HIVE_FILTER__) || ""; const shown = hiveFilter === "personal" ? CATS.slice(0, 5) : hiveFilter === "community" ? CATS.slice(5) : CATS; const rowSizes = shown.length <= 5 ? [3, 2] : [3, 4, 3]; const catHex = (c) => label({ for: `hive-${c.id}`, class: 'hex-cat' }, span({ class: 'hx-ico' }, c.emoji), span({ class: 'hx-lbl' }, c.title)); const modHex = (m) => a({ class: 'hex-mod', href: m.href }, span({ class: 'hx-ico' }, m.glyph), span({ class: 'hx-lbl' }, i18n[m.labelKey] || '')); const radios = shown.map(c => input({ type: 'radio', name: 'hive-cat', id: `hive-${c.id}`, class: 'hive-radio' })); const catRows = []; let idx = 0; for (const size of rowSizes) { catRows.push(div({ class: 'hive-row' }, ...shown.slice(idx, idx + size).map(catHex))); idx += size; } const grid = div({ class: 'hive-grid hive-cats' }, ...catRows); const panels = shown.map(c => div({ class: 'hive-panel', id: `hive-panel-${c.id}` }, div({ class: 'hive-mods' }, ...c.mods.filter(m => modOn(m.mod)).map(modHex)) )); return div({ class: 'hive-nav' }, ...radios, grid, ...panels); }; exports.renderHiveNav = renderHiveNav; // FORK_IA_UX: el hive vive en una HOJA INFERIOR (bottom-sheet) alcanzable con el pulgar; sin JS (checkbox-hack). // Un checkbox oculto (#fork-explore-toggle) y la hoja son hermanos dentro de .fork-sheet-wrap (display:contents), // asรญ `#fork-explore-toggle:checked ~ .hive-sheet` la despliega; el botรณn "Explore" de la barra es su