const { div, a, span, form, button, section, p, ul, li, input, label, br, select, option, h2, textarea, details, summary } = require("../server/node_modules/hyperaxe"); const RAW_HTML = Symbol.for('oasis.rawHtml'); const moment = require("../server/node_modules/moment"); const { template, i18n, userLink, renderSpreadButton, renderPrivacyChip, renderLifespanChip, renderContentActions, renderInviteQrCard, renderStateChip, renderSubscriptionBox, renderModuleStats, moduleIsEmpty, paged } = require('./main_views'); const { renderEncryptedChip: renderForumEncryptedChip, renderTribeOriginChip } = require('./clearnet_view'); const { config } = require('../server/SSB_server.js'); const { renderStyledText } = require('../backend/renderStyledText'); const { renderStyledHtml } = require('../backend/renderStyledText'); const { sanitizeHtml } = require('../backend/sanitizeHtml'); const { TEXT_CAP } = require('../backend/long_text'); 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','recent','mine','top']; 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]; const catKey = (c) => 'forumCat' + String(c || '').replace(/\./g,'').replace(/[\s-]/g,'').toUpperCase(); const catLabel = (c) => i18n[catKey(c)] || c; const tribeCatLabel = (c) => i18n['tribeForumCat' + String(c || '').charAt(0) + String(c || '').slice(1).toLowerCase()] || 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.filter(f => !f.tribeOrigin).sort((a,b) => b.score - a.score); if (filter === 'hot') return forums .filter(f => !f.tribeOrigin && 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 = (draft = null) => div({ class: 'forum-form' }, form({ action: '/forum/create', method: 'POST', enctype: 'multipart/form-data' }, label(i18n.forumCategoryLabel), br(), select({ name: 'category', required: true }, ALL_CATS.map(cat => option({ value: cat, ...(draft && draft.category === cat ? { selected: true } : {}) }, catLabel(cat))) ), br(), br(), label(i18n.eventPrivacyLabel), br(), select({ name: 'isPublic', id: 'isPublic' }, option({ value: 'public', ...(draft && draft.isPrivate ? {} : { selected: true }) }, i18n.eventPublic), option({ value: 'private', ...(draft && draft.isPrivate ? { selected: true } : {}) }, i18n.eventPrivate) ), br(), br(), label(i18n.forumTitleLabel), br(), input({ type: 'text', name: 'title', maxlength: '100', required: true, placeholder: i18n.forumTitlePlaceholder, value: draft ? String(draft.title || '') : '' }), br(), br(), label(i18n.forumMessageLabel), br(), textarea({ maxlength: String(TEXT_CAP), name: 'text', required: true, rows: 8, placeholder: i18n.forumMessagePlaceholder }, draft ? String(draft.text || '') : ''), br(), br(), label(i18n.uploadMedia), br(), input({ type: 'file', name: 'blob' }), br(), br(), button({ type: 'submit' }, i18n.forumCreateButton) ) ); const bySupport = (a, b) => wilsonScore(b.positiveVotes, b.negativeVotes) - wilsonScore(a.positiveVotes, a.negativeVotes); const bestReplyKey = (nodes) => { const flat = []; const walk = (list) => { for (const m of Array.isArray(list) ? list : []) { if (m && m.text && String(m.text).trim()) flat.push(m); walk(m && m.children); } }; walk(nodes); if (!flat.length) return null; const sorted = flat.slice().sort(bySupport); const top = sorted[0]; if (wilsonScore(top.positiveVotes, top.negativeVotes) > 0) return top.key; const first = (Array.isArray(nodes) ? nodes : []).filter(m => m && m.text && String(m.text).trim()).sort(bySupport)[0]; return first ? first.key : null; }; const renderThread = (nodes, level = 0, forumId, bestKey = null) => { 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(bySupport) .flatMap((m) => { const classList = [ 'forum-comment', `level-${level}`, bestKey && m.key === bestKey ? 'highlighted-reply' : '' ].filter(Boolean).join(' '); const commentBox = div( { class: classList, id: `msg-${String(m.key || '').replace(/[^a-zA-Z0-9]/g, '')}` }, div({ class: 'comment-header' }, span({ class: 'date-link' }, `${moment(m.timestamp).format('YYYY/MM/DD HH:mm')}`), userLink(m.author), div({ class: 'comment-votes' }, span({ class: 'votes-count' }, `▲: ${m.positiveVotes || 0}`), span({ class: 'votes-count votes-count-negative' }, `▼: ${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(...renderStyledText(l, { zoomImages: true }))) ) ) ), details({ class: 'new-reply forum-inline-reply', ...(draftFor(m.key) ? { open: true } : {}) }, summary({ class: 'forum-inline-reply-toggle' }, i18n.tribeForumReply), form({ method: 'POST', action: `/forum/${encodeURIComponent(forumId)}/message`, class: 'comment-form', enctype: 'multipart/form-data' }, input({ type: 'hidden', name: 'parentId', value: m.key }), textarea({ maxlength: String(TEXT_CAP), name: 'message', rows: 4, required: true, placeholder: i18n.forumMessagePlaceholder, class: 'comment-textarea' }, draftFor(m.key)), div({ class: 'comment-file-upload' }, input({ type: 'file', name: 'blob' })), button({ type: 'submit', class: 'forum-send-btn' }, i18n.tribeForumReply) ) ) ); return [ commentBox, ...renderThread(m.children || [], level + 1, forumId, bestKey) ]; }); }; const forumHref = (f) => `/forum/${encodeURIComponent(f.key)}`; const forumCounts = (f) => span({ class: 'card-label activity-update-counts mailing-counts' }, `👥: ${f.participants?.length || 1} · 💬 ${Math.max(0, (f.messagesCount || 1) - 1)} · ▲ ${f.positiveVotes || 0} · ▼ ${f.negativeVotes || 0}`); 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()) ) if (!visibleForums.length) return div({ class: 'tribe-grid' }, p(i18n.noForums)); return ul({ class: 'mailing-archive' }, ...paged(visibleForums).map(f => { if (f.tribeOrigin) { return li({ class: 'mailing-archive-item' }, div({ class: 'emergency-update-head mailing-archive-head' }, div({ class: 'mailing-archive-meta' }, a({ href: f.tribeOrigin.href, class: 'user-link' }, f.title || '—'), ALL_CATS.includes(f.category) ? a({ class: 'forum-category', href: `/forum?filter=${encodeURIComponent(f.category)}` }, `[${catLabel(f.category)}]`) : (f.category ? span({ class: 'forum-category' }, `[${tribeCatLabel(f.category)}]`) : null), span({ class: 'card-label activity-update-counts mailing-counts' }, `👥: ${f.participants?.length || 1} · 💬 ${Math.max(0, (f.messagesCount || 1) - 1)}`), renderLifespanChip(f.lifetime, i18n), renderTribeOriginChip(f.tribeOrigin) ) ) ); } return li({ class: 'mailing-archive-item' }, div({ class: 'emergency-update-head mailing-archive-head' }, div({ class: 'mailing-archive-meta' }, a({ href: forumHref(f), class: 'user-link' }, f.title || '—'), a({ class: 'forum-category', href: `/forum?filter=${encodeURIComponent(f.category)}` }, `[${catLabel(f.category)}]`), forumCounts(f), f.isPrivate ? renderPrivacyChip(true, i18n) : null, f.isPrivate ? renderForumEncryptedChip(i18n) : null, renderLifespanChip(f.lifetime, i18n), f.subscriptionIn === true ? renderStateChip('mutuals', '✉', i18n.subscriptionOn) : (f.subscriptionIn === false ? renderStateChip('closed', '✉', i18n.subscriptionOff) : null) ), renderContentActions(f.key, forumHref(f), { spread: spreadMap.get(f.key) || null, author: f.author, favKind: 'forum', isFavorite: f.isFavorite, reportTitle: f.title, deleteAction: f.author === userId ? `/forum/delete/${encodeURIComponent(f.key)}` : null }) ) ); }) ); } exports.forumView = async (forums, currentFilter, params = {}) => { const CAT_I18N_MAP_UP = ALL_CATS.reduce((m,c)=>{ m[c]=(catLabel(c)||c).toUpperCase(); return m; },{}); const emptyModForum = moduleIsEmpty(getFilteredForums(currentFilter || 'all', forums), currentFilter || 'all', 'all', params.q); const censusForums = Array.isArray(params.censusList) ? params.censusList : (Array.isArray(forums) ? forums : []); const presentCats = new Set(censusForums.map(f => f && f.category).filter(Boolean)); const baseChipVisible = (mode) => { if (mode === currentFilter || mode === 'all') return true; if (mode === 'mine') return censusForums.some(f => String(f.author) === String(userId)); if (mode === 'recent') return censusForums.length > 0; return censusForums.length > 0; }; return template(i18n.forumTitle, section( div({ class: 'tags-header module-header-line' }, h2(i18n.forumTitle), p(i18n.forumDescription) ), div({ class: 'mode-buttons-row' }, ...(emptyModForum ? [] : [ generateFilterButtons(BASE_FILTERS.filter(baseChipVisible), currentFilter, '/forum', { hot: i18n.forumFilterHot, all: i18n.forumFilterAll, mine: i18n.forumFilterMine, recent: i18n.forumFilterRecent, top: i18n.forumFilterTop }), generateFilterButtons(CAT_BLOCK1.filter(c => presentCats.has(c) || c === currentFilter), currentFilter, '/forum', CAT_I18N_MAP_UP), generateFilterButtons(CAT_BLOCK2.filter(c => presentCats.has(c) || c === currentFilter), currentFilter, '/forum', CAT_I18N_MAP_UP), generateFilterButtons(CAT_BLOCK3.filter(c => presentCats.has(c) || c === currentFilter), currentFilter, '/forum', CAT_I18N_MAP_UP) ]), renderCreateForumButton() ), currentFilter === 'create' ? null : emptyModForum ? null : div({ class: 'filters activity-filter-chips activity-toolbar-row' }, renderModuleStats(getFilteredForums(currentFilter || 'all', forums).length), 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(params.draft || null) : renderForumList( getFilteredForums(currentFilter || 'all', forums), currentFilter, params.spreadMap ) ) ); }; let replyDraft = null; const draftFor = (parentId) => replyDraft && String(replyDraft.parentId || '') === String(parentId || '') ? String(replyDraft.message || '') : ''; exports.singleForumView = async (forum, messagesData, currentFilter, replyId = null, params = {}) => { replyDraft = params.draft || null; const CAT_I18N_MAP_UP = ALL_CATS.reduce((m,c)=>{ m[c]=(catLabel(c)||c).toUpperCase(); return m; },{}); const isForumOwner = String(forum.author) === String(userId); const sharesForum = isForumOwner || (forum.participants || []).includes(userId); const forumSubIn = isForumOwner || (forum.subscription && forum.subscription.subscribed === true); return template(forum.title, section( div({ class: 'tags-header module-header-line' }, h2(i18n.forumTitle), p(i18n.forumDescription) ), div({ class: 'mode-buttons-row' }, 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' }, div({ class: 'forum-card forum-thread-header' }, div({ class: 'root-vote-col' }, renderVotes( forum.key, messagesData.totalScore, forum.key )), div({ class: 'forum-main-col' }, div({ class: 'card-header activity-card-header' }, renderContentActions(forum.key, forumHref(forum), { author: forum.author, favKind: 'forum', isFavorite: forum.isFavorite, spread: params.spreads || null, reportTitle: forum.title, deleteAction: isForumOwner ? `/forum/delete/${encodeURIComponent(forum.key)}` : null }) ), 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.subscription && sharesForum) ? renderStateChip(forumSubIn ? 'mutuals' : 'closed', '✉', forumSubIn ? i18n.subscriptionOn : i18n.subscriptionOff) : null, (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) ? div({ class: 'tribe-open-invite' }, span({ class: 'card-label' }, i18n.tribeInviteCodeText), span({ class: 'tribe-open-invite-code' }, forum.openInviteCode), renderInviteQrCard({ qrDataUrl: `/qr-invite-code/forum/${encodeURIComponent(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?filter=others' }, i18n.tribeEnterInvite) : null ), (forum.subscription && sharesForum) ? renderSubscriptionBox({ target: forum.rootId || forum.key, scope: 'forum', subscribed: forum.subscription.subscribed === true, count: forum.subscription.count, isOwner: isForumOwner, returnTo: `/forum/${encodeURIComponent(forum.key)}` }) : null, div({ class: 'forum-body', [RAW_HTML]: sanitizeHtml(renderStyledHtml(forum.text || '')) }), div({ class: 'forum-meta' }, span({ class: 'forum-positive-votes' }, `▲: ${messagesData.positiveVotes}`), 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}`) ), div({ class: 'forum-footer' }, span({ class: 'date-link' }, `${moment(forum.createdAt).format('YYYY/MM/DD HH:mm')}`), userLink(forum.author) ) ) ), div({ class: 'new-message-wrapper' }, form({ method: 'POST', action: `/forum/${encodeURIComponent(forum.key)}/message`, class: 'new-message-form', enctype: 'multipart/form-data' }, textarea({ maxlength: String(TEXT_CAP), name: 'message', rows: 8, required: true, placeholder: i18n.forumMessagePlaceholder, class: 'new-message-textarea' }, draftFor(null)), br(), label(i18n.uploadMedia), br(), input({ type: 'file', name: 'blob' }), br(), button({ type: 'submit', class: 'forum-send-btn' }, i18n.forumSendButton) ) ), ...renderThread(messagesData.messages, 0, forum.key, bestReplyKey(messagesData.messages)) ) ); };