OASIS_MOBILE/src/views/forum_view.js
s1to ed9f01e8e6 [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.
2026-08-05 23:36:18 +02:00

429 lines
17 KiB
JavaScript

const {
div, a, span, form, button, section, p,
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 { renderEncryptedChip: renderForumEncryptedChip } = require('./clearnet_view');
const { config } = require('../server/SSB_server.js');
const { renderUrl } = require('../backend/renderUrl');
const { renderTextWithStyles } = require('../backend/renderTextWithStyles');
const { sanitizeHtml } = require('../backend/sanitizeHtml');
const userId = config.keys.id;
exports.renderForumInvitePage = (code) => {
const pageContent = div({ class: 'invite-page' },
h2(i18n.tribeInviteCodeText, code),
form({ method: 'GET', action: '/forum' },
button({ type: 'submit', class: 'filter-btn' }, i18n.walletBack)
)
);
return template(i18n.invitesForumsTitle || 'Forums', section(pageContent));
};
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_BLOCK3 = ['PHILOSOPHY','SOCIETY','PRIVACY','CYBERWARFARE','SURVIVALISM'];
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 Z = 1.96;
function wilsonScore(pos, neg) {
const n = (pos||0)+(neg||0);
if (n === 0) return 0;
const phat = pos / n, z2 = Z * Z;
return (phat + z2/(2*n) - Z*Math.sqrt((phat*(1-phat)+z2/(4*n))/n)) / (1+z2/n);
}
function getFilteredForums(filter, forums) {
const now = Date.now();
if (filter === 'mine') return forums.filter(f => f.author === userId);
if (filter === 'recent') return forums.filter(f => new Date(f.createdAt).getTime() >= now - 86400000);
if (filter === 'top') return forums.slice().sort((a,b) => b.score - a.score);
if (filter === 'hot') return forums
.filter(f => new Date(f.createdAt).getTime() >= now - 86400000)
.sort((a,b) => b.score - a.score);
if (ALL_CATS.includes(filter))
return forums.filter(f => f.category === filter);
return forums;
}
const generateFilterButtons = (filters, currentFilter, action, i18nMap = {}) =>
div({ class: 'filter-group' },
filters.map(mode =>
form({ method: 'GET', action },
input({ type: 'hidden', name: 'filter', value: mode }),
button({ type: 'submit', class: currentFilter === mode ? 'filter-btn active' : 'filter-btn' },
String(i18nMap[mode] || mode).toUpperCase()
)
)
)
);
const renderCreateForumButton = () =>
div({ class: 'forum-create-col' },
form({ method: 'GET', action: '/forum' },
button({ type: 'submit', name: 'filter', value: 'create', class: 'create-button' },
i18n.forumCreateButton
)
)
);
const renderVotes = (target, score, forumId) =>
div({ class: 'forum-score-box' },
form({ method: 'POST', action: `/forum/${encodeURIComponent(forumId)}/vote`, class: 'forum-score-form' },
button({ name: 'value', value: 1, class: 'score-btn' }, '▲'),
div({ class: 'score-total' }, String(score || 0)),
button({ name: 'value', value: -1, class: 'score-btn' }, '▼'),
input({ type: 'hidden', name: 'target', value: target }),
input({ type: 'hidden', name: 'forumId', value: forumId })
)
);
const renderForumForm = () =>
div({ class: 'forum-form' },
form({ action: '/forum/create', method: 'POST' },
label(i18n.forumCategoryLabel), br(),
select({ name: 'category', required: true },
ALL_CATS.map(cat => option({ value: cat }, catLabel(cat)))
), br(), br(),
label(i18n.eventPrivacyLabel), br(),
select({ name: 'isPublic', id: 'isPublic' },
option({ value: 'public', selected: true }, i18n.eventPublic),
option({ value: 'private' }, i18n.eventPrivate)
), br(), br(),
label(i18n.forumTitleLabel), br(),
input({
type: 'text',
name: 'title',
required: true,
placeholder: i18n.forumTitlePlaceholder
}), br(), br(),
label(i18n.forumMessageLabel), br(),
textarea({
name: 'text',
required: true,
rows: 4,
placeholder: i18n.forumMessagePlaceholder
}), br(), br(),
button({ type: 'submit' }, i18n.forumCreateButton)
)
);
const renderThread = (nodes, level = 0, forumId) => {
if (!Array.isArray(nodes)) return [];
return [...nodes]
.filter(m => (m && m.text && String(m.text).trim()) || (m && Array.isArray(m.children) && m.children.length))
.sort((a, b) =>
wilsonScore(b.positiveVotes, b.negativeVotes)
- wilsonScore(a.positiveVotes, a.negativeVotes)
)
.flatMap((m, i) => {
const isTopLevelWinner = level === 0 && i === 0;
const classList = [
'forum-comment',
`level-${level}`,
isTopLevelWinner ? 'highlighted-reply' : ''
].filter(Boolean).join(' ');
const commentBox = div(
{ class: classList },
div({ class: 'comment-header' },
span({ class: 'date-link' },
`${moment(m.timestamp).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed}`),
userLink(m.author),
div({ class: 'comment-votes' },
span({ class: 'votes-count' }, `▲: ${m.positiveVotes || 0}`),
span({ class: 'votes-count', style: 'margin-left:12px;' },
`▼: ${m.negativeVotes || 0}`)
)
),
div({ class: 'comment-body-row' },
div({ class: 'comment-vote-col' },
renderVotes(m.key, m.score, forumId)
),
div({ class: 'comment-text-col' },
div(
...(m.text || '').split('\n')
.map(l => l.trim())
.filter(l => l)
.map(l => p(...renderUrl(l)))
)
)
),
div({ class: 'new-reply' },
form({
method: 'POST',
action: `/forum/${encodeURIComponent(forumId)}/message`,
class: 'comment-form'
},
input({ type: 'hidden', name: 'parentId', value: m.key }),
textarea({
name: 'message',
rows: 2,
required: true,
placeholder: i18n.forumMessagePlaceholder,
class: 'comment-textarea'
}),
button({ type: 'submit', class: 'forum-send-btn' }, 'Reply')
)
)
);
return [ commentBox, ...renderThread(m.children || [], level + 1, forumId) ];
});
};
const renderForumList = (forums, currentFilter, spreadMap = new Map()) => {
const visibleForums = (Array.isArray(forums) ? forums : []).filter(f =>
(f && f.title && String(f.title).trim()) || (f && f.text && String(f.text).trim())
)
return div({ class: 'forum-list' },
visibleForums.length
? visibleForums.map(f =>
div({ class: 'forum-card' },
div({ class: 'forum-score-col' },
renderVotes(f.key, f.score, f.key)
),
div({ class: 'forum-main-col' },
div({ class: 'forum-header-row' },
a({
class: 'forum-category',
href: `/forum?filter=${encodeURIComponent(f.category)}`
}, `[${catLabel(f.category)}]`),
a({
class: 'forum-title',
href: `/forum/${encodeURIComponent(f.key)}`
}, f.title),
f.isPrivate ? renderPrivacyChip(true, i18n) : null,
f.isPrivate ? renderForumEncryptedChip(i18n) : null,
renderLifespanChip(f.lifetime, i18n)
),
div({
class: 'forum-body',
innerHTML: sanitizeHtml(renderTextWithStyles(f.text || ''))
}),
div({ class: 'forum-meta' },
span({ class: 'forum-positive-votes' },
`▲: ${f.positiveVotes || 0}`),
span({ class: 'forum-negative-votes', style: 'margin-left:12px;' },
`▼: ${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)
)
),
(() => {
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}`),
userLink(f.author)
),
currentFilter === 'mine' && f.author === userId
? div({ class: 'forum-owner-actions' },
form({
method: 'POST',
action: `/forum/delete/${encodeURIComponent(f.key)}`,
class: 'forum-delete-form'
},
button({ type: 'submit', class: 'delete-btn' },
i18n.forumDeleteButton)
)
)
: null
)
)
)
: p(i18n.noForums)
);
}
exports.forumView = async (forums, currentFilter, params = {}) => {
const CAT_I18N_MAP_UP = ALL_CATS.reduce((m,c)=>{ m[c]=(catLabel(c)||c).toUpperCase(); return m; },{});
return template(i18n.forumTitle,
section(
div({ class: 'tags-header' },
h2(currentFilter === 'create'
? i18n.forumCreateSectionTitle
: i18n.forumTitle),
p(i18n.forumDescription)
),
div({ class: 'mode-buttons-cols' },
generateFilterButtons(BASE_FILTERS, currentFilter, '/forum', {
hot: i18n.forumFilterHot,
all: i18n.forumFilterAll,
mine: i18n.forumFilterMine,
recent: i18n.forumFilterRecent,
top: i18n.forumFilterTop
}),
generateFilterButtons(CAT_BLOCK1, currentFilter, '/forum', CAT_I18N_MAP_UP),
generateFilterButtons(CAT_BLOCK2, currentFilter, '/forum', CAT_I18N_MAP_UP),
generateFilterButtons(CAT_BLOCK3, currentFilter, '/forum', CAT_I18N_MAP_UP),
renderCreateForumButton()
),
currentFilter === 'create'
? renderForumForm()
: renderForumList(
getFilteredForums(currentFilter || 'all', forums),
currentFilter,
params.spreadMap
)
)
);
};
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' },
h2(i18n.forumTitle),
p(i18n.forumDescription)
),
div({ class: 'mode-buttons-cols' },
generateFilterButtons(BASE_FILTERS, currentFilter, '/forum', {
hot: i18n.forumFilterHot,
all: i18n.forumFilterAll,
mine: i18n.forumFilterMine,
recent: i18n.forumFilterRecent,
top: i18n.forumFilterTop
}),
generateFilterButtons(CAT_BLOCK1, currentFilter, '/forum', CAT_I18N_MAP_UP),
generateFilterButtons(CAT_BLOCK2, currentFilter, '/forum', CAT_I18N_MAP_UP),
generateFilterButtons(CAT_BLOCK3, currentFilter, '/forum', CAT_I18N_MAP_UP),
renderCreateForumButton()
)
),
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(
forum.key,
messagesData.totalScore,
forum.key
)),
div({
class: 'forum-main-col',
style: 'flex:1;padding-left:10px;'
},
div({ class: 'forum-header-row' },
a({
class: 'forum-category',
href: `/forum?filter=${encodeURIComponent(forum.category)}`
}, `[${catLabel(forum.category)}]`),
a({
class: 'forum-title',
href: `/forum/${encodeURIComponent(forum.key)}`
}, forum.title),
forum.isPrivate ? renderPrivacyChip(true, i18n) : null,
forum.isPrivate ? renderForumEncryptedChip(i18n) : null,
renderLifespanChip(forum.lifetime, i18n),
(forum.isPrivate && forum.author === userId)
? form({ method: 'POST', action: `/forum/generate-invite/${encodeURIComponent(forum.key)}`, class: 'forum-invite-form' },
button({ type: 'submit', class: 'tribe-action-btn' }, i18n.tribeGenerateInvite))
: null,
(forum.isPrivate && forum.author === userId && !forum.openInviteCode)
? form({ method: 'POST', action: `/forum/open-invite/create/${encodeURIComponent(forum.key)}`, class: 'forum-invite-form' },
button({ type: 'submit', class: 'tribe-action-btn' }, i18n.tribeOpenInvitation))
: null,
(forum.isPrivate && forum.author === userId && forum.openInviteCode)
? span({ class: 'tribe-open-invite' },
span({ class: 'card-label' }, i18n.tribeInviteCodeText),
span({ class: 'tribe-open-invite-code' }, forum.openInviteCode))
: null,
(forum.isPrivate && forum.author === userId && forum.openInviteCode)
? form({ method: 'POST', action: `/forum/open-invite/remove/${encodeURIComponent(forum.key)}`, class: 'forum-invite-form' },
button({ type: 'submit', class: 'tribe-action-btn danger-btn' }, i18n.tribeRemoveInvitation))
: null,
(forum.isPrivate && forum.author !== userId)
? a({ class: 'tribe-action-btn', href: '/invites#invites-forums' }, i18n.tribeEnterInvite)
: null,
(forum.author === userId)
? form({ method: 'POST', action: `/forum/delete/${encodeURIComponent(forum.key)}`, class: 'forum-delete-form' },
button({ type: 'submit', class: 'tribe-action-btn danger-btn' }, i18n.forumDeleteButton))
: null
),
div({
class: 'forum-body',
innerHTML: sanitizeHtml(renderTextWithStyles(forum.text || ''))
}),
div({ class: 'forum-meta' },
span({ class: 'votes-count' },
`▲: ${messagesData.positiveVotes}`),
span({
class: 'votes-count',
style: 'margin-left:12px;'
}, `▼: ${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}`),
userLink(forum.author)
)
)
),
div({
class: 'new-message-wrapper',
style: 'margin-top:12px;'
},
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%;'
}), br(),
button({
type: 'submit',
class: 'forum-send-btn',
style: 'margin-top:4px;'
}, i18n.forumSendButton)
)
),
...renderThread(shownMsgs, 0, forum.key)
)
);
};