fix: cerrar la integracion 0.9.5 — recursos que faltaban y regresiones

Una revision a fondo del arbol contra 0.9.5 saco varios fallos, unos heredados y
otros que introdujo la propia integracion. Verificados uno a uno antes de tocar.

Recursos referenciados que no existian (los introduje al adoptar vistas de 0.9.5
que los usan, mientras la rama los habia borrado):
- Los cuatro temas *-SNH.css. settings_view ofrece Dark, Clear, Matrix y Purple,
  pero solo estaba OasisMobile: elegir cualquiera daba 404 y la app se quedaba sin
  estilos. Ahora los cuatro responden 200.
- pdf.min.mjs, pdf.worker.min.mjs y pdf-viewer.js, referenciados desde seis vistas.

Regresiones revertidas a 0.9.5 verbatim en catorce ficheros. Las de fondo:
- larp_model perdio getGoverningPeriodId, que backend.js llama: el anuncio de
  gobierno no salia nunca, con el TypeError tragado por un catch.
- courts_view y search_view tenian selected: false, que hyperscript SI serializa
  como atributo y HTML interpreta como seleccionado. La forma de upstream, el
  spread condicional, es la correcta.
- pm_model perdio includeDeleted, projects_model la validacion de deadline pasado,
  jobs_model el desempate por timestamp, middleware el flag de debug HTTP.

forum_view pasa a 0.9.5 conservando el filtro hot. Esto arregla un fallo activo: la
vista de la rama declaraba un cuarto parametro topic y backend.js llama con cinco
argumentos, asi que al abrir una respuesta el %clave del hilo se colaba como tema y
el hilo salia VACIO. Upstream pasa los mismos cinco argumentos a una firma de tres y
JS los ignora, que es lo correcto.

chats_view pasa a 0.9.5 y se implementa replyTo de verdad. Estaba muerto de punta a
punta: backend.js no leia ctx.query.replyTo ni pasaba el cuarto argumento a
sendMessage, asi que ningun mensaje llegaba a tener replyTo y la cita no se
renderizaba jamas. Ahora la cadena esta completa —modelo, GET, POST y vista— con
indice msgById armado antes de mezclar con las encuestas, y sin gate de movil:
responder a un mensaje no es una funcion de movil.

aria-label: encontrada la causa. No es que hyperaxe lo ignore, es que hyperscript
solo asigna como propiedad y html-element solo serializa atributos de su tabla, en
la que aria-* no esta; data-* si tiene rama propia, por eso las tablas responsive
funcionaban. Se arregla con el escape hatch attrs:{}, sin tocar node_modules.

prepare.sh copia README.md y falla si no esta: backend.js lo lee con readFileSync
sin try/catch, de modo que sin el fichero el backend muere con ENOENT al arrancar.

Verificado con la app en marcha: 17 rutas OK, los cuatro recursos que faltaban
sirviendo 200, aria-label emitido en el HTML, e interfaz intacta (10 categorias,
50 modulos, 7 accesos rapidos, 0 etiquetas vacias).
This commit is contained in:
SITO 2026-08-18 23:48:14 +02:00
parent 7459644a73
commit c3a1c841e6
27 changed files with 1994 additions and 220 deletions

View file

@ -20,7 +20,7 @@ const FILTER_LABELS = {
const BASE_FILTERS = ['recent', 'all', 'mine', 'tombstone', 'logs'];
const CAT_BLOCK1 = ['votes', 'event', 'task', 'report', 'calendar', 'parliament', 'courts'];
const CAT_BLOCK2 = ['pub', 'tribe', 'about', 'contact', 'curriculum', 'vote', 'aiExchange'];
const CAT_BLOCK3 = ['banking', 'job', 'market', 'project', 'industry', 'transfer', 'feed', 'post', 'pixelia', 'shop', 'gameScore'];
const CAT_BLOCK3 = ['banking', 'job', 'housing', 'market', 'project', 'industry', 'transfer', 'feed', 'post', 'pixelia', 'shop', 'gameScore'];
const CAT_BLOCK4 = ['forum', 'pad', 'chat', 'bookmark', 'image', 'video', 'audio', 'document', 'map', 'torrent'];
const SEARCH_FIELDS = ['author','id','from','to'];

View file

@ -1,9 +1,11 @@
const { div, h2, p, section, button, form, a, span, textarea, br, input, label, select, option, img, table, tr, td, ul, li } = require("../server/node_modules/hyperaxe")
const { template, i18n, userLink, renderStateChip, renderLifespanChip, renderSpreadButton } = require("./main_views")
const { div, h2, p, section, button, form, a, span, textarea, br, input, label, select, option, img, table, tr, td, ul, li, details, summary } = require("../server/node_modules/hyperaxe")
const { template, i18n, userLink, userLinkLabel, renderStateChip, renderLifespanChip, renderSpreadButton, renderContentActions } = require("./main_views")
const { renderEncryptedChip } = require("./clearnet_view")
const { renderResults, renderBallot, outcomeOf } = require("./polls_view")
const moment = require("../server/node_modules/moment")
const { config } = require("../server/SSB_server.js")
const { renderUrl } = require("../backend/renderUrl")
const { renderZoomableImage } = require("./gallery_view")
const userId = config.keys.id
const safeArr = (v) => (Array.isArray(v) ? v : [])
@ -17,6 +19,13 @@ const ALL_CATS = [...CAT_BLOCK1, ...CAT_BLOCK2, ...CAT_BLOCK3]
const catKey = (c) => "forumCat" + String(c || "").replace(/\./g, "").replace(/[\s-]/g, "").toUpperCase()
const catLabel = (c) => i18n[catKey(c)] || c
const blobSrcOf = (value) => {
const s = String(value || "").trim()
if (s.startsWith("&")) return `/blob/${encodeURIComponent(s)}`
const mImg = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/)
return mImg ? `/blob/${encodeURIComponent(mImg[1])}` : null
}
const renderMediaBlob = (value, fallbackSrc = null, attrs = {}) => {
if (!value) return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null
const s = String(value).trim()
@ -65,8 +74,19 @@ const renderChatCard = (chat, filter, params = {}) => {
renderEncryptedChip(i18n),
renderLifespanChip(chat.lifetime, i18n)
].filter(Boolean)
const href = `/chats/${encodeURIComponent(chat.key)}`
return div({ class: "tribe-card" },
div({ class: "card-header activity-card-header" },
renderContentActions(chat.key, href, {
spread: params.spread || null,
author: chat.author,
favKind: 'chats',
isFavorite: chat.isFavorite,
returnTo: buildReturnTo(filter, params),
reportTitle: chat.title
})
),
div({ class: "tribe-card-image-wrapper" },
a({ href: `/chats/${encodeURIComponent(chat.key)}` },
renderMediaBlob(chat.image, "/assets/images/default-avatar.png", { class: "tribe-card-hero-image" })
@ -82,13 +102,6 @@ const renderChatCard = (chat, filter, params = {}) => {
chat.description ? p({ class: "tribe-card-description" }, chat.description) : null,
div({ class: "tribe-card-members" },
span({ class: "tribe-members-count" }, `${i18n.chatParticipants}: ${safeArr(chat.members).length}`)
),
(() => {
const btn = renderSpreadButton(chat.key, (params && params.spreadMap && params.spreadMap.get(chat.key)) || (params && params.spreads));
return btn ? div({ class: "card-spread-left" }, btn) : null;
})(),
div({ class: "visit-btn-centered" },
a({ href: `/chats/${encodeURIComponent(chat.key)}`, class: "filter-btn" }, i18n.chatVisitChat)
)
)
)
@ -104,16 +117,15 @@ const renderChatForm = (filter, chat = {}, params = {}) => {
input({ type: "hidden", name: "returnTo", value: returnTo }),
tribeId ? input({ type: "hidden", name: "tribeId", value: tribeId }) : null,
span(i18n.title || "Title"), br(),
input({ type: "text", name: "title", required: true, placeholder: i18n.chatTitlePlaceholder, value: chat.title || "" }), br(), br(),
input({ type: "text", name: "title", maxlength: "100", required: true, placeholder: i18n.chatTitlePlaceholder, value: chat.title || "" }), br(), br(),
span(i18n.chatDescription), br(),
textarea({ name: "description", rows: 4, placeholder: i18n.chatDescriptionPlaceholder }, chat.description || ""), br(), br(),
span(i18n.chatImageLabel || "Select an image file (.jpeg, .jpg, .png, .gif)"), br(),
span(i18n.uploadMedia), br(),
input({ type: "file", name: "image", accept: "image/*" }), br(), br(),
span(i18n.chatCategory), br(),
select({ name: "category" },
option({ value: "" }, "\u2014"),
ALL_CATS.map(cat =>
option({ value: cat, ...(chat.category === cat ? { selected: true } : {}) }, catLabel(cat))
option({ value: cat, ...((chat.category || "GENERAL") === cat ? { selected: true } : {}) }, catLabel(cat))
)
), br(), br(),
span(i18n.chatStatusLabel || "Status"), br(),
@ -140,37 +152,99 @@ const renderMessageText = (text) => {
return span({ class: "chat-message-text" }, ...nodes)
}
const renderMessage = (msg, chatAuthor, chatKey, msgById) => {
const chatActivityTs = (c) => Math.max(
Number(c.lastMsgAt || 0),
Date.parse(c.updatedAt || "") || 0,
Date.parse(c.createdAt || "") || 0
)
const renderChatTopics = (chats, activeKey) =>
div({ class: "chat-topics" },
div({ class: "chat-topics-head" },
a({ href: "/chats", class: "chat-topics-title-link" }, i18n.chatsTitle),
a({ href: "/chats?filter=create", class: "filter-btn chat-topics-new" }, "+")
),
...safeArr(chats).slice().sort((a, b) => chatActivityTs(b) - chatActivityTs(a)).map(c => {
const key = c.rootId || c.key
const active = activeKey && String(key) === String(activeKey)
return a({ href: `/chats/${encodeURIComponent(key)}`, class: active ? "chat-topic chat-topic-active" : "chat-topic" },
span({ class: "chat-topic-title" }, c.title || i18n.chatUntitled),
span({ class: "chat-topic-meta" }, `${i18n.chatParticipants}: ${safeArr(c.members).length}`)
)
})
)
const renderSenderLink = (author, isOwner) =>
author
? a({ href: `/author/${encodeURIComponent(author)}`, class: isOwner ? "chat-bubble-sender chat-bubble-sender-owner" : "chat-bubble-sender" }, userLinkLabel(author))
: null
const renderChatPoll = (poll, chat) => {
const isSelf = String(poll.author) === String(userId)
const isOwner = String(poll.author) === String(chat.author)
const showResults = poll.hasVoted || poll.status === "CLOSED"
return div({ class: isSelf ? "chat-bubble-row chat-bubble-row-self" : "chat-bubble-row" },
div({ class: isSelf ? "chat-message chat-message-self chat-poll" : "chat-message chat-poll" },
isSelf ? null : renderSenderLink(poll.author, isOwner),
div({ class: "chat-poll-head" },
span({ class: "chat-poll-tag" }, i18n.pollInChat),
poll.anonymous ? span({ class: "chat-poll-tag" }, i18n.pollAnonymous) : null,
poll.multiple ? span({ class: "chat-poll-tag" }, i18n.pollMultiple) : null,
poll.status === "CLOSED" ? span({ class: "chat-poll-tag" }, i18n.pollStatusClosed) : null
),
poll.undecryptable
? p({ class: "chat-poll-question" }, i18n.chatAccessDenied)
: [
p({ class: "chat-poll-question" }, poll.question),
showResults ? renderResults(poll) : null,
div({ class: "chat-poll-actions" },
renderBallot(poll, `/chats/${encodeURIComponent(chat.key)}`, "/polls"),
String(poll.author) === String(userId) && poll.status === "OPEN"
? form({ method: "POST", action: `/polls/close/${encodeURIComponent(poll.id)}` },
input({ type: "hidden", name: "returnTo", value: `/chats/${encodeURIComponent(chat.key)}` }),
button({ type: "submit", class: "filter-btn" }, i18n.pollCloseButton)
)
: null
),
div({ class: "chat-poll-meta" },
span({ class: "card-label" }, `${i18n.pollVoters}: `),
span({ class: "card-value" }, String(poll.totalVoters)),
span({ class: "card-label" }, ` · ${i18n.pollOutcome}: `),
span({ class: "card-value" }, outcomeOf(poll))
)
],
span({ class: "chat-bubble-time" }, moment(poll.createdAt).format("HH:mm"))
)
)
}
const renderMessage = (msg, chatAuthor, ctx = {}) => {
const isAuthor = String(msg.author) === String(chatAuthor)
const isSelf = String(msg.author) === String(userId)
const dateStr = moment(msg.createdAt).format("YYYY/MM/DD HH:mm")
const shortId = msg.author ? "@" + msg.author.slice(1, 9) + "\u2026" : "?"
const authorLink = msg.author ? userLink(msg.author) : span("?")
const imageNode = msg.image ? renderMediaBlob(msg.image, null, { class: "chat-message-image" }) : null
// m\u00f3vil: cita del mensaje respondido + bot\u00f3n \u21a9 para responder a ESTE mensaje (server-side ?replyTo=, sin JS)
const mobile = process.env.OASIS_MOBILE === "1"
const quoted = (mobile && msg.replyTo && msgById) ? msgById.get(String(msg.replyTo)) : null
const quoteNode = quoted
? div({ class: "chat-quote" },
span({ class: "chat-quote-author" }, quoted.author ? userLink(quoted.author) : span("?")),
span({ class: "chat-quote-text" }, String(quoted.text || (quoted.image ? "\ud83d\uddbc" : "")).slice(0, 80))
)
: null
const replyLink = (mobile && chatKey && msg.key)
? a({ class: "chat-reply-btn", href: `/chats/${encodeURIComponent(chatKey)}?replyTo=${encodeURIComponent(msg.key)}#chatbox`, title: "Reply" }, "\u21a9")
// cita del mensaje respondido: se resuelve con el indice que arma la vista
const quoted = (msg.replyTo && ctx.msgById) ? ctx.msgById.get(String(msg.replyTo)) : null
const replyHref = (ctx.chatKey && msg.key)
? `/chats/${encodeURIComponent(ctx.chatKey)}?replyTo=${encodeURIComponent(msg.key)}#chat-compose`
: null
const imageSrc = blobSrcOf(msg.image)
const imageNode = imageSrc
? renderZoomableImage(imageSrc, { imgClass: "chat-message-image" })
: (msg.image ? renderMediaBlob(msg.image, null, { class: "chat-message-image" }) : null)
return div({ class: isSelf ? "chat-message chat-message-self" : isAuthor ? "chat-message chat-message-author" : "chat-message" },
div({ class: "chat-message-meta" },
span({ class: "chat-message-sender" }, authorLink),
span({ class: "chat-message-date" }, ` [ ${dateStr} ]`),
replyLink
),
quoteNode,
imageNode ? div({ class: "chat-message-image-wrap" }, imageNode) : null,
renderMessageText(msg.text || "")
return div({ class: isSelf ? "chat-bubble-row chat-bubble-row-self" : "chat-bubble-row" },
div({ class: isSelf ? "chat-message chat-message-self" : isAuthor ? "chat-message chat-message-author" : "chat-message" },
isSelf ? null : renderSenderLink(msg.author, isAuthor),
quoted
? div({ class: "chat-quote" },
span({ class: "chat-quote-author" }, String(quoted.author || "").slice(0, 10)),
span({ class: "chat-quote-text" }, String(quoted.text || "").slice(0, 120))
)
: null,
renderMessageText(msg.text || ""),
imageNode ? div({ class: "chat-message-image-wrap" }, imageNode) : null,
span({ class: "chat-bubble-time" }, moment(msg.createdAt).format("HH:mm")),
replyHref ? a({ class: "chat-reply-btn", href: replyHref, title: i18n.chatReply || "Reply" }, "↩") : null
)
)
}
@ -192,15 +266,21 @@ exports.chatsView = async (chats, filter, chatToEdit = null, params = {}) => {
const isForm = filter === "create" || filter === "edit"
const chatHeaderMap = {
all: i18n.chatsTitle,
mine: i18n.chatMineSectionTitle || "Your Chats",
recent: i18n.chatRecentTitle || "Recent Chats",
favorites: i18n.chatFavoritesTitle || "Favorites",
open: i18n.chatOpenTitle || "Open Chats",
closed: i18n.chatClosedTitle || "Closed Chats"
const headerText = i18n.chatsTitle
if (params.workspace && !isForm) {
return template(
i18n.chatsTitle,
section({ class: "chat-workspace-section" },
div({ class: "chat-workspace" },
renderChatTopics(list, null),
div({ class: "chat-workspace-main chat-workspace-empty" },
p({ class: "chat-no-messages" }, list.length ? i18n.chatPickChat : i18n.chatNoneYet)
)
)
)
)
}
const headerText = chatHeaderMap[filter] || i18n.chatsTitle
return template(
i18n.chatsTitle,
@ -214,12 +294,12 @@ exports.chatsView = async (chats, filter, chatToEdit = null, params = {}) => {
!isForm
? section(
div({ class: "filters" },
form({ method: "GET", action: "/chats" },
form({ method: "GET", action: "/chats", class: "filter-box" },
input({ type: "hidden", name: "filter", value: filter }),
input({ type: "text", name: "q", placeholder: i18n.chatSearchPlaceholder, value: q }),
br(),
button({ type: "submit" }, i18n.search),
br()
input({ type: "text", name: "q", placeholder: i18n.chatSearchPlaceholder, value: q, class: "filter-box__input" }),
div({ class: "filter-box__controls" },
button({ type: "submit", class: "filter-box__button" }, i18n.searchButton)
)
)
)
)
@ -229,7 +309,7 @@ exports.chatsView = async (chats, filter, chatToEdit = null, params = {}) => {
? renderChatForm(filter, filter === "edit" ? (chatToEdit || {}) : {}, params)
: div({ class: "tribe-grid" },
list.length
? list.map(chat => renderChatCard(chat, filter, { q }))
? list.map(chat => renderChatCard(chat, filter, { q, spread: params.spreadMap && params.spreadMap.get(chat.key) }))
: p(i18n.chatNoItems)
)
)
@ -241,7 +321,6 @@ exports.singleChatView = async (chat, filter, messages = [], params = {}) => {
const returnTo = safeText(params.returnTo) || buildReturnTo(filter, { q })
const isAuthor = String(chat.author) === String(userId)
const isMember = safeArr(chat.members).includes(userId) || (!!chat.tribeId && !!chat.isTribeMember)
const fullShareUrl = `/chats/${encodeURIComponent(chat.key)}`
const isRestrictedInviteOnly = !isMember && !isAuthor && chat.status === "INVITE-ONLY"
const statusLabel = chat.status === "CLOSED" ? i18n.chatStatusClosed :
@ -253,19 +332,14 @@ exports.singleChatView = async (chat, filter, messages = [], params = {}) => {
renderLifespanChip(chat.lifetime, i18n)
].filter(Boolean)
const chatSide = div({ class: "tribe-side" },
div({ class: "card-header activity-card-header" },
renderContentActions(chat.key, null, { spread: params.spreads || null, author: chat.author, favKind: 'chats', isFavorite: chat.isFavorite, returnTo: params.returnTo, reportTitle: chat.title })
),
div({ class: "shop-title-row" },
h2({ class: "tribe-card-title" }, chat.title || i18n.chatUntitled)
),
detailChips.length ? div({ class: "card-chips-row" }, ...detailChips) : null,
renderMediaBlob(chat.image, "/assets/images/default-avatar.png", { class: "tribe-detail-image" }),
div({ class: "shop-share" },
span({ class: "tribe-info-label" }, `${i18n.chatShareUrl}: `),
input({ type: "text", value: fullShareUrl, readonly: true, class: "shop-share-input" })
),
(() => {
const btn = renderSpreadButton(chat.key, params.spreads);
return btn ? div({ class: "card-spread-centered" }, btn) : null;
})(),
div({ class: "tribe-card-members" },
span({ class: "tribe-members-count" }, `${i18n.chatParticipants}: ${safeArr(chat.members).length}`)
),
@ -274,17 +348,18 @@ exports.singleChatView = async (chat, filter, messages = [], params = {}) => {
td({ class: "tribe-info-label" }, i18n.chatCreatedAt),
td({ class: "tribe-info-value", colspan: "3" }, moment(chat.createdAt).format("YYYY/MM/DD HH:mm"))
),
!isRestrictedInviteOnly && chat.category ? tr(
td({ class: "tribe-info-label" }, i18n.chatCategoryLabel),
td({ class: "tribe-info-value", colspan: "3" }, catLabel(chat.category))
) : null,
isRestrictedInviteOnly ? null : tr(
td({ class: "tribe-info-value", colspan: "4" },
userLink(chat.author)
)
),
!isRestrictedInviteOnly && chat.category ? tr(
td({ class: "tribe-info-label" }, i18n.chatCategoryLabel),
td({ class: "tribe-info-value", colspan: "3" }, catLabel(chat.category))
) : null
)
),
isRestrictedInviteOnly ? null : div({ class: "tribe-side-actions" },
isRestrictedInviteOnly ? null : (() => {
const sideActionNodes = [
isAuthor && chat.status === "INVITE-ONLY"
? form({ method: "POST", action: `/chats/generate-invite` },
input({ type: "hidden", name: "chatId", value: chat.key }),
@ -310,39 +385,34 @@ exports.singleChatView = async (chat, filter, messages = [], params = {}) => {
button({ type: "submit", class: "tribe-action-btn" }, i18n.tribeOpenInvitation)
)
})(),
form(
{ method: "POST", action: chat.isFavorite ? `/chats/favorites/remove/${encodeURIComponent(chat.key)}` : `/chats/favorites/add/${encodeURIComponent(chat.key)}` },
input({ type: "hidden", name: "returnTo", value: returnTo }),
button({ type: "submit", class: "tribe-action-btn" }, chat.isFavorite ? i18n.chatRemoveFavorite : i18n.chatAddFavorite)
),
chat.author && String(chat.author) !== String(userId)
? form({ method: "GET", action: "/pm" },
input({ type: "hidden", name: "recipients", value: chat.author }),
button({ type: "submit", class: "tribe-action-btn" }, i18n.chatPM || i18n.privateMessage)
)
: null,
isAuthor
? form({ method: "GET", action: `/chats/edit/${encodeURIComponent(chat.key)}` },
button({ type: "submit", class: "tribe-action-btn" }, i18n.chatUpdate)
)
: null,
isAuthor && chat.status !== "CLOSED"
? form({ method: "POST", action: `/chats/close/${encodeURIComponent(chat.key)}` },
input({ type: "hidden", name: "returnTo", value: returnTo }),
button({ type: "submit", class: "tribe-action-btn" }, i18n.chatClose)
)
: null,
isAuthor
? form({ method: "POST", action: `/chats/delete/${encodeURIComponent(chat.key)}` },
button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.chatDelete)
)
: null,
!isAuthor && isMember
? form({ method: "POST", action: `/chats/leave/${encodeURIComponent(chat.key)}` },
input({ type: "hidden", name: "returnTo", value: returnTo }),
button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.tribeLeaveButton)
)
: null
].flat().filter(Boolean)
return sideActionNodes.length ? div({ class: "tribe-side-actions" }, ...sideActionNodes) : null
})(),
isAuthor
? div({ class: "tribe-side-actions housing-status-row" },
span({ class: "card-label" }, `${i18n.chatStatusLabel}: `),
renderStateChip(chat.status === "CLOSED" ? "hidden" : "mutuals", chat.status === "CLOSED" ? "🔒" : "👁", String(chat.status || "OPEN").toUpperCase()),
chat.status !== "CLOSED"
? form({ method: "POST", action: `/chats/close/${encodeURIComponent(chat.key)}`, class: "inline-form" },
input({ type: "hidden", name: "returnTo", value: returnTo }),
button({ type: "submit", class: "tribe-action-btn" }, i18n.chatClose)
)
: null
)
: null,
!isAuthor ? null : div({ class: "tribe-side-actions owner-actions" },
form({ method: "GET", action: `/chats/edit/${encodeURIComponent(chat.key)}` },
button({ type: "submit", class: "tribe-action-btn" }, i18n.chatUpdate)
),
form({ method: "POST", action: `/chats/delete/${encodeURIComponent(chat.key)}` },
button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.chatDelete)
)
),
!isMember && chat.status === "INVITE-ONLY"
? div({ class: "chat-join-section" },
@ -357,54 +427,104 @@ exports.singleChatView = async (chat, filter, messages = [], params = {}) => {
)
const msgList = safeArr(messages)
const msgById = new Map(msgList.map(m => [String(m.key), m]))
const replyingTo = (process.env.OASIS_MOBILE === "1" && params.replyTo) ? msgById.get(String(params.replyTo)) : null
// indice por clave para resolver las citas; se arma antes de mezclar con encuestas
const msgById = new Map(msgList.filter(m => m && m.key).map(m => [String(m.key), m]))
const replyingTo = params.replyTo ? msgById.get(String(params.replyTo)) : null
const canWrite = (isMember || chat.status === "OPEN") && chat.status !== "CLOSED"
const chatMain = isRestrictedInviteOnly
? div({ class: "tribe-main chat-full-width" }, p({ class: "access-denied-msg" }, i18n.chatAccessDenied))
: div({ class: "tribe-main chat-full-width" },
canWrite
? div({ class: "chat-message-form", id: "chatbox" },
replyingTo
? div({ class: "chat-replying-to" },
span({ class: "chat-replying-label" }, "Replying to: "),
span({ class: "chat-quote-author" }, replyingTo.author ? userLink(replyingTo.author) : span("?")),
span({ class: "chat-quote-text" }, String(replyingTo.text || (replyingTo.image ? "🖼" : "")).slice(0, 80)),
a({ class: "chat-reply-cancel", href: `/chats/${encodeURIComponent(chat.key)}`, title: "Cancel" }, "✕")
)
: null,
form({ method: "POST", action: `/chats/${encodeURIComponent(chat.key)}/message`, enctype: "multipart/form-data" },
input({ type: "hidden", name: "returnTo", value: `/chats/${encodeURIComponent(chat.key)}` }),
replyingTo ? input({ type: "hidden", name: "replyTo", value: String(params.replyTo) }) : null,
textarea({ name: "text", rows: 3, placeholder: i18n.chatMessagePlaceholder }), br(),
span(i18n.chatImageLabel || "Select an image file (.jpeg, .jpg, .png, .gif)"), br(),
input({ type: "file", name: "image", accept: "image/*" }), br(), br(),
button({ type: "submit", class: "filter-btn" }, i18n.chatSendMessage)
)
msgList.length
? div({ class: "chat-jump-row" },
a({ href: "#chat-latest", class: "filter-btn chat-jump-latest" }, i18n.chatJumpLatest)
)
: null,
div({ class: "chat-messages-list" },
(() => {
const visible = msgList.filter(msg => (msg.text && String(msg.text).trim()) || msg.image)
return visible.length
? visible.map(msg => renderMessage(msg, chat.author, chat.key, msgById))
: p({ class: "chat-no-messages" }, i18n.chatNoMessages)
const visible = msgList
.filter(msg => (msg.text && String(msg.text).trim()) || msg.image)
.map(msg => ({ kind: 'message', ts: Date.parse(msg.createdAt) || 0, msg }))
const pollItems = safeArr(params.polls).map(poll => ({
kind: 'poll', ts: Date.parse(poll.createdAt) || 0, poll
}))
const stream = [...visible, ...pollItems].sort((a, b) => a.ts - b.ts)
if (!stream.length) return p({ class: "chat-no-messages" }, i18n.chatNoMessages)
const nodes = []
let prevDay = null
stream.forEach((entry, i) => {
const day = moment(entry.ts).format("YYYY/MM/DD")
if (day !== prevDay) {
nodes.push(div({ class: "chat-day-separator" }, span({ class: "chat-day-chip" }, day)))
prevDay = day
}
const last = i === stream.length - 1
const node = entry.kind === 'poll'
? renderChatPoll(entry.poll, chat)
: renderMessage(entry.msg, chat.author, { msgById, chatKey: chat.key })
nodes.push(last ? div({ id: "chat-latest", class: "chat-latest-anchor" }, node) : node)
})
return nodes
})()
)
),
canWrite
? div({ class: "chat-message-form" },
form({ method: "POST", action: `/chats/${encodeURIComponent(chat.key)}/message`, enctype: "multipart/form-data" },
input({ type: "hidden", name: "returnTo", value: `/chats/${encodeURIComponent(chat.key)}#chat-latest` }),
replyingTo
? div({ class: "chat-replying-to", id: "chat-compose" },
span({ class: "chat-quote-author" }, String(replyingTo.author || "").slice(0, 10)),
span({ class: "chat-quote-text" }, String(replyingTo.text || "").slice(0, 120)),
a({ class: "chat-reply-cancel", href: `/chats/${encodeURIComponent(chat.key)}#chat-latest` }, "✕")
)
: null,
replyingTo ? input({ type: "hidden", name: "replyTo", value: String(params.replyTo) }) : null,
textarea({ name: "text", rows: 3, placeholder: i18n.chatMessagePlaceholder }), br(),
span(i18n.uploadMedia), br(),
input({ type: "file", name: "image", accept: "image/*,video/*" }), br(), br(),
button({ type: "submit", class: "filter-btn" }, i18n.chatSendMessage)
)
)
: null,
canWrite && params.pollsEnabled
? details({ class: "chat-poll-form" },
summary({ class: "chat-poll-summary" }, i18n.pollChatCreate),
form({ method: "POST", action: `/chats/${encodeURIComponent(chat.key)}/polls/create` },
input({ type: "hidden", name: "returnTo", value: `/chats/${encodeURIComponent(chat.key)}` }),
span(i18n.pollQuestion), br(),
input({ type: "text", name: "question", required: true, maxlength: "300", placeholder: i18n.pollQuestionPlaceholder }), br(), br(),
span(i18n.pollOptions), br(),
textarea({ name: "options", rows: 4, required: true, placeholder: i18n.pollOptionsPlaceholder }), br(), br(),
div({ class: "poll-switch" },
input({ type: "hidden", name: "anonymous", value: "0" }),
label(input({ type: "checkbox", name: "anonymous", value: "1" }), " ", i18n.pollAnonymousLabel)
),
div({ class: "poll-switch" },
input({ type: "hidden", name: "multiple", value: "0" }),
label(input({ type: "checkbox", name: "multiple", value: "1" }), " ", i18n.pollMultipleLabel)
),
button({ type: "submit", class: "filter-btn" }, i18n.pollPublishButton)
)
)
: null
)
// móvil: tira para saltar entre chats sin volver a la lista (server-side, enlaces)
const switcherChats = process.env.OASIS_MOBILE === "1" && Array.isArray(params.chatList) ? params.chatList : []
const chatSwitcher = switcherChats.length
? div({ class: "chat-switcher" },
switcherChats.slice(0, 30).map(c => a({
class: String(c.key) === String(chat.key) ? "chat-switch-chip active" : "chat-switch-chip",
href: `/chats/${encodeURIComponent(c.key)}`,
title: c.title || ""
}, (c.title || "·").slice(0, 18)))
if (params.workspace) {
return template(
chat.title || i18n.chatUntitled,
section({ class: "chat-workspace-section" },
div({ class: "chat-workspace" },
renderChatTopics(safeArr(params.allChats), chat.rootId || chat.key),
div({ class: "chat-workspace-main" },
div({ class: "tribe-details chat-workspace-details" },
chatSide,
chatMain
)
)
)
)
: null
)
}
return template(
chat.title || i18n.chatUntitled,
@ -413,8 +533,7 @@ exports.singleChatView = async (chat, filter, messages = [], params = {}) => {
h2(i18n.chatsTitle),
p(i18n.modulesChatsDescription)
),
renderModeButtons(filter || "all"),
chatSwitcher
renderModeButtons(filter || "all")
),
section(
div({ class: "tribe-details" },

View file

@ -470,11 +470,11 @@ const CaseCard = (c) => {
select(
{ name: 'preference' },
option(
{ value: 'YES', selected: c.myPublicPreference === true },
{ value: 'YES', ...(c.myPublicPreference === true ? { selected: true } : {})},
i18n.courtsPublicPrefYes
),
option(
{ value: 'NO', selected: c.myPublicPreference === false },
{ value: 'NO', ...(c.myPublicPreference === false ? { selected: true } : {})},
i18n.courtsPublicPrefNo
)
),
@ -685,11 +685,11 @@ const MyCaseCard = (c) => {
select(
{ name: 'preference' },
option(
{ value: 'YES', selected: c.myPublicPreference === true },
{ value: 'YES', ...(c.myPublicPreference === true ? { selected: true } : {})},
i18n.courtsPublicPrefYes
),
option(
{ value: 'NO', selected: c.myPublicPreference === false },
{ value: 'NO', ...(c.myPublicPreference === false ? { selected: true } : {})},
i18n.courtsPublicPrefNo
)
),

View file

@ -110,7 +110,7 @@ module.exports = ({ i18n }) => {
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" },
{ class: "oasis-bottombar", attrs: { "aria-label": i18n.bbQuickActions || "Quick actions" } },
...slots,
editSlot,
div({ class: "bb-pair" }, ...pair)

View file

@ -3,7 +3,7 @@ const {
input, label, br, select, option, h2, textarea
} = require("../server/node_modules/hyperaxe");
const moment = require("../server/node_modules/moment");
const { template, i18n, userLink, renderSpreadButton, renderPrivacyChip, renderLifespanChip } = require('./main_views');
const { template, i18n, userLink, renderSpreadButton, renderPrivacyChip, renderLifespanChip, renderContentActions } = require('./main_views');
const { renderEncryptedChip: renderForumEncryptedChip } = require('./clearnet_view');
const { config } = require('../server/SSB_server.js');
const { renderUrl } = require('../backend/renderUrl');
@ -23,8 +23,8 @@ exports.renderForumInvitePage = (code) => {
};
const BASE_FILTERS = ['hot','all','mine','recent','top'];
const CAT_BLOCK1 = ['GENERAL','OASIS','L.A.R.P.','POLITICS','TECH'];
const CAT_BLOCK2 = ['SCIENCE','MUSIC','ART','GAMING','BOOKS','FILMS'];
const CAT_BLOCK1 = ['GENERAL','OASIS','L.A.R.P.'];
const CAT_BLOCK2 = ['POLITICS','TECH','SCIENCE','MUSIC','ART','GAMING','BOOKS','FILMS'];
const CAT_BLOCK3 = ['PHILOSOPHY','SOCIETY','PRIVACY','CYBERWARFARE','SURVIVALISM'];
const ALL_CATS = [...CAT_BLOCK1, ...CAT_BLOCK2, ...CAT_BLOCK3];
@ -99,7 +99,7 @@ const renderForumForm = () =>
label(i18n.forumTitleLabel), br(),
input({
type: 'text',
name: 'title',
name: 'title', maxlength: '100',
required: true,
placeholder: i18n.forumTitlePlaceholder
}), br(), br(),
@ -138,7 +138,7 @@ const renderThread = (nodes, level = 0, forumId) => {
userLink(m.author),
div({ class: 'comment-votes' },
span({ class: 'votes-count' }, `▲: ${m.positiveVotes || 0}`),
span({ class: 'votes-count', style: 'margin-left:12px;' },
span({ class: 'votes-count votes-count-negative' },
`▼: ${m.negativeVotes || 0}`)
)
),
@ -190,6 +190,9 @@ const renderForumList = (forums, currentFilter, spreadMap = new Map()) => {
renderVotes(f.key, f.score, f.key)
),
div({ class: 'forum-main-col' },
div({ class: 'card-header activity-card-header' },
renderContentActions(f.key, `/forum/${encodeURIComponent(f.key)}`, { spread: spreadMap.get(f.key) || null, author: f.author, favKind: 'forum', isFavorite: f.isFavorite, reportTitle: f.title })
),
div({ class: 'forum-header-row' },
a({
class: 'forum-category',
@ -210,20 +213,13 @@ const renderForumList = (forums, currentFilter, spreadMap = new Map()) => {
div({ class: 'forum-meta' },
span({ class: 'forum-positive-votes' },
`▲: ${f.positiveVotes || 0}`),
span({ class: 'forum-negative-votes', style: 'margin-left:12px;' },
span({ class: 'forum-negative-votes' },
`▼: ${f.negativeVotes || 0}`),
span({ class: 'forum-participants' },
`${i18n.forumParticipants.toUpperCase()}: ${f.participants?.length || 1}`),
span({ class: 'forum-messages' },
`${i18n.forumMessages.toUpperCase()}: ${(f.messagesCount || 1) - 1}`),
form({ method: 'GET', action: `/forum/${encodeURIComponent(f.key)}`, class: 'visit-forum-form' },
button({ type: 'submit', class: 'filter-btn' }, i18n.forumVisitButton)
)
`${i18n.forumMessages.toUpperCase()}: ${(f.messagesCount || 1) - 1}`)
),
(() => {
const btn = renderSpreadButton(f.key, spreadMap.get(f.key));
return btn ? div({ class: 'card-spread-left' }, btn) : null;
})(),
div({ class: 'forum-footer' },
span({ class: 'date-link' },
`${moment(f.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed}`),
@ -253,9 +249,7 @@ exports.forumView = async (forums, currentFilter, params = {}) => {
return template(i18n.forumTitle,
section(
div({ class: 'tags-header' },
h2(currentFilter === 'create'
? i18n.forumCreateSectionTitle
: i18n.forumTitle),
h2(i18n.forumTitle),
p(i18n.forumDescription)
),
div({ class: 'mode-buttons-cols' },
@ -271,6 +265,17 @@ exports.forumView = async (forums, currentFilter, params = {}) => {
generateFilterButtons(CAT_BLOCK3, currentFilter, '/forum', CAT_I18N_MAP_UP),
renderCreateForumButton()
),
currentFilter === 'create'
? null
: div({ class: 'filters' },
form({ method: 'GET', action: '/forum', class: 'filter-box' },
input({ type: 'hidden', name: 'filter', value: currentFilter || 'all' }),
input({ type: 'text', name: 'q', value: params.q || '', placeholder: i18n.forumSearchPlaceholder, class: 'filter-box__input' }),
div({ class: 'filter-box__controls' },
button({ type: 'submit', class: 'filter-box__button' }, i18n.searchButton)
)
)
),
currentFilter === 'create'
? renderForumForm()
: renderForumList(
@ -282,20 +287,8 @@ exports.forumView = async (forums, currentFilter, params = {}) => {
);
};
exports.singleForumView = async (forum, messagesData, currentFilter, topic = '') => {
exports.singleForumView = async (forum, messagesData, currentFilter) => {
const CAT_I18N_MAP_UP = ALL_CATS.reduce((m,c)=>{ m[c]=(catLabel(c)||c).toUpperCase(); return m; },{});
// móvil: subtopics — etiqueta opcional en los mensajes raíz → tira de pestañas + filtro por ?topic=
const forumMobile = process.env.OASIS_MOBILE === '1';
const rootMsgs = Array.isArray(messagesData.messages) ? messagesData.messages : [];
const forumTopics = forumMobile ? [...new Set(rootMsgs.map(m => String(m.subtopic || '').trim()).filter(Boolean))] : [];
const activeTopic = forumMobile ? String(topic || '').trim() : '';
const shownMsgs = activeTopic ? rootMsgs.filter(m => String(m.subtopic || '').trim() === activeTopic) : rootMsgs;
const topicStrip = (forumMobile && forumTopics.length)
? div({ class: 'forum-topics' },
a({ class: activeTopic ? 'forum-topic-chip' : 'forum-topic-chip active', href: `/forum/${encodeURIComponent(forum.key)}` }, i18n.menuAll || 'All'),
...forumTopics.map(t => a({ class: t === activeTopic ? 'forum-topic-chip active' : 'forum-topic-chip', href: `/forum/${encodeURIComponent(forum.key)}?topic=${encodeURIComponent(t)}` }, t))
)
: null;
return template(forum.title,
section(
div({ class: 'tags-header' },
@ -317,23 +310,22 @@ exports.singleForumView = async (forum, messagesData, currentFilter, topic = '')
)
),
div({ class: 'forum-thread-container' },
topicStrip,
div({
class: 'forum-card forum-thread-header',
style: 'display:flex;align-items:flex-start;'
},
div({
class: 'root-vote-col',
style: 'width:60px;text-align:center;'
}, renderVotes(
div({ class: 'forum-card forum-thread-header' },
div({ class: 'root-vote-col' }, renderVotes(
forum.key,
messagesData.totalScore,
forum.key
)),
div({
class: 'forum-main-col',
style: 'flex:1;padding-left:10px;'
},
div({ class: 'forum-main-col' },
div({ class: 'card-header activity-card-header' },
renderContentActions(forum.key, null, {
author: forum.author,
favKind: 'forum',
isFavorite: forum.isFavorite,
spread: null,
reportTitle: forum.title
})
),
div({ class: 'forum-header-row' },
a({
class: 'forum-category',
@ -376,21 +368,15 @@ exports.singleForumView = async (forum, messagesData, currentFilter, topic = '')
innerHTML: sanitizeHtml(renderTextWithStyles(forum.text || ''))
}),
div({ class: 'forum-meta' },
span({ class: 'votes-count' },
span({ class: 'forum-positive-votes' },
`▲: ${messagesData.positiveVotes}`),
span({
class: 'votes-count',
style: 'margin-left:12px;'
}, `▼: ${messagesData.negativeVotes}`),
span({ class: 'forum-negative-votes' },
`▼: ${messagesData.negativeVotes}`),
span({ class: 'forum-participants' },
`${i18n.forumParticipants.toUpperCase()}: ${forum.participants?.length || 1}`),
span({ class: 'forum-messages' },
`${i18n.forumMessages.toUpperCase()}: ${messagesData.total}`)
),
(() => {
const btn = renderSpreadButton(forum.key);
return btn ? div({ class: 'card-spread-left' }, btn) : null;
})(),
div({ class: 'forum-footer' },
span({ class: 'date-link' },
`${moment(forum.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed}`),
@ -398,31 +384,23 @@ exports.singleForumView = async (forum, messagesData, currentFilter, topic = '')
)
)
),
div({
class: 'new-message-wrapper',
style: 'margin-top:12px;'
},
div({ class: 'new-message-wrapper' },
form({
method: 'POST',
action: `/forum/${encodeURIComponent(forum.key)}/message`,
class: 'new-message-form'
},
forumMobile ? input({ type: 'text', name: 'subtopic', value: activeTopic, placeholder: 'Topic (optional)', class: 'forum-topic-input' }) : null,
textarea({
name: 'message',
rows: 4,
required: true,
placeholder: i18n.forumMessagePlaceholder,
style: 'width:100%;'
class: 'new-message-textarea'
}), br(),
button({
type: 'submit',
class: 'forum-send-btn',
style: 'margin-top:4px;'
}, i18n.forumSendButton)
button({ type: 'submit', class: 'forum-send-btn' }, i18n.forumSendButton)
)
),
...renderThread(shownMsgs, 0, forum.key)
...renderThread(messagesData.messages, 0, forum.key)
)
);
};

View file

@ -54,7 +54,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
contentTypes.map(type =>
option({
value: type === 'all' ? "" : type,
selected: (types.length === 0 && type === 'all') || types.includes(type)
...((types.length === 0 && type === 'all') || types.includes(type) ? { selected: true } : {})
}, i18n[type + "Label"] || type.toUpperCase())
)
);
@ -89,6 +89,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
case 'market': return `/market/${encodeURIComponent(contentId)}`;
case 'report': return `/reports/${encodeURIComponent(contentId)}`;
case 'project': return `/projects/${encodeURIComponent(contentId)}`;
case 'housing': return `/housing/${encodeURIComponent(contentId)}`;
case 'job': return `/jobs/${encodeURIComponent(contentId)}`;
case 'industry': return `/industry/${encodeURIComponent(contentId)}`;
case 'industryBlueprint': return `/industry/blueprint/${encodeURIComponent(contentId)}`;
@ -444,6 +445,14 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
a({ href: `https://ecoin.03c8.net/blockexplorer/search?q=${content.txid}`, target: '_blank' }, content.txid)
) : null
);
case 'housing':
return div({ class: 'search-housing' },
content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title)) : null,
content.housing_type ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.housingType + ':'), span({ class: 'card-value' }, String(content.housing_type).toUpperCase())) : null,
content.place ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.housingPlace + ':'), span({ class: 'card-value' }, content.place)) : null,
String(content.housing_type || '').toLowerCase() !== 'couchsurfing' && content.price ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.housingPrice + ':'), span({ class: 'card-value' }, `${content.price} ECO`)) : null,
content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.housingStatus + ':'), span({ class: 'card-value' }, String(content.status).toUpperCase())) : null
);
case 'job':
return div({ class: 'search-job' },
content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title)) : null,