"use strict"; // Navegacion propia de la rama experimental para movil: topbar, barra inferior // personalizable y lanzador de modulos. // // Vive fuera de main_views.js a proposito: upstream reescribe ese fichero en cada // release (de 0.9.1 a 0.9.5 movio +256/-502 lineas) y esta interfaz se perdia en // cada integracion. Aqui solo depende de i18n, que se inyecta porque main_views // lo muta en cada peticion sin reasignarlo. 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"); const { iconNode, iconKeyOf } = require("./mobile_icons"); // con pocos modulos activos (el workflow activistas) todo cabe en una pantalla: // sobran el filtro Personal/Social y los accesos de usuario avanzado const COMPACT_MAX = 16; module.exports = ({ i18n }) => { // === Oasis UX (portado de 0.8.7_UX): topbar + bottombar + lanzador === // topbar fija arriba: [logo] [Personal] [Community] [avatar] en una línea (sin título OASIS) const renderMobileTopbar = () => { // solo en movil: en escritorio manda la cabecera de upstream if (process.env.OASIS_MOBILE !== '1') return ""; // 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" })), isCompact() ? div({ class: "omt-menus" }) : 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.menuSocial || "Social")) ), 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: 'rooms', href: '/rooms', glyph: 'ꕢ', labelKey: 'roomsTitle', mod: 'roomsMod' }, { key: 'wiki', href: '/wiki', glyph: 'ꖿ', labelKey: 'wikiTitle', mod: 'wikiMod' }, { key: 'polls', href: '/polls', glyph: 'ꖙ', labelKey: 'pollsTitle', mod: 'pollsMod' }, { key: 'blogs', href: '/blogs', glyph: 'ꔫ', labelKey: 'blogTitle', mod: 'blogsMod' }, { key: 'data', href: '/data', glyph: 'ꘑ', labelKey: 'dataTitle', mod: 'dataMod' }, { 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 }, ]; 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; // la pestaña de la seccion abierta se marca (pastilla de color tras el icono) let here = ''; try { here = (require('../models/typed_log').requestScope.getStore() || {}).path || ''; } catch (_) {} const isHere = (href) => here === href || (href !== '/' && here.startsWith(href + '/')) || (href === '/activity' && here === '/'); const bbLink = (href, ico, label, badge) => a( { class: isHere(href) ? "bb-item current" : "bb-item", href, ...(isHere(href) ? { 'aria-current': 'page' } : {}) }, iconNode("bb-ico", iconKeyOf(href), ico), (badge && badge > 0) ? span({ class: "bb-badge" }, String(badge)) : "", span({ class: "bb-lbl" }, label) ); // barra PERSONALIZABLE: solo los pins (max 4). Editarla y Peers estan en el // lanzador (Herramientas): 7 botones abajo eran demasiados para un pulgar. 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 )); // quinta pestaña fija: Menu abre desde abajo una hoja con todos los modulos (el // lanzador). Sin JS: una casilla y CSS. autocomplete=off para que al volver atras // el navegador no la deje abierta. Ver docs/ESTUDIO_NAVEGACION.md const menuLabel = i18n.bbMenu || 'Menu'; return nav( { class: "oasis-bottombar", attrs: { "aria-label": i18n.bbQuickActions || "Quick actions" } }, input({ type: 'checkbox', id: 'oasis-menu-toggle', class: 'menu-toggle', autocomplete: 'off', 'aria-label': menuLabel }), ...slots, label({ class: 'bb-item bb-menu', for: 'oasis-menu-toggle' }, iconNode('bb-ico', 'modules', '▦'), span({ class: 'bb-lbl' }, menuLabel) ), label({ class: 'menu-sheet-backdrop', for: 'oasis-menu-toggle', 'aria-hidden': 'true' }), div({ class: 'menu-sheet', role: 'dialog', 'aria-label': menuLabel }, div({ class: 'menu-sheet-head' }, span({ class: 'menu-sheet-title' }, menuLabel), label({ class: 'menu-sheet-close', for: 'oasis-menu-toggle', 'aria-label': i18n.bbMenuClose || 'Close' }, '✕') ), renderHiveNav() ) ); }; // Lanzador de modulos de la home: secciones con una rejilla de iconos, como el // cajon de apps del movil. Solo salen los modulos activos y las secciones que // tienen alguno, asi que con un workflow corto (activistas) cabe en una pantalla. const GROUPS = [ { id: 'community', titleKey: 'menuNetwork', side: 'community', mods: [ { href: '/activity', glyph: 'ꔙ', labelKey: 'activityTitle', mod: null }, { href: '/feed', glyph: 'ꕿ', labelKey: 'feedTitle', mod: 'feedMod' }, { href: '/chats', glyph: 'ꖒ', labelKey: 'chatsTitle', mod: 'chatsMod' }, { href: '/rooms', glyph: 'ꕢ', labelKey: 'roomsTitle', mod: 'roomsMod' }, { href: '/forum', glyph: 'ꕒ', labelKey: 'forumTitle', mod: 'forumMod' }, { href: '/pads', glyph: 'ꔗ', labelKey: 'padsTitle', mod: 'padsMod' }, { href: '/wiki', glyph: 'ꖿ', labelKey: 'wikiTitle', mod: 'wikiMod' }, { href: '/blogs', glyph: 'ꔫ', labelKey: 'blogTitle', mod: 'blogsMod' }, { href: '/opinions', glyph: 'ꔍ', labelKey: 'opinionsTitle', mod: 'opinionsMod' }, { href: '/mentions', glyph: '✺', labelKey: 'mentions', mod: null, extra: true }, { href: '/tags', glyph: 'ꖶ', labelKey: 'tagsLabel', mod: 'tagsMod' }, { href: '/trending', glyph: 'ꗝ', labelKey: 'trendingLabel', mod: 'trendingMod' }, { href: '/maps', glyph: 'ꔌ', labelKey: 'mapsLabel', mod: 'mapsMod' }, { href: '/graphos', glyph: '⌬', labelKey: 'modulesGraphosLabel', mod: 'graphosMod' }, { href: '/inhabitants', glyph: 'ꖘ', labelKey: 'inhabitantsLabel', mod: null, extra: true }, { href: '/fediverse', glyph: 'ꗵ', labelKey: 'fediverseTimeline', mod: 'fediverseMod' } ] }, { id: 'media', titleKey: 'menuMedia', side: 'community', mods: [ { href: '/images', glyph: 'ꕥ', labelKey: 'imagesLabel', mod: 'imagesMod' }, { href: '/videos', glyph: 'ꗟ', labelKey: 'videosLabel', mod: 'videosMod' }, { href: '/audios', glyph: 'ꔿ', labelKey: 'audiosLabel', mod: 'audiosMod' }, { href: '/podcasts', glyph: '◉', labelKey: 'podcastsTitle', mod: 'podcastsMod' }, { href: '/documents', glyph: 'ꕨ', labelKey: 'docsLabel', mod: 'docsMod' }, { href: '/files', glyph: 'ꖃ', labelKey: 'filesTitle', mod: 'filesMod' }, { href: '/bookmarks', glyph: 'ꔪ', labelKey: 'bookmarksLabel', mod: 'bookmarksMod' }, { href: '/torrents', glyph: 'ꖅ', labelKey: 'torrentsLabel', mod: 'torrentsMod' } ] }, { id: 'creative', titleKey: 'menuCreative', side: 'community', mods: [ { href: '/games', glyph: 'ꕇ', labelKey: 'gamesTitle', mod: 'gamesMod' }, { href: '/pixelia', glyph: 'ꔘ', labelKey: 'pixeliaTitle', mod: 'pixeliaMod' }, { href: '/melody', glyph: '♪', labelKey: 'melodyTitle', mod: 'melodyMod' } ] }, { id: 'office', titleKey: 'menuOffice', side: 'personal', mods: [ { href: '/events', glyph: 'ꕆ', labelKey: 'eventsLabel', mod: 'eventsMod' }, { href: '/calendars', glyph: 'ꖯ', labelKey: 'calendarsTitle', mod: 'calendarsMod' }, { href: '/tasks', glyph: 'ꖧ', labelKey: 'tasksTitle', mod: 'tasksMod' }, { href: '/campaigns', glyph: '⚑', labelKey: 'campaignsTitle', mod: 'campaignsMod' }, { href: '/mailing', glyph: '✉', labelKey: 'mailingTitle', mod: 'mailingMod' }, { href: '/votes', glyph: 'ꔰ', labelKey: 'votationsTitle', mod: 'votesMod' }, { href: '/polls', glyph: 'ꖙ', labelKey: 'pollsTitle', mod: 'pollsMod' }, { href: '/reports', glyph: 'ꕤ', labelKey: 'reportsTitle', mod: 'reportsMod' } ] }, { id: 'governance', titleKey: 'menuGovernance', side: 'personal', mods: [ { href: '/tribes', glyph: 'ꖥ', labelKey: 'tribesTitle', mod: 'tribesMod' }, { href: '/emergencies', glyph: '✚', labelKey: 'emergenciesTitle', mod: 'emergenciesMod' }, { href: '/school', glyph: 'ꗳ', labelKey: 'schoolTitle', mod: 'schoolMod' }, { href: '/larp', glyph: '✦', labelKey: 'larpTitle', mod: 'larpMod' }, { href: '/parliament', glyph: 'ꗞ', labelKey: 'parliamentTitle', mod: 'parliamentMod' }, { href: '/courts', glyph: 'ꖻ', labelKey: 'courtsTitle', mod: 'courtsMod' } ] }, { id: 'economy', titleKey: 'menuEconomy', side: 'personal', mods: [ { href: '/market', glyph: 'ꕻ', labelKey: 'marketTitle', mod: 'marketMod' }, { href: '/shops', glyph: 'ꔜ', labelKey: 'shopsTitle', mod: 'shopsMod' }, { href: '/jobs', glyph: 'ꗒ', labelKey: 'jobsTitle', mod: 'jobsMod' }, { href: '/projects', glyph: 'ꕧ', labelKey: 'projectsTitle', mod: 'projectsMod' }, { href: '/housing', glyph: '⌂', labelKey: 'housingTitle', mod: 'housingMod' }, { href: '/logistics', glyph: '⇄', labelKey: 'logisticsTitle', mod: 'logisticsMod' }, { href: '/banking', glyph: 'ꗴ', labelKey: 'bankingTitle', mod: 'bankingMod' }, { href: '/transfers', glyph: 'ꘉ', labelKey: 'transfersTitle', mod: 'transfersMod' } ] }, { id: 'personal', titleKey: 'menuPersonal', side: 'personal', mods: [ { href: '/profile', glyph: '⚉', labelKey: 'profile', mod: null, extra: true }, { href: '/agenda', glyph: 'ꗤ', labelKey: 'agendaTitle', mod: 'agendaMod' }, { href: '/favorites', glyph: 'ꘝ', labelKey: 'favoritesTitle', mod: 'favoritesMod' }, { href: '/phone', glyph: '☏', labelKey: 'phoneTitle', mod: 'phoneMod' }, { href: '/cv', glyph: 'ꕛ', labelKey: 'cvTitle', mod: null, extra: true }, { href: '/wallet', glyph: '❄', labelKey: 'wallet', mod: 'walletMod' }, { href: '/logs', glyph: 'ꗯ', labelKey: 'logsTitle', mod: 'logsMod' } ] }, { id: 'tools', titleKey: 'menuTools', side: 'personal', mods: [ { href: '/invites', glyph: 'ꔹ', labelKey: 'invites', mod: 'invitesMod' }, { href: '/peers', glyph: '⧖', labelKey: 'peers', mod: null }, { href: '/settings/bottombar', glyph: '✎', labelKey: 'bbYourBar', mod: null }, { href: '/backup', glyph: 'ꖤ', labelKey: 'backupTitle', mod: 'backupMod' }, { href: '/cipher', glyph: 'ꗄ', labelKey: 'cipher', mod: 'cipherMod' }, { href: '/ai', glyph: 'ꘜ', labelKey: 'ai', mod: 'aiMod' }, { href: '/stats', glyph: 'ꕷ', labelKey: 'statistics', mod: null, extra: true }, { href: '/blockexplorer', glyph: 'ꖸ', labelKey: 'blockchain', mod: null, extra: true }, { href: '/modules', glyph: 'ꗣ', labelKey: 'modules', mod: null, extra: true }, { href: '/settings', glyph: '⚙', labelKey: 'settings', mod: null } ] }, ]; const isCompact = () => { const cfg = getConfig(); const on = (m) => m && cfg.modules[m] === 'on'; return GROUPS.reduce((n, g) => n + g.mods.filter(x => on(x.mod)).length, 0) <= COMPACT_MAX; }; 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 compact = isCompact(); // Personal/Social de la topbar (lo pone el middleware desde ?hive=) parte el lanzador en dos const hiveFilter = compact ? "" : ((typeof global !== "undefined" && global.__OASIS_HIVE_FILTER__) || ""); const app = (m) => a({ class: 'ln-app', href: m.href }, iconNode('ln-ico', iconKeyOf(m.href), m.glyph), span({ class: 'ln-lbl' }, i18n[m.labelKey] || m.href.slice(1))); const groups = GROUPS .filter(g => !hiveFilter || g.side === hiveFilter) .map(g => ({ g, mods: g.mods.filter(m => modOn(m.mod) && !(compact && m.extra)) })) .filter(x => x.mods.length); return nav({ class: 'launcher', 'aria-label': i18n.modules || 'Modules' }, ...groups.map(({ g, mods }) => div({ class: 'ln-group' }, h3({ class: 'ln-title' }, i18n[g.titleKey] || g.id), div({ class: 'ln-grid' }, ...mods.map(app)) )) ); }; // 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' }, iconNode('bb-manage-ico', iconKeyOf(m.href), 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' }, iconNode('bb-pick-ico', iconKeyOf(m.href), 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, GROUPS }; };