[0.9.1] Mobile: fixed topbar, global hive, per-message chat reply/switcher, forum subtopics, square module grid

- Topbar: single fixed line [logo][Personal][Community][avatar] in one dark
  tone (#121212) with gold accents only; the hive hexagons scroll below it.
- Hive: Personal/Community filter the home hive from any page; opening a
  category shows its modules as a 2x2 grid of square buttons (a lone odd
  button is centered instead of left-aligned).
- Chats: per-message reply (?replyTo=), chat switcher strip and quote banner;
  the message rate limit is lifted on mobile.
- Forums: optional subtopics (?topic=) with threaded rendering.
- Bottombar: fixed Inbox - PM - Write - Search - Graphos - Peers.
This commit is contained in:
s1to 2026-08-05 23:36:18 +02:00
parent 2b2c65fc7a
commit ed9f01e8e6
8 changed files with 280 additions and 129 deletions

View file

@ -1993,7 +1993,6 @@ exports.activityView = (actions, filter, userId, q = '', extras = {}) => {
h2(i18n.activityList),
p(desc)
),
renderHiveNav(),
input({ type: 'checkbox', id: 'activity-filter-toggle', class: 'activity-filter-toggle' }),
label({ for: 'activity-filter-toggle', class: 'activity-filter-btn' },
span(i18n.filter || 'Filter'), span({ class: 'activity-filter-caret' }, '▾')),

View file

@ -140,7 +140,7 @@ const renderMessageText = (text) => {
return span({ class: "chat-message-text" }, ...nodes)
}
const renderMessage = (msg, chatAuthor) => {
const renderMessage = (msg, chatAuthor, chatKey, msgById) => {
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")
@ -149,11 +149,26 @@ const renderMessage = (msg, chatAuthor) => {
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")
: 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} ]`)
span({ class: "chat-message-date" }, ` [ ${dateStr} ]`),
replyLink
),
quoteNode,
imageNode ? div({ class: "chat-message-image-wrap" }, imageNode) : null,
renderMessageText(msg.text || "")
)
@ -342,15 +357,26 @@ 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
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" },
? 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(),
@ -362,12 +388,24 @@ exports.singleChatView = async (chat, filter, messages = [], params = {}) => {
(() => {
const visible = msgList.filter(msg => (msg.text && String(msg.text).trim()) || msg.image)
return visible.length
? visible.map(msg => renderMessage(msg, chat.author))
? visible.map(msg => renderMessage(msg, chat.author, chat.key, msgById))
: p({ class: "chat-no-messages" }, i18n.chatNoMessages)
})()
)
)
// 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)))
)
: null
return template(
chat.title || i18n.chatUntitled,
section(
@ -375,7 +413,8 @@ exports.singleChatView = async (chat, filter, messages = [], params = {}) => {
h2(i18n.chatsTitle),
p(i18n.modulesChatsDescription)
),
renderModeButtons(filter || "all")
renderModeButtons(filter || "all"),
chatSwitcher
),
section(
div({ class: "tribe-details" },

View file

@ -282,8 +282,20 @@ exports.forumView = async (forums, currentFilter, params = {}) => {
);
};
exports.singleForumView = async (forum, messagesData, currentFilter) => {
exports.singleForumView = async (forum, messagesData, currentFilter, topic = '') => {
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' },
@ -305,6 +317,7 @@ exports.singleForumView = async (forum, messagesData, currentFilter) => {
)
),
div({ class: 'forum-thread-container' },
topicStrip,
div({
class: 'forum-card forum-thread-header',
style: 'display:flex;align-items:flex-start;'
@ -394,6 +407,7 @@ exports.singleForumView = async (forum, messagesData, currentFilter) => {
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,
@ -408,7 +422,7 @@ exports.singleForumView = async (forum, messagesData, currentFilter) => {
}, i18n.forumSendButton)
)
),
...renderThread(messagesData.messages, 0, forum.key)
...renderThread(shownMsgs, 0, forum.key)
)
);
};

View file

@ -1103,12 +1103,19 @@ const renderTasksLink = () => {
// === Oasis UX (portado de 0.8.7_UX): topbar + bottombar + hive ===
const renderMobileTopbar = () => div(
{ class: "oasis-mobile-topbar" },
a({ class: "omt-logo", href: "/" }, img({ src: "/assets/images/snh-oasis.jpg", alt: "Oasis" })),
a({ class: "omt-title", href: "/" }, "OASIS"),
a({ class: "omt-avatar", href: "/profile" }, img({ src: (typeof global !== "undefined" && global.__OASIS_SELF_AVATAR__) || "/assets/images/default-avatar.png", alt: i18n.profile || "Profile" }))
);
// topbar fija arriba: [logo] [Personal] [Community] [avatar] en una línea (sin título OASIS)
const renderMobileTopbar = () => {
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 },
@ -1170,26 +1177,18 @@ const renderBottomBar = () => {
(badge && badge > 0) ? span({ class: "bb-badge" }, String(badge)) : "",
span({ class: "bb-lbl" }, label)
);
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" }, pins.length < MAX ? "+" : "✎"),
span({ class: "bb-lbl" }, pins.length < MAX ? (i18n.bbAdd || "Add") : (i18n.bbManage || "Edit"))
);
const pair = [ bbLink("/peers", "⧖", i18n.peers || "Peers") ];
if (modOn('invitesMod')) pair.push(bbLink("/invites", "ꔮ", i18n.invites || "Invites"));
// barra inferior FIJA (estilo epsylon): Inbox · PM · Write · Search · Graphos · Peers
const items = [
bbLink("/inbox", "☂", "Inbox", inboxN),
bbLink("/pm", "✉", "PM"),
bbLink("/publish", "✎", "Write"),
bbLink("/search", "ꔅ", "Search"),
bbLink("/graphos", "◈", "Graphos"),
bbLink("/peers", "⧖", "Peers"),
];
return nav(
{ class: "oasis-bottombar", "aria-label": i18n.bbQuickActions || "Quick actions" },
...slots,
editSlot,
div({ class: "bb-pair" }, ...pair)
{ class: "oasis-bottombar oasis-bottombar-fixed", "aria-label": i18n.bbQuickActions || "Quick actions" },
...items
);
};
@ -1292,33 +1291,8 @@ const template = (titlePrefix, ...elements) => {
div(
{ class: uxMode === "ainav" ? "header ainav-only" : "header" },
renderMobileTopbar(),
div(
{ class: "oasis-mobile-menus" },
// enlaces (no checkboxes): funcionan en CUALQUIER página → navegan al home /activity con el filtro.
// toggle: si el filtro ya está activo, el enlace lleva a /activity (se deselecciona → todos)
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"))
),
(() => {
const aiNavOn = getConfig().modules.aiNavMod === 'on';
if (!aiNavOn && uxMode !== 'ainav') return null;
return div(
{ class: uxMode === 'ainav' ? "top-bar-center top-bar-center-ainav" : "top-bar-center" },
form(
{ method: 'POST', action: '/ai/ask', class: 'ai-ask-form' },
input({
type: 'text',
name: 'q',
class: 'ai-ask-input',
placeholder: i18n.aiNavPlaceholder || 'Where do you want to go?',
autocomplete: 'off',
maxlength: '300',
autofocus: uxMode === 'ainav' ? 'autofocus' : undefined
}),
button({ type: 'submit', class: 'ai-ask-btn' }, '➤')
)
);
})()
// hive global: los hexágonos salen en TODAS las páginas y SCROLLEAN bajo la topbar fija (antes solo en /activity)
renderHiveNav()
),
(() => {
const updateFlagPath = path.join(__dirname, '../server/.update_required');