upstream: integrar Oasis 1.3.3

Diferencia 1.3.1 -> 1.3.3 del codigo de la APK oficial aplicada a tres
bandas encima de la rama: karma en el perfil, salas con directo, fotos
selladas, visibilidad de menciones, telefono por stream en Android y
las traducciones nuevas. Sin dependencias nuevas.

OasisMobile.css sigue siendo el del fork; del oficial solo se traen al
final las reglas de las piezas nuevas (karma, directo a pantalla
completa, salas en vivo, graphos).
This commit is contained in:
oasis-bot 2026-10-19 00:30:25 +02:00
parent bc64565e9c
commit 948b6460d9
95 changed files with 3613 additions and 1356 deletions

View file

@ -837,7 +837,11 @@ function renderActionCards(actions, userId, allActions, spreadMap = new Map(), e
)
: '',
contentWarning ? h2({ class: 'content-warning' }, contentWarning) : '',
bodyNode
bodyNode,
Array.isArray(content?.tags) && content.tags.filter(Boolean).length
? div({ class: 'card-tags' }, content.tags.filter(Boolean).map(tag =>
a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)))
: ''
)
);
}
@ -1883,6 +1887,7 @@ function renderActionCards(actions, userId, allActions, spreadMap = new Map(), e
),
renderContentActions(msgId, detailHref, {
author: action.author,
contact: type === 'about',
reportTitle: (content && (content.title || content.question || content.concept || content.name)) || '',
spread: SPREADABLE_TYPES.has(type) ? (spreadMap.get(action.id) || null) : undefined,
...(type === 'torrent' || type === 'file' ? { spreadTitle: i18n.seedAction } : {}),

View file

@ -18,7 +18,7 @@ const {
const { renderCommentsSection: renderSharedCommentsSection, renderCommentsLink } = require("./comments_view");
const { renderLicenseChip, renderLicenseSelect, renderReachChip, renderClearnetSelector, renderClearnetSwitch, renderTribeOriginChip } = require('./clearnet_view');
const { clearnetItemHref, template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip, renderContentActions , renderSpreadEditWarning, renderModuleStats, moduleIsEmpty, contentDeleteAction, paged } = require("./main_views");
const { clearnetItemHref, template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip, renderContentActions, renderSpreadEditWarning, renderModuleStats, moduleIsEmpty, contentDeleteAction, paged, blobUrlOf } = require("./main_views");
const moment = require("../server/node_modules/moment");
const { config } = require("../server/SSB_server.js");
const { renderStyledText } = require("../backend/renderStyledText")
@ -61,7 +61,7 @@ const renderAudioPlayer = (audioObj, opts = {}) =>
audioObj?.url
? div(
{ class: "audio-container" },
audioHyperaxe({ controls: true, src: `/blob/${encodeURIComponent(audioObj.url)}`, preload: "metadata" }),
audioHyperaxe({ controls: true, src: blobUrlOf(audioObj.url), preload: "metadata" }),
opts.skipTranscode ? null : renderTranscodeButton(audioObj)
)
: p(i18n.audioNoFile);
@ -236,7 +236,6 @@ exports.audioView = async (audios, filter = "all", audioId = null, params = {})
{ class: "filters" },
form(
{ method: "GET", action: "/audios", class: "ui-toolbar ui-toolbar--filters" },
input({ type: "hidden", name: "q", value: q }),
input({ type: "hidden", name: "sort", value: sort }),
...(emptyMod ? [] : [
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.audioFilterAll).toUpperCase()),
@ -380,7 +379,6 @@ exports.singleAudioView = async (audioObj, filter = "all", comments = [], params
{ class: "filters" },
form(
{ method: "GET", action: "/audios", class: "ui-toolbar ui-toolbar--filters" },
input({ type: "hidden", name: "q", value: q }),
input({ type: "hidden", name: "sort", value: sort }),
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.audioFilterAll).toUpperCase()),
...(mediaChip("recent") ? [button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, String(i18n.audioFilterRecent).toUpperCase())] : []),

View file

@ -1,6 +1,6 @@
const { div, h2, h3, p, section, button, form, a, input, span, pre, table, thead, tbody, tr, td, th, br, strong, label, ul, li } = require("../server/node_modules/hyperaxe");
const RAW_HTML = Symbol.for('oasis.rawHtml');
const { template, i18n, userLink, formatCarbon, renderStateChip, renderModuleStats, renderWalletChip, paged } = require("../views/main_views");
const { template, i18n, userLink, formatCarbon, renderStateChip, renderModuleStats, renderWalletChip, paged, renderIcon } = require("../views/main_views");
const moment = require("../server/node_modules/moment");
const FILTER_LABELS = {
@ -489,7 +489,7 @@ const renderUbiPubs = (pubs, hasValidWallet) => {
userLink(p.pubId),
p.isThisNode || p.isConnected ? span({ class: "status you" }, p.isThisNode ? i18n.bankUbiPubThisNode : i18n.bankUbiPubConnected) : null,
hasValidWallet && p.address
? div({ class: "content-actions" }, form({ method: "GET", action: "/banking/fund", class: "content-action-form" }, input({ type: "hidden", name: "pub", value: p.pubId }), button({ type: "submit", class: "btn-singleview btn-donate", title: i18n.bankDonateEco }, "\u2744")))
? div({ class: "content-actions" }, form({ method: "GET", action: "/banking/fund", class: "content-action-form" }, input({ type: "hidden", name: "pub", value: p.pubId }), button({ type: "submit", class: "btn-singleview btn-donate", title: i18n.bankDonateEco }, renderIcon("donate"))))
: null
),
div({ class: "bank-summary ubi-pub-summary" },

View file

@ -1,5 +1,5 @@
const { div, h2, h3, p, section, button, form, a, input, span, pre, table, tr, td, strong } = require("../server/node_modules/hyperaxe");
const { template, i18n, userLink, paged } = require("../views/main_views");
const { template, i18n, userLink, paged, renderIcon } = require("../views/main_views");
const moment = require("../server/node_modules/moment");
const FILTER_LABELS = {
@ -175,7 +175,9 @@ const getViewDetailsAction = (type, block) => {
case 'event': return `/events/${encodeURIComponent(block.id)}`;
case 'task': return `/tasks/${encodeURIComponent(block.id)}`;
case 'about': return `/author/${encodeURIComponent(block.author)}`;
case 'post': return `/thread/${encodeURIComponent(block.id)}#${encodeURIComponent(block.id)}`;
case 'post': return block.content && typeof block.content.root === 'string'
? `/thread/${encodeURIComponent(block.content.root)}#${encodeURIComponent(block.id)}`
: `/blogs/${encodeURIComponent(block.id)}`;
case 'vote': return `/thread/${encodeURIComponent(block.content.vote.link)}#${encodeURIComponent(block.content.vote.link)}`;
case 'contact': return `/inhabitants`;
case 'pub': return `/invites`;
@ -424,7 +426,7 @@ const renderSingleBlockView = (block, filter = 'recent', userId, search = {}, vi
button({ type:'submit', class:'filter-btn' }, `← ${i18n.blockchainBack}`)
),
!block.isTombstoned && !block.isReplaced && getViewDetailsAction(block.type, block) ?
a({ href:getViewDetailsAction(block.type, block), class:'btn-singleview btn-content', title:i18n.visitContent }, '↗')
a({ href:getViewDetailsAction(block.type, block), class:'btn-singleview btn-content', title:i18n.visitContent }, renderIcon('open'))
: (block.isTombstoned || block.isReplaced) ?
div({ class: 'deleted-label' },
i18n.blockchainContentDeleted || "This content has been deleted."
@ -534,10 +536,10 @@ const renderBlockchainView = (blocks, filter, userId, search = {}, extras = {})
.map(block=>
div({ class:'block' },
div({ class:'block-buttons' },
block.restricted ? null : a({ href:`/blockexplorer/block/${encodeURIComponent(block.id)}${qs}`, class:'btn-singleview', title:i18n.blockchainDetails }, '⦿'),
block.restricted ? null : a({ href:`/blockexplorer/block/${encodeURIComponent(block.id)}${qs}&view=datagram`, class:'btn-singleview btn-datagram', title:i18n.blockchainDatagram }, '⊞'),
block.restricted ? null : a({ href:`/blockexplorer/block/${encodeURIComponent(block.id)}${qs}`, class:'btn-singleview', title:i18n.blockchainDetails }, renderIcon('block')),
block.restricted ? null : a({ href:`/blockexplorer/block/${encodeURIComponent(block.id)}${qs}&view=datagram`, class:'btn-singleview btn-datagram', title:i18n.blockchainDatagram }, renderIcon('datagram')),
!block.isTombstoned && !block.isReplaced && getViewDetailsAction(block.type, block) ?
a({ href:getViewDetailsAction(block.type, block), class:'btn-singleview btn-content', title:i18n.visitContent }, '↗')
a({ href:getViewDetailsAction(block.type, block), class:'btn-singleview btn-content', title:i18n.visitContent }, renderIcon('open'))
: (block.isTombstoned || block.isReplaced) ?
div({ class: 'deleted-label' },
i18n.blockchainContentDeleted || "This content has been deleted."

View file

@ -21,9 +21,16 @@ const FILTERS = [
const safeText = (v) => String(v == null ? "" : v).trim();
const renderBlogTags = (tags) => {
const list = (Array.isArray(tags) ? tags : []).map(t => String(t || "").trim().replace(/^#+/, "")).filter(Boolean);
return list.length
? div({ class: "card-tags" }, list.map(tag => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)))
: null;
};
const excerpt = (text, max = 420) => {
const s = safeText(text);
return s.length > max ? s.slice(0, max) + "…" : s;
return s.length > max ? s.slice(0, max).replace(/\s+$/, "") + "…" : s;
};
const renderFilterBar = (filter, q, showSearch = true, total = null, censusList = null) => {
@ -77,6 +84,7 @@ const renderBlogCard = (blog, filter, spreadInfo) => {
: null,
blog.clearnet === true ? div({ class: "card-chips-row" }, renderReachChip(true, i18n, clearnetItemHref("blog", blog.subject || blog.text, blog.id))) : null,
div({ class: "blog-card-text", [RAW_HTML]: sanitizeHtml(renderStyledHtml(excerpt(blog.text))) }),
renderBlogTags(blog.tags),
p({ class: "card-footer" },
span({ class: "date-link" }, `${moment(blog.createdAt).format("YYYY/MM/DD HH:mm")}`),
userLink(blog.author)
@ -89,13 +97,15 @@ const renderCreateForm = (draft = null) => {
const subjectValue = draft ? safeText(draft.subject) : "";
const textValue = draft ? String(draft.text || "") : "";
const allowComments = draft ? draft.allowComments !== false : true;
const tagsValue = draft ? (Array.isArray(draft.tags) ? draft.tags.join(", ") : safeText(draft.tags)) : "";
return section(
draft
? section({ class: "post-preview blog-preview" },
div({ class: "preview-content" },
h2(i18n.messagePreview),
subjectValue ? h2({ class: "tribe-card-title" }, subjectValue) : null,
div({ class: "blog-detail-text", [RAW_HTML]: sanitizeHtml(renderStyledHtml(textValue)) })
div({ class: "blog-detail-text", [RAW_HTML]: sanitizeHtml(renderStyledHtml(textValue)) }),
renderBlogTags(draft.tags)
)
)
: null,
@ -105,17 +115,19 @@ const renderCreateForm = (draft = null) => {
br(),
input({ type: "text", name: "subject", id: "subject", maxlength: "150", placeholder: i18n.contentWarningPlaceholder, value: subjectValue }),
br(),
br(),
label({ for: "text" }, i18n.blogMessage),
br(),
textarea({ required: true, name: "text", id: "text", rows: "8", class: "publish-textarea", maxlength: String(TEXT_CAP), placeholder: i18n.publishWarningPlaceholder }, textValue),
br(),
br(),
label({ for: "blob" }, i18n.blogMedia),
br(),
input({ type: "file", id: "blob", name: "blob", multiple: true, accept: "image/*,video/*,audio/*,application/pdf" }),
br(),
br(),
label({ for: "tags" }, i18n.tagsLabel || "Tags"),
br(),
input({ type: "text", name: "tags", id: "tags", placeholder: i18n.tagsPlaceholder || "", value: tagsValue }),
br(),
div({ class: "blog-allow-comments" },
input({ type: "hidden", name: "allowComments", value: "0" }),
label(
@ -206,7 +218,8 @@ exports.singleBlogView = async (blog, comments = [], params = {}) => {
const blogMain = div({ class: "tribe-main" },
div({ class: "job-section" },
div({ class: "blog-detail-text", [RAW_HTML]: sanitizeHtml(renderStyledHtml(blog.text || "")) })
div({ class: "blog-detail-text", [RAW_HTML]: sanitizeHtml(renderStyledHtml(blog.text || "")) }),
renderBlogTags(blog.tags)
),
renderEngagement(blog.id,
renderOpinionsVoting('/blogs/opinions', blog.id, blog.opinions, href, blog.opinions_inhabitants),

View file

@ -227,7 +227,6 @@ exports.bookmarkView = async (bookmarks, filter = "all", bookmarkId = null, para
{ class: "filters" },
form(
{ method: "GET", action: "/bookmarks", class: "ui-toolbar ui-toolbar--filters" },
input({ type: "hidden", name: "q", value: q }),
input({ type: "hidden", name: "sort", value: sort }),
...(emptyMod ? [] : [
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.bookmarkFilterAll).toUpperCase()),
@ -369,7 +368,6 @@ exports.singleBookmarkView = async (bookmark, filter = "all", comments = [], par
{ class: "filters" },
form(
{ method: "GET", action: "/bookmarks", class: "ui-toolbar ui-toolbar--filters" },
input({ type: "hidden", name: "q", value: q }),
input({ type: "hidden", name: "sort", value: sort }),
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.bookmarkFilterAll).toUpperCase()),
...(mediaChip("recent") ? [button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, String(i18n.bookmarkFilterRecent).toUpperCase())] : []),

View file

@ -76,7 +76,6 @@ const renderFilters = (filter, q, census, emptyMod) =>
section(
div({ class: "filters" },
form({ method: "GET", action: "/campaigns", class: "ui-toolbar ui-toolbar--filters" },
input({ type: "hidden", name: "q", value: q || "" }),
...(emptyMod ? [] : [
...chipButtons(BASE_FILTERS, filter, census, filterLabel),
...chipButtons(STATUSES, filter, census, statusLabel),

View file

@ -21,6 +21,7 @@ const catLabel = (c) => i18n[catKey(c)] || c
const blobSrcOf = (value) => {
const s = String(value || "").trim()
if (s.startsWith("/blob/sealed/")) return s
if (s.startsWith("&")) return `/blob/${encodeURIComponent(s)}`
const mImg = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/)
return mImg ? `/blob/${encodeURIComponent(mImg[1])}` : null
@ -30,9 +31,10 @@ const renderMediaBlob = (value, fallbackSrc = null, attrs = {}) => {
if (!value) return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null
const s = String(value).trim()
if (!s) return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null
if (s.startsWith("/blob/sealed/")) return img({ loading: 'lazy', src: s, ...attrs })
if (s.startsWith("&")) return img({ loading: 'lazy', src: `/blob/${encodeURIComponent(s)}`, ...attrs })
const mImg = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/)
if (mImg) return img({ loading: 'lazy', src: `/blob/${encodeURIComponent(mImg[1])}`, ...attrs })
const mImg = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256|\/blob\/sealed\/[^)\s]+)\s*\)/)
if (mImg) return img({ loading: 'lazy', src: mImg[1].startsWith("/blob/sealed/") ? mImg[1] : `/blob/${encodeURIComponent(mImg[1])}`, ...attrs })
return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null
}

View file

@ -238,6 +238,47 @@ const renderClearnetUrlBlock = ({ baseUrl = '', path, i18nObj = {} }) => {
);
};
const CLEARNET_ZOOM_CSS = `
.cn-zoom{display:contents}
.cn-zoom img{cursor:zoom-in}
.cn-lightbox{display:none;position:fixed;inset:0;z-index:1000;background:rgba(0,0,0,.9);align-items:center;justify-content:center}
.cn-lightbox:target{display:flex}
.cn-lightbox-link{display:flex;align-items:center;justify-content:center;width:100%;height:100%;cursor:zoom-out}
.cn-lightbox-img{display:block;max-width:92vw;max-height:88vh;object-fit:contain}
.cn-lightbox-full{background:#000}
.cn-lightbox-full .cn-lightbox-img{width:100vw;height:100vh;max-width:100vw;max-height:100vh}
.cn-lightbox-close,.cn-lightbox-fs{position:absolute;top:14px;z-index:1;width:42px;height:42px;display:flex;align-items:center;justify-content:center;border-radius:50%;background:rgba(0,0,0,.6);color:#fff;text-decoration:none;font-size:24px;line-height:1}
.cn-lightbox-close{right:16px}
.cn-lightbox-fs{right:66px}
.cn-lightbox-fs svg{display:block;width:20px;height:20px}
`;
const CN_FULLSCREEN_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="4 9 4 4 9 4"/><polyline points="15 4 20 4 20 9"/><polyline points="20 15 20 20 15 20"/><polyline points="9 20 4 20 4 15"/></svg>';
const cnZoom = (id, src, alt = '', { full = false, imgClass = '' } = {}) => {
const zid = `cnz-${String(id).replace(/[^A-Za-z0-9_-]/g, '')}`;
const a = escapeHtml(alt);
const u = escapeHtml(src);
const cls = imgClass ? ` class="${escapeHtml(imgClass)}"` : '';
const fs = full ? `<a class="cn-lightbox-fs" href="#${zid}-full" title="${escapeHtml(cnText('liveFullscreen', 'Full screen'))}">${CN_FULLSCREEN_ICON}</a>` : '';
return `<a class="cn-zoom" id="${zid}-src" href="#${zid}"><img${cls} src="${u}" alt="${a}"/></a>`
+ `<div class="cn-lightbox" id="${zid}"><a class="cn-lightbox-close" href="#${zid}-src">×</a>${fs}<a class="cn-lightbox-link" href="#${zid}-src"><img class="cn-lightbox-img" src="${u}" alt="${a}" loading="lazy"/></a></div>`
+ (full ? `<div class="cn-lightbox cn-lightbox-full" id="${zid}-full"><a class="cn-lightbox-close" href="#${zid}">×</a><a class="cn-lightbox-link" href="#${zid}"><img class="cn-lightbox-img" src="${u}" alt="${a}" loading="lazy"/></a></div>` : '');
};
const CN_ZOOM_SKIP = /\bcn-(lightbox-img|footer-logo)\b/;
const zoomClearnetImages = (html) => {
let depth = 0;
let n = 0;
return String(html || '').replace(/<a\b[^>]*>|<\/a\s*>|<img\b[^>]*>/gi, (tag) => {
if (/^<a\b/i.test(tag)) { depth++; return tag; }
if (/^<\/a/i.test(tag)) { depth = Math.max(0, depth - 1); return tag; }
if (depth > 0) return tag;
const cls = (tag.match(/\sclass="([^"]*)"/i) || [])[1] || '';
const src = (tag.match(/\ssrc="([^"]*)"/i) || [])[1] || '';
if (!src || CN_ZOOM_SKIP.test(cls)) return tag;
const alt = (tag.match(/\salt="([^"]*)"/i) || [])[1] || '';
const zid = `cnz-i${++n}`;
return `<a class="cn-zoom" id="${zid}-src" href="#${zid}">${tag}</a><span class="cn-lightbox" id="${zid}"><a class="cn-lightbox-close" href="#${zid}-src">×</a><a class="cn-lightbox-link" href="#${zid}-src"><img class="cn-lightbox-img" src="${src}" alt="${alt}" loading="lazy"/></a></span>`;
});
};
const CLEARNET_TEXT_CSS = `
.cn-tags{display:flex;flex-wrap:wrap;gap:6px;margin:10px 0}
.cn-hub-priceline{margin:10px 0 0 0}
@ -266,8 +307,11 @@ a.tag-link:hover{text-decoration:underline}
.rt-rule{display:block;border-top:1px solid currentColor;opacity:.4;margin:10px 0}
.rt-code{font-family:monospace;background:rgba(128,128,128,.18);padding:1px 4px;border-radius:3px;word-break:break-word}
.rt-code-block{display:block;font-family:monospace;white-space:pre-wrap;background:rgba(128,128,128,.18);padding:10px;border-radius:5px;margin:8px 0;overflow-x:auto;word-break:break-word}
.post-image{display:block;max-width:100%;height:auto;margin:12px 0;border:1px solid var(--border);border-radius:6px}
.post-video,.post-audio{display:block;width:100%;max-width:100%;margin:12px 0;border-radius:6px;background:#000}
.post-image{display:block;width:auto;max-width:100%;max-height:480px;height:auto;margin:12px auto;border:1px solid var(--border);border-radius:6px}
.post-video{display:block;width:100%;max-width:100%;max-height:480px;margin:12px auto;border-radius:6px;background:#000}
.post-audio{display:block;width:100%;max-width:100%;margin:12px 0}
.cn-media-frame img{max-height:480px;width:auto;margin:0 auto}
.cn-media-frame video{max-height:480px}
.post-pdf{display:inline-block;margin:8px 0;padding:8px 14px;background:var(--bg-sub);border:1px solid var(--border);border-radius:6px;color:var(--fg);text-decoration:none}
.post-pdf:hover{border-color:var(--fg)}
`;
@ -346,7 +390,10 @@ footer.cn-footer .cn-footer-logo{width:56px;height:auto;display:block;margin:0 a
footer.cn-footer .cn-footer-line{margin:3px 0}
`;
const renderClearnetPage = ({ title, ogTitle, ogDescription = '', ogImage = null, extraCss = '', body, headerExtra = '', hubFeedId = null }) => {
const renderClearnetHubSearch = ({ type = '', query = '' } = {}) =>
`<form class="cn-search" method="GET" action="/c">${type ? `<input type="hidden" name="type" value="${escapeHtml(type)}"/>` : ''}<input type="text" name="q" value="${escapeHtml(query || '')}" placeholder="${escapeHtml(cnText('cnSearchPlaceholder', 'Search…'))}" autocomplete="off"/></form>`;
const renderClearnetPage = ({ title, ogTitle, ogDescription = '', ogImage = null, extraCss = '', body, headerExtra = null, hubFeedId = null }) => {
const safeTitle = escapeHtml(title || 'Oasis');
const safeOgTitle = escapeHtml(ogTitle || title || 'Oasis');
const safeOgDesc = escapeHtml(ogDescription || '');
@ -368,7 +415,7 @@ const renderClearnetPage = ({ title, ogTitle, ogDescription = '', ogImage = null
<meta name="description" content="${safeOgDesc}"/>
<meta name="robots" content="index, follow"/>
<link rel="icon" href="/c/assets/images/favicon.svg"/>
<style>${baseCss}${CLEARNET_SEARCH_CSS}${CLEARNET_TEXT_CSS}${extraCss}
<style>${baseCss}${CLEARNET_SEARCH_CSS}${CLEARNET_TEXT_CSS}${CLEARNET_ZOOM_CSS}${extraCss}
.cn-brand-link{display:block;text-decoration:none}
.cn-brand-link:hover .cn-brand{color:var(--accent)}
@ -386,9 +433,9 @@ const renderClearnetPage = ({ title, ogTitle, ogDescription = '', ogImage = null
<body>
<header class="cn-header">
${brandBlock}
<div class="cn-header-extra">${renderLangSelector(lang)}${propagateLang(headerExtra || '', langOverride)}</div>
<div class="cn-header-extra">${renderLangSelector(lang)}${propagateLang(headerExtra == null ? renderClearnetHubSearch() : headerExtra, langOverride)}</div>
</header>
${propagateLang(stripInternalAnchors(body), langOverride)}
${propagateLang(stripInternalAnchors(zoomClearnetImages(body)), langOverride)}
<footer class="cn-footer">
<a href="https://wiki.solarnethub.com" target="_blank" rel="noopener"><img class="cn-footer-logo" src="/c/assets/images/snh-oasis.jpg" alt="Oasis"/></a>
<div class="cn-footer-line">${escapeHtml(cnText('cnSyncedPeers', 'Synced-peers'))}: [ <strong>${Number(sharedState.getSyncedPeerCount ? sharedState.getSyncedPeerCount() : 0) || 0}</strong> ]</div>
@ -550,6 +597,7 @@ const renderClearnetPodcastView = ({ channel }) => {
};
module.exports = {
cnZoom,
kindLabel,
renderTagChips,
renderClearnetPodcastView,
@ -575,6 +623,7 @@ module.exports = {
renderDoubleEncryptionChip,
renderClearnetUrlBlock,
renderClearnetSearchForm,
renderClearnetHubSearch,
renderClearnetPage,
renderClearnetNotFound,
CLEARNET_PAGER_CSS,

View file

@ -1,7 +1,7 @@
const { div, p, h2, span, a, form, input, textarea, button, label, br, details, summary } = require("../server/node_modules/hyperaxe");
const { TEXT_CAP } = require('../backend/long_text');
const moment = require("../server/node_modules/moment");
const { i18n, userLink } = require("./main_views");
const { i18n, userLink, renderHideIcon, renderIcon } = require("./main_views");
const { config } = require("../server/SSB_server.js");
const { renderStyledText } = require("../backend/renderStyledText");
@ -15,7 +15,7 @@ const visibleComments = (comments) => (Array.isArray(comments) ? comments : []).
return t && String(t).trim();
});
const renderCommentCard = (c, extra = null) => {
const renderCommentCard = (c, extra = null, returnTo = null) => {
const author = (c && c.value && c.value.author) || "";
const ts = (c && c.value && c.value.timestamp) || (c && c.timestamp);
const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm") : "";
@ -28,7 +28,13 @@ const renderCommentCard = (c, extra = null) => {
return div({ class: own ? "votations-comment-card own-comment" : "votations-comment-card", id: c && c.key ? c.key : undefined },
own && c.key
? form({ method: "POST", action: `/comments/delete/${encodeURIComponent(c.key)}`, class: "comment-delete-form" },
button({ type: "submit", class: "btn-singleview btn-delete", title: i18n.chatDelete }, "✕"))
button({ type: "submit", class: "btn-singleview btn-delete", title: i18n.chatDelete }, renderIcon("close")))
: null,
!own && c.key
? form({ method: "POST", action: `/hide/${encodeURIComponent(c.key)}`, class: "comment-delete-form" },
input({ type: "hidden", name: "returnTo", value: returnTo || (rootId ? `/thread/${encodeURIComponent(rootId)}` : "/activity") }),
input({ type: "hidden", name: "label", value: String(text).replace(/\s+/g, " ").trim().slice(0, 80) }),
button({ type: "submit", class: "btn-singleview btn-hide", title: i18n.hideContent }, renderHideIcon()))
: null,
span({ class: "created-at" },
author ? userLink(author) : span("(unknown)"),
@ -52,7 +58,7 @@ const renderCommentsSection = ({ action, comments = [], returnTo = null, closedN
div({ class: "vote-comments-section" },
list.length
? div({ class: "comments-list" }, ...list.map((c, i) => {
const card = renderCommentCard(c, typeof commentExtra === "function" ? commentExtra(c) : null);
const card = renderCommentCard(c, typeof commentExtra === "function" ? commentExtra(c) : null, returnTo);
return i === list.length - 1 ? div({ id: "comments-latest", class: "comments-latest-anchor" }, card) : card;
}))
: (closedNote ? null : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)),

View file

@ -1,5 +1,5 @@
const { div, h2, p, section, button, form, a, input, span, table, tr, td, ul, li } = require("../server/node_modules/hyperaxe");
const { template, i18n, userLink, renderModuleStats, paged } = require("./main_views");
const { template, i18n, userLink, renderModuleStats, paged, renderIcon } = require("./main_views");
const KIND_LABEL = {
inhabitants: () => i18n.dataKindInhabitants,
@ -32,7 +32,8 @@ const KIND_LABEL = {
mailing: () => i18n.mailingTitle,
logistics: () => i18n.logisticsTitle,
podcasts: () => i18n.podcastsTitle,
campaigns: () => i18n.campaignsTitle
campaigns: () => i18n.campaignsTitle,
blogs: () => i18n.blogTitle
};
const REASON_LABEL = {
@ -54,39 +55,50 @@ const FILTER_COLUMNS = [
["INHABITANTS", "TRIBES"],
["VOTES", "EVENTS", "CALENDARS", "TASKS", "REPORTS", "EMERGENCIES", "MAILING", "CAMPAIGNS"],
["MARKET", "HOUSING", "JOBS", "PROJECTS", "INDUSTRY", "LOGISTICS"],
["FORUM", "CHATS", "ROOMS", "PADS", "WIKI", "MAPS", "SCHOOL"],
["BLOGS", "FORUM", "CHATS", "ROOMS", "PADS", "WIKI", "MAPS", "SCHOOL"],
["AUDIOS", "BOOKMARKS", "DOCUMENTS", "FILES", "IMAGES", "TORRENTS", "VIDEOS", "PODCASTS"]
];
const kindLabel = (kind) => (KIND_LABEL[kind] ? KIND_LABEL[kind]() : String(kind || '').toUpperCase());
const reasonButton = (value, label, current, q) =>
const reasonButton = (value, label, current, filter) =>
form({ method: "GET", action: "/data" },
input({ type: "hidden", name: "q", value: q || "" }),
filter && filter !== "ALL" ? input({ type: "hidden", name: "filter", value: filter }) : null,
value ? input({ type: "hidden", name: "reason", value }) : null,
button({ type: "submit", class: current === value ? "filter-btn active" : "filter-btn" }, String(label).toUpperCase())
);
const filterButton = (value, label, current, reason) =>
form({ method: "GET", action: "/data" },
input({ type: "hidden", name: "filter", value }),
reason ? input({ type: "hidden", name: "reason", value: reason }) : null,
button({ type: "submit", class: current === value ? "filter-btn active" : "filter-btn" }, String(label).toUpperCase())
);
const sectionId = (kind) => `data-${String(kind || '').toLowerCase()}`;
const renderFilters = (q, payload = {}, kindsPresent = []) => {
const matches = Array.isArray(payload.matches) ? payload.matches : [];
const reason = String(payload.reason || "");
if (!matches.length && !String(q || "").trim() && !reason) return null;
const filter = String(payload.filter || "ALL").toUpperCase();
const isKindFilter = !FILTER_COLUMNS[0].includes(filter);
if (!matches.length && !String(q || "").trim() && !reason && !isKindFilter) return null;
const avail = (Array.isArray(payload.reasonsAvail) ? payload.reasonsAvail : []).filter(r => REASON_LABEL[r]);
const present = new Set(kindsPresent);
const columns = FILTER_COLUMNS.slice(1)
.map(col => col.filter(k => present.has(k.toLowerCase())))
.filter(col => col.length);
const columns = [FILTER_COLUMNS[0]].concat(
FILTER_COLUMNS.slice(1)
.map(col => col.filter(k => present.has(k.toLowerCase())))
.filter(col => col.length)
);
return section(
div({ class: "activity-sub-filter" },
reasonButton("", i18n.dataFilterAll, reason, q),
...avail.map(r => reasonButton(r, REASON_LABEL[r](), reason, q))
reasonButton("", i18n.dataFilterAll, reason, filter),
...avail.map(r => reasonButton(r, REASON_LABEL[r](), reason, filter))
),
reason || !columns.length ? null : div({ class: "activity-filter-grid" },
div({ class: "activity-filter-grid" },
...columns.map(col =>
div({ class: "activity-filter-col" },
...col.map(k => a({ href: `#${sectionId(k)}`, class: "filter-btn" }, String(kindLabel(k.toLowerCase())).toUpperCase()))
...col.map(k => filterButton(k, FILTER_COLUMNS[0].includes(k) ? (i18n[k + "Button"] || k) : kindLabel(k.toLowerCase()), filter, reason))
)
)
),
@ -94,6 +106,7 @@ const renderFilters = (q, payload = {}, kindsPresent = []) => {
renderModuleStats(matches.length),
form({ method: "GET", action: "/data", class: "filter-box" },
reason ? input({ type: "hidden", name: "reason", value: reason }) : null,
filter !== "ALL" ? input({ type: "hidden", name: "filter", value: filter }) : null,
input({ type: "text", name: "q", value: q || "", placeholder: i18n.dataSearchPlaceholder, class: "filter-box__input" }),
div({ class: "filter-box__controls" },
button({ type: "submit", class: "filter-box__button" }, i18n.searchButton)
@ -165,7 +178,7 @@ const renderMatchRow = (m, isBest) => {
span({ class: "data-kind-chip" }, String(kindLabel(m.kind)).toUpperCase()),
span({ class: "card-label data-score-label" }, `${isBest ? i18n.dataBestMatch : (i18n.dataCcShort || "CC")}: `),
span({ class: isBest ? "data-score-value data-best-score" : "data-score-value" }, `${scorePct(m.score)}%`),
a({ href: m.href, class: "btn-singleview btn-content data-side-visit", title: i18n.visitContent }, "↗")
a({ href: m.href, class: "btn-singleview btn-content data-side-visit", title: i18n.visitContent }, renderIcon("open"))
),
a({ href: m.href, class: "data-card-title" }, shortTitle(m.title) || shortId(m.id)),
span({ class: "data-card-author" }, userLink(m.author)),
@ -210,12 +223,12 @@ exports.dataView = async (payload = {}) => {
return template(
i18n.dataTitle,
section(div({ class: "tags-header module-header-line" }, h2(i18n.dataTitle), p(i18n.dataDescription))),
renderFilters(q, { ...payload, matches }, groups.map(g => g.kind)),
renderFilters(q, { ...payload, matches }, payload.kindsMatched && typeof payload.kindsMatched === "object" ? Object.keys(payload.kindsMatched) : groups.map(g => g.kind)),
emptyData ? null : (payload.cohesion ? renderCohesion(payload.cohesion) : null),
section(
emptyData ? null : div({ class: "tags-header" },
h2(q ? `${i18n.dataTopicTitle} #${q}` : i18n.dataMatchesTitle),
p(q ? i18n.dataTopicHint : i18n.dataMatchesHint)
h2(i18n.dataMatchesTitle),
p(i18n.dataMatchesHint)
),
best
? div({ class: "data-section data-top-best" },

View file

@ -4,7 +4,7 @@ const { renderCommentsSection: renderSharedCommentsSection, renderCommentsLink }
const moment = require("../server/node_modules/moment");
const { renderLicenseChip, renderLicenseSelect, renderReachChip, renderClearnetSelector, renderClearnetSwitch, renderTribeOriginChip } = require('./clearnet_view');
const { clearnetItemHref, template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip, renderContentActions , renderSpreadEditWarning, renderModuleStats, moduleIsEmpty, contentDeleteAction, paged } = require("./main_views");
const { clearnetItemHref, template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip, renderContentActions, renderSpreadEditWarning, renderModuleStats, moduleIsEmpty, contentDeleteAction, paged, blobUrlOf } = require("./main_views");
const { config } = require("../server/SSB_server.js");
const { renderStyledText } = require("../backend/renderStyledText");
@ -77,7 +77,7 @@ const renderDocumentList = exports.renderDocumentList = (documents, filter, para
div({ class: "shop-title-row" }, title ? h2(titleHref ? a({ href: titleHref, class: "card-title-link" }, title) : title) : null, doc.tribeOrigin ? renderTribeOriginChip(doc.tribeOrigin) : doc.clearnet === true ? renderReachChip(true, i18n, clearnetItemHref('documents', doc.title, doc.key)) : null, renderLicenseChip(doc.license), ...(typeof params.titleChips === "function" ? params.titleChips(doc) : [])),
doc.lifetime ? div({ class: "card-chips-row" }, renderLifespanChip(doc.lifetime, i18n)) : null,
doc?.url
? div({ id: pdfId, class: "pdf-viewer-container", "data-pdf-url": `/blob/${encodeURIComponent(doc.url)}` })
? div({ id: pdfId, class: "pdf-viewer-container", "data-pdf-url": blobUrlOf(doc.url) })
: p(i18n.documentNoFile),
...(typeof params.bodyExtra === "function" ? params.bodyExtra(doc) : []),
engagement,
@ -180,7 +180,6 @@ exports.documentView = async (documents, filter = "all", documentId = null, para
{ class: "filters" },
form(
{ method: "GET", action: "/documents", class: "ui-toolbar ui-toolbar--filters" },
input({ type: "hidden", name: "q", value: q }),
input({ type: "hidden", name: "sort", value: sort }),
...(emptyMod ? [] : [
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.documentFilterAll).toUpperCase()),
@ -285,7 +284,7 @@ exports.singleDocumentView = async (doc, filter = "all", comments = [], params =
const docMain = div({ class: "tribe-main" },
detailActions,
doc?.url
? div({ id: pdfId, class: "pdf-viewer-container", "data-pdf-url": `/blob/${encodeURIComponent(doc.url)}` })
? div({ id: pdfId, class: "pdf-viewer-container", "data-pdf-url": blobUrlOf(doc.url) })
: p(i18n.documentNoFile),
(() => {
const createdTs = doc.createdAt ? new Date(doc.createdAt).getTime() : NaN;
@ -321,7 +320,6 @@ exports.singleDocumentView = async (doc, filter = "all", comments = [], params =
{ class: "filters" },
form(
{ method: "GET", action: "/documents", class: "ui-toolbar ui-toolbar--filters" },
input({ type: "hidden", name: "q", value: q }),
input({ type: "hidden", name: "sort", value: sort }),
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.documentFilterAll).toUpperCase()),
...(mediaChip("recent") ? [button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, String(i18n.documentFilterRecent).toUpperCase())] : []),

View file

@ -62,7 +62,6 @@ const renderFilters = (filter, q, census, emptyMod) =>
section(
div({ class: "filters" },
form({ method: "GET", action: "/emergencies", class: "ui-toolbar ui-toolbar--filters" },
input({ type: "hidden", name: "q", value: q || "" }),
...(emptyMod ? [] : [
...chipButtons(BASE_FILTERS, filter, census, baseLabel),
...chipButtons(SEVERITIES, filter, census, sevLabel),

View file

@ -126,7 +126,7 @@ const renderFeedCard = exports.renderFeedCard = (feed, spreadMap = null, opts =
const signerId = feed.value.author || "";
const refeedsNum = Number(content.refeeds || 0) || 0;
const commentCount = Number(content.commentCount || 0);
const styledNodes = renderStyledText(safeText, { zoomImages: true });
const styledNodes = renderStyledText(safeText, { zoomImages: true, ...(typeof opts.hashtagHref === 'function' ? { hashtagHref: opts.hashtagHref } : {}) });
const refeedAction = opts.refeedAction || `/feed/refeed/${encodeURIComponent(feed.key)}`;
const canRefeed = opts.canRefeed !== false && !(authorId && String(authorId) === String(me));
const headerActions = opts.headerActions !== undefined

View file

@ -75,7 +75,7 @@ const renderFileSeeds = (t, spreadMap) => {
const info = spreadInfoOf(t, spreadMap);
return String(info && typeof info.count === "number" ? info.count : (info && Array.isArray(info.voters) ? info.voters.length : 0));
};
const renderFileSpread = (t, spreadMap) => String(t.author) === String(userId) ? "" : renderSpreadButton(t.key, spreadInfoOf(t, spreadMap), i18n.seedAction, false);
const renderFileSpread = (t, spreadMap) => String(t.author) === String(userId) ? "—" : renderSpreadButton(t.key, spreadInfoOf(t, spreadMap), i18n.seedAction, false);
const renderFileTable = exports.renderFileTable = (files, filter, params = {}) => {
const returnTo = buildReturnTo(filter, params);
@ -86,18 +86,16 @@ const renderFileTable = exports.renderFileTable = (files, filter, params = {}) =
{ border: "1", class: "file-table" },
tr(
th(i18n.createdAt || "DATE"),
th(i18n.authorLabel || "AUTHOR"),
th(i18n.fileTitleLabel || "TITLE"),
th(i18n.fileSizeLabel || "SIZE"),
th(i18n.seedsLabel),
th(i18n.seedAction),
th({ class: "torrent-spread-cell" }, i18n.seedsLabel),
th({ class: "torrent-spread-cell" }, i18n.seedAction),
th("")
),
files.map((t) =>
tr(
td({ class: "row-chips" }, t.clearnet === true ? renderReachChip(true, i18n, clearnetItemHref('files', t.title, t.key)) : null),
td({ "data-label": i18n.createdAt || "DATE" }, moment(t.createdAt).format("YYYY/MM/DD HH:mm")),
td({ class: "row-author" }, userLink(t.author)),
td({ class: "row-title", "data-label": i18n.fileTitleLabel || "TITLE" }, t.title || ""),
td({ "data-label": i18n.fileSizeLabel || "SIZE" }, formatSize(t.size)),
td({ class: "torrent-spread-cell", "data-label": i18n.seedsLabel }, renderFileSeeds(t, params.spreadMap)),
@ -226,7 +224,6 @@ exports.filesView = async (files, filter = "all", fileId = null, params = {}) =>
{ class: "filters" },
form(
{ method: "GET", action: "/files", class: "ui-toolbar ui-toolbar--filters" },
input({ type: "hidden", name: "q", value: q }),
input({ type: "hidden", name: "sort", value: sort }),
...(emptyMod ? [] : [
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.fileFilterAll).toUpperCase()),
@ -388,7 +385,7 @@ exports.singleFileView = async (fileObj, filter = "all", comments = [], params =
: null
);
})(),
renderEngagement(fileObj.key,
fileObj.tribeId ? null : renderEngagement(fileObj.key,
renderOpinionsVoting('/files/opinions', fileObj.key, fileObj.opinions, returnTo, fileObj.opinions_inhabitants),
renderFileCommentsSection(fileObj.key, comments, returnTo)
)
@ -405,7 +402,6 @@ exports.singleFileView = async (fileObj, filter = "all", comments = [], params =
{ class: "filters" },
form(
{ method: "GET", action: "/files", class: "ui-toolbar ui-toolbar--filters" },
input({ type: "hidden", name: "q", value: q }),
input({ type: "hidden", name: "sort", value: sort }),
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.fileFilterAll).toUpperCase()),
...(mediaChip("recent") ? [button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, String(i18n.fileFilterRecent).toUpperCase())] : []),

View file

@ -94,9 +94,12 @@ const buildGraphSvg = (me, peers, links = []) => {
+ `</svg>`;
};
const zoomableGraph = (id, svg) => [
div({ class: 'graphos-zoom-row' }, a({ href: process.env.OASIS_MOBILE === '1' ? `#${id}-focus` : `#${id}`, id: `${id}-src`, class: 'tribe-action-btn graphos-zoom-btn' }, `\u2922 ${i18n.graphosZoom || 'Enlarge'}`)),
div({ class: 'graphos-canvas', [RAW_HTML]: svg }),
const zoomableGraph = (id, svg, stats = []) => [
div({ class: 'graphos-legend graphos-statsbar' }, ...stats.filter(Boolean)),
div({ class: 'graphos-canvas' },
a({ href: process.env.OASIS_MOBILE === '1' ? `#${id}-focus` : `#${id}`, id: `${id}-src`, class: 'tribe-action-btn graphos-zoom-btn' }, `\u2922 ${i18n.graphosZoom || 'Enlarge'}`),
div({ class: 'graphos-canvas-svg', [RAW_HTML]: svg })
),
div({ id, class: 'lightbox graphos-lightbox' },
a({ href: `#${id}-src`, class: 'lightbox-close' }, '\u00d7'),
div({ class: 'graphos-canvas-zoom' },
@ -108,7 +111,7 @@ const zoomableGraph = (id, svg) => [
)
];
const kpi = (label, value) => div({ class: 'stats-kpi stats-kpi-inline' },
const kpi = (label, value) => span({ class: 'graphos-legend-item graphos-kpi' },
span({ class: 'stats-kpi-label' }, String(label).toUpperCase()),
span({ class: 'stats-kpi-value' }, String(value))
);
@ -153,15 +156,12 @@ exports.graphosView = ({ filter, me, peers, links = [], kpis, focus = null, focu
capped
? p({ class: 'graphos-cap-note' }, `${i18n.graphosShowing || 'Showing'} ${shown} / ${total}`)
: null,
div({ class: 'stats-block graphos-stats' },
div({ class: 'stats-grid' },
kpi(i18n.graphosTotalNodes || 'Total nodes', kpis.total),
kpi(i18n.online || 'Online', kpis.online),
filter !== 'MINE' ? kpi(i18n.discovered || 'Discovered', kpis.discovered) : null,
filter !== 'MINE' ? kpi(i18n.unknown || 'Unknown', kpis.unknown) : null
)
),
...zoomableGraph('graphos-zoom', buildGraphSvg(me, peers, links)),
...zoomableGraph('graphos-zoom', buildGraphSvg(me, peers, links), [
kpi(i18n.graphosTotalNodes || 'Total nodes', kpis.total),
kpi(i18n.online || 'Online', kpis.online),
filter !== 'MINE' ? kpi(i18n.discovered || 'Discovered', kpis.discovered) : null,
filter !== 'MINE' ? kpi(i18n.unknown || 'Unknown', kpis.unknown) : null
]),
div({ class: 'graphos-legend' },
legendItem('me', i18n.graphosYou || 'You'),
legendItem('online', i18n.online || 'Online'),
@ -267,18 +267,15 @@ exports.graphosFederationView = ({ myName, rows = [], links = [], ownVersion = '
)
)
),
div({ class: 'stats-block graphos-stats' },
div({ class: 'stats-grid' },
kpi(i18n.graphosFedPubs, sorted.length),
kpi(i18n.peerStateConnected, count(r => r.state === 'connected')),
kpi(i18n.graphosFedUnreachable, count(r => r.state === 'unreachable')),
kpi(i18n.peerOutdated, count(outdated)),
kpi(i18n.peerErrTor, count(r => r.transport === 'tor')),
kpi(i18n.graphosFedLinks, links.filter(l => l.mutual).length),
kpi(i18n.graphosFedIsolated, count(r => r.isolated))
)
),
...(sorted.length ? zoomableGraph('graphos-fed-zoom', buildFederationSvg(myName || '', sorted, links, ownVersion)) : [p(i18n.graphosFedEmpty)]),
...(sorted.length ? zoomableGraph('graphos-fed-zoom', buildFederationSvg(myName || '', sorted, links, ownVersion), [
kpi(i18n.graphosFedPubs, sorted.length),
kpi(i18n.peerStateConnected, count(r => r.state === 'connected')),
kpi(i18n.graphosFedUnreachable, count(r => r.state === 'unreachable')),
kpi(i18n.peerOutdated, count(outdated)),
kpi(i18n.peerErrTor, count(r => r.transport === 'tor')),
kpi(i18n.graphosFedLinks, links.filter(l => l.mutual).length),
kpi(i18n.graphosFedIsolated, count(r => r.isolated))
]) : [p(i18n.graphosFedEmpty)]),
div({ class: 'graphos-legend' },
legendItem('me', i18n.graphosYou || 'You'),
span({ class: 'graphos-legend-item' }, span({ class: 'graphos-legend-dot graphos-fed-dot-connected' }), span(i18n.peerStateConnected)),

View file

@ -4,7 +4,7 @@ const { renderCommentsSection: renderSharedCommentsSection, renderCommentsLink }
const moment = require("../server/node_modules/moment");
const { renderLicenseChip, renderLicenseSelect, renderReachChip, renderClearnetSelector, renderClearnetSwitch, renderTribeOriginChip } = require('./clearnet_view');
const { clearnetItemHref, template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip, renderStateChip, renderContentActions , renderSpreadEditWarning, renderModuleStats, moduleIsEmpty, contentDeleteAction, paged } = require("./main_views");
const { clearnetItemHref, template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip, renderStateChip, renderContentActions, renderSpreadEditWarning, renderModuleStats, moduleIsEmpty, contentDeleteAction, paged, blobUrlOf } = require("./main_views");
const { config } = require("../server/SSB_server.js");
const { renderStyledText } = require("../backend/renderStyledText")
const { renderMapLocationVisitLabel, renderMapEmbed } = require("./maps_view");
@ -36,7 +36,7 @@ const renderTags = (tags) => {
};
const renderImageMedia = (imgObj, filter, params = {}) => {
const src = imgObj?.url ? `/blob/${encodeURIComponent(imgObj.url)}` : "";
const src = imgObj?.url ? blobUrlOf(imgObj.url) : "";
return imgObj?.url
? div(
@ -144,7 +144,7 @@ const renderImageForm = (filter, imageId, imageToEdit, params = {}) => {
br(),
br(),
imageToEdit?.url
? img({ loading: 'lazy', src: `/blob/${encodeURIComponent(imageToEdit.url)}`, class: "media-preview", alt: imageToEdit?.title || "" })
? img({ loading: 'lazy', src: blobUrlOf(imageToEdit.url), class: "media-preview", alt: imageToEdit?.title || "" })
: null,
label(i18n.imageTitleLabel),
br(),
@ -188,7 +188,7 @@ const renderGallery = (images) => {
const renderLightbox = (images) =>
images.map((imgObj) => {
const src = imgObj.url ? `/blob/${encodeURIComponent(imgObj.url)}` : "";
const src = imgObj.url ? blobUrlOf(imgObj.url) : "";
return div(
{ id: `img-${encodeURIComponent(imgObj.key)}`, class: "lightbox" },
a({ href: `#img-${encodeURIComponent(imgObj.key)}-src`, class: "lightbox-close" }, "×"),
@ -239,7 +239,6 @@ exports.imageView = async (images, filter = "all", imageId = null, params = {})
{ class: "filters" },
form(
{ method: "GET", action: "/images", class: "ui-toolbar ui-toolbar--filters" },
input({ type: "hidden", name: "q", value: q }),
input({ type: "hidden", name: "sort", value: sort }),
...(emptyMod ? [] : [
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.imageFilterAll).toUpperCase()),
@ -351,7 +350,7 @@ exports.singleImageView = async (imageObj, filter = "all", comments = [], params
? div(
{ class: "image-container" },
img({
src: `/blob/${encodeURIComponent(imageObj.url)}`,
src: blobUrlOf(imageObj.url),
alt: imageObj.title || "",
class: "media-preview",
loading: "lazy"
@ -392,7 +391,6 @@ exports.singleImageView = async (imageObj, filter = "all", comments = [], params
{ class: "filters" },
form(
{ method: "GET", action: "/images", class: "ui-toolbar ui-toolbar--filters" },
input({ type: "hidden", name: "q", value: q }),
input({ type: "hidden", name: "sort", value: sort }),
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.imageFilterAll).toUpperCase()), ...(mediaChip("favorites") ? [button(
...(mediaChip("recent") ? [button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, String(i18n.imageFilterRecent).toUpperCase())] : []),

View file

@ -160,11 +160,6 @@ const renderInhabitantCard = (user, filter, currentUserId, fediverseConfigured)
fediverseHandle: typeof raw.fediverseHandle === 'string' ? raw.fediverseHandle : ''
};
return div({ class: 'trending-card inhabitants-card' + (isMe ? ' own-content' : '') },
div(
{ class: 'card-header activity-card-header' },
span(),
renderContentActions(user.id, `/inhabitant/${encodeURIComponent(user.id)}`, { author: user.id })
),
div({ class: 'card-section inhabitants-card-body' },
div({ class: 'inhabitant-card' },
div({ class: 'inhabitant-left' },
@ -175,7 +170,7 @@ const renderInhabitantCard = (user, filter, currentUserId, fediverseConfigured)
user.name ? p({ class: 'inhabitant-left-name' }, userLink(user.id, user.name)) : null,
...renderUserSensors({
isMe, fediverseConfigured, prefs, id: user.id,
karmaScore: user.karmaScore, carbonGrams: user.carbonGrams,
karmaScore: user.karmaScore, karmaReceived: user.karmaReceived || null, carbonGrams: user.carbonGrams,
deviceSource: user.deviceSource, activityBucket: user.lastActivityBucket,
gpgFingerprint: user.gpgFingerprint, ecoAddress: user.ecoAddress,
estimatedUBI: user.estimatedUBI, lastClaimedDate: user.lastClaimedDate, totalClaimed: user.totalClaimed,
@ -411,7 +406,7 @@ exports.inhabitantsProfileView = (payload, currentUserId, fediverseConfigured) =
safe.oasisVersion ? div({ class: 'profile-side-version' }, '🌴 ' + (i18n.oasisVersionLabel || 'Oasis Version') + ': ', strong(safe.oasisVersion)) : null,
...renderUserSensors({
isMe, fediverseConfigured, prefs, id: id || viewedId,
karmaScore, carbonGrams, deviceSource: safe.deviceSource, activityBucket: providedBucket,
karmaScore, karmaReceived: safe.karmaReceived || null, carbonGrams, deviceSource: safe.deviceSource, activityBucket: providedBucket,
gpgFingerprint, ecoAddress, estimatedUBI, lastClaimedDate, totalClaimed,
larpHouse, stats: safe.stats,
mutual: !!(safe.relationship && safe.relationship.following && safe.relationship.followsMe)

View file

@ -1,6 +1,6 @@
const { div, h1, h2, h3, p, section, button, form, input, span, img, a, br, table, tr, td, textarea, label, strong, details, summary } = require("../server/node_modules/hyperaxe");
const { renderStyledText } = require("../backend/renderStyledText");
const { template, i18n, userLink, paged } = require("./main_views");
const { template, i18n, userLink, paged, renderIcon } = require("./main_views");
const { config } = require("../server/SSB_server.js");
const userId = config.keys.id;
@ -209,7 +209,7 @@ const renderPostsBlock = (posts, house, canPost) => {
paged(stream).map(entry => div({ class: entry.post.author === userId ? 'larp-post own-comment' : 'larp-post' },
entry.post.author === userId && entry.post.id
? form({ method: 'POST', action: `/larp/post/delete/${encodeURIComponent(entry.post.id)}`, class: 'comment-delete-form' },
button({ type: 'submit', class: 'btn-singleview btn-delete', title: i18n.chatDelete }, '✕'))
button({ type: 'submit', class: 'btn-singleview btn-delete', title: i18n.chatDelete }, renderIcon('close')))
: null,
div({ class: 'larp-post-head' },
userLink(entry.post.author),

View file

@ -126,7 +126,6 @@ const renderFilters = (filter, q, zone, census, zones, emptyMod) =>
section(
div({ class: "filters" },
form({ method: "GET", action: "/logistics", class: "ui-toolbar ui-toolbar--filters" },
input({ type: "hidden", name: "q", value: q || "" }),
input({ type: "hidden", name: "zone", value: zone || "" }),
...(emptyMod ? [] : [
...chipButtons(BASE_FILTERS, filter, census, filterLabel),

View file

@ -81,7 +81,6 @@ const renderFilters = (filter, q, census, emptyMod) =>
section(
div({ class: "filters" },
form({ method: "GET", action: "/mailing", class: "ui-toolbar ui-toolbar--filters" },
input({ type: "hidden", name: "q", value: q || "" }),
...(emptyMod ? [] : [
...chipButtons(BASE_FILTERS, filter, census, baseLabel),
...chipButtons(TYPE_FILTERS, filter, census, typeLabel),

View file

@ -111,18 +111,27 @@ const renderLiveFeed = (src, imgClass) => {
a({ href: `#${id}`, id: `${id}-src`, class: 'zoom-link zoom-inline' }, img({ src, alt: '', class: imgClass })),
div({ id, class: 'lightbox live-lightbox' },
a({ href: `#${id}-src`, class: 'lightbox-close' }, '\u00d7'),
a({ href: `#${id}-full`, class: 'live-fullscreen-btn', title: i18n.liveFullscreen }, renderIcon('fullscreen')),
a({ href: `#${id}-src`, class: 'lightbox-image-link' }, img({ src, alt: '', class: 'lightbox-image', loading: 'lazy' }))
),
div({ id: `${id}-full`, class: 'lightbox live-lightbox live-fullscreen' },
a({ href: `#${id}`, class: 'lightbox-close' }, '\u00d7'),
a({ href: `#${id}`, class: 'lightbox-image-link' }, img({ src, alt: '', class: 'lightbox-image', loading: 'lazy' }))
)
];
};
exports.renderLiveFeed = renderLiveFeed;
const renderVideoGrid = (peers, me) => {
const renderVideoGrid = (peers, me, { film = false } = {}) => {
const list = (Array.isArray(peers) ? peers : []).filter(p => p && p.id);
const filmName = (...label) => span({ class: 'video-tile-name video-tile-film' }, '▶ ', ...label);
const tiles = [
me ? div({ class: 'video-tile video-tile-me' }, ...renderLiveFeed('/phone/video/me', 'video-feed'), span({ class: 'video-tile-name' }, i18n.roomYou)) : null,
...(Array.isArray(peers) ? peers : []).filter(p => p && p.id && p.on).map(p => div({ class: 'video-tile' }, ...renderLiveFeed(`/phone/video/${encodeURIComponent(p.id)}`, 'video-feed'), userLink(p.id)))
film ? div({ class: 'video-tile' }, ...renderLiveFeed(`/phone/video/${encodeURIComponent('film:me')}`, 'video-feed'), filmName(i18n.roomYou)) : null,
...list.filter(p => p.on).map(p => div({ class: 'video-tile' }, ...renderLiveFeed(`/phone/video/${encodeURIComponent(p.id)}`, 'video-feed'), userLink(p.id))),
...list.filter(p => p.film).map(p => div({ class: 'video-tile' }, ...renderLiveFeed(`/phone/video/${encodeURIComponent(`film:${p.id}`)}`, 'video-feed'), filmName(userLink(p.id))))
].filter(Boolean);
return tiles.length ? div({ class: 'video-grid' }, ...tiles) : null;
return tiles.length ? div({ class: tiles.length === 1 ? 'video-grid video-grid-solo' : 'video-grid' }, ...tiles) : null;
};
exports.renderVideoGrid = renderVideoGrid;
@ -148,7 +157,7 @@ const renderSubscriptionBox = ({ target, scope, subscribed, count, isOwner, retu
: button({ type: 'submit', class: subscribed ? 'tribe-action-btn danger-btn' : 'tribe-action-btn' }, `${subscribed ? '🔕' : '🔔'} ${String(subscribed ? i18n.subscriptionUnsubscribe : i18n.subscriptionSubscribe).toUpperCase()}`)
),
showPm && total
? a({ href: `/pm?list=${encodeURIComponent(target)}`, class: 'btn-singleview btn-pm', title: i18n.pmCreateButton || 'Write a PM' }, '✉')
? a({ href: `/pm?list=${encodeURIComponent(target)}`, class: 'btn-singleview btn-pm', title: i18n.pmCreateButton || 'Write a PM' }, renderIcon('mail'))
: null
)
];
@ -254,7 +263,7 @@ const renderLinkBox = (blockId, viewHref, title) => {
const id = `linkbox-${linkBoxSeq}-${String(blockId).replace(/[^a-zA-Z0-9]/g, '').slice(-10)}`;
const clearnetHref = clearnetHrefFor(viewHref, blockId, title);
return [
a({ href: `#${id}`, id: `${id}-src`, class: 'btn-singleview btn-link', title: i18n.copyLinkTitle }, '⚯'),
a({ href: `#${id}`, id: `${id}-src`, class: 'btn-singleview btn-link', title: i18n.copyLinkTitle }, renderIcon('link')),
span({ id, class: 'lightbox link-lightbox' },
a({ href: `#${id}-src`, class: 'lightbox-close' }, '\u00d7'),
span({ class: 'link-box' },
@ -297,6 +306,29 @@ const renderFileDownloads = (fileKey, torrentUrl, name, opts = {}) => {
};
exports.renderFileDownloads = renderFileDownloads;
const blobUrlOf = (v) => (String(v || '').startsWith('/blob/sealed/') ? String(v) : `/blob/${encodeURIComponent(v)}`);
exports.blobUrlOf = blobUrlOf;
const ACTION_ICONS = {
hide: '<path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/><line x1="3" y1="3" x2="21" y2="21"/>',
spread: '<path d="M21 12a9 9 0 1 1-2.64-6.36"/><polyline points="21 3 21 9 15 9"/>',
pin: '<path d="M9 3h6l-1 6 4 4H6l4-4-1-6z"/><line x1="12" y1="13" x2="12" y2="21"/>',
report: '<line x1="5" y1="21" x2="5" y2="3"/><path d="M5 4h12l-2.5 4.5L17 13H5"/>',
mail: '<rect x="3" y="5" width="18" height="14" rx="2"/><polyline points="3 7 12 13 21 7"/>',
call: '<path d="M5 3h4l2 5-2.5 1.5a11 11 0 0 0 6 6L16 13l5 2v4a2 2 0 0 1-2 2A17 17 0 0 1 3 5a2 2 0 0 1 2-2z"/>',
link: '<path d="M10 14a4.5 4.5 0 0 0 6.4 0l3.2-3.2a4.5 4.5 0 0 0-6.4-6.4L11.8 5.8"/><path d="M14 10a4.5 4.5 0 0 0-6.4 0l-3.2 3.2a4.5 4.5 0 0 0 6.4 6.4l1.4-1.4"/>',
block: '<path d="M12 2.5l8.5 4.75v9.5L12 21.5l-8.5-4.75v-9.5z"/><polyline points="3.5 7.25 12 12 20.5 7.25"/><line x1="12" y1="12" x2="12" y2="21.5"/>',
datagram: '<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/><line x1="3" y1="12" x2="21" y2="12"/>',
open: '<path d="M7 17L17 7M8 7h9v9"/>',
donate: '<line x1="12" y1="2" x2="12" y2="22"/><line x1="3.3" y1="7" x2="20.7" y2="17"/><line x1="3.3" y1="17" x2="20.7" y2="7"/><polyline points="9.5 4 12 6 14.5 4"/><polyline points="9.5 20 12 18 14.5 20"/>',
torrent: '<line x1="7" y1="20" x2="7" y2="4"/><polyline points="3 8 7 4 11 8"/><line x1="17" y1="4" x2="17" y2="20"/><polyline points="13 16 17 20 21 16"/>',
close: '<line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/>',
film: '<rect x="3" y="4" width="18" height="16" rx="2"/><line x1="7" y1="4" x2="7" y2="20"/><line x1="17" y1="4" x2="17" y2="20"/><line x1="3" y1="12" x2="21" y2="12"/>',
fullscreen: '<polyline points="4 9 4 4 9 4"/><polyline points="15 4 20 4 20 9"/><polyline points="20 15 20 20 15 20"/><polyline points="9 20 4 20 4 15"/>'
};
const renderIcon = (name) => span({ class: 'btn-icon', [RAW_HTML]: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ACTION_ICONS[name] || ''}</svg>` });
const renderHideIcon = () => renderIcon('hide');
exports.renderIcon = renderIcon;
exports.renderHideIcon = renderHideIcon;
const renderContentActions = (msgId, viewHref, opts = {}) => {
const o = (opts && typeof opts === 'object') ? opts : {};
const blockId = (typeof msgId === 'string' && msgId.startsWith('%')) ? msgId : null;
@ -306,7 +338,7 @@ const renderContentActions = (msgId, viewHref, opts = {}) => {
const pinBtn = o.favKind && blockId
? form({ method: 'POST', action: `/${o.favKind}/favorites/${o.isFavorite ? 'remove' : 'add'}/${encodeURIComponent(blockId)}`, class: 'content-action-form' },
o.returnTo ? input({ type: 'hidden', name: 'returnTo', value: o.returnTo }) : null,
button({ type: 'submit', class: o.isFavorite ? 'btn-singleview btn-pin-on' : 'btn-singleview', title: o.isFavorite ? i18n.favoriteRemove : i18n.favoriteAdd }, o.isFavorite ? '✜' : '✛')
button({ type: 'submit', class: o.isFavorite ? 'btn-singleview btn-pin-on' : 'btn-singleview', title: o.isFavorite ? i18n.favoriteRemove : i18n.favoriteAdd }, renderIcon('pin'))
)
: null;
@ -318,17 +350,17 @@ const renderContentActions = (msgId, viewHref, opts = {}) => {
if (!src || typeof src.blobId !== 'string' || !src.blobId.startsWith('&')) return null;
try { if (getConfig().modules.torrentsMod !== 'on') return null; } catch (_) { return null; }
const name = String(src.name || o.reportTitle || '').slice(0, 100);
return a({ href: `/torrents?filter=create&fromBlob=${encodeURIComponent(src.blobId)}&name=${encodeURIComponent(name)}`, class: 'btn-singleview btn-torrent', title: i18n.torrentFromContent }, '\u21C5');
return a({ href: `/torrents?filter=create&fromBlob=${encodeURIComponent(src.blobId)}&name=${encodeURIComponent(name)}`, class: 'btn-singleview btn-torrent', title: i18n.torrentFromContent }, renderIcon('torrent'));
})();
const chainBtn = blockId && process.env.OASIS_MOBILE !== '1'
? a({ href: `/blockexplorer/block/${encodeURIComponent(blockId)}`, class: 'btn-singleview', title: i18n.blockchainViewBlockexplorer }, '⦿')
? a({ href: `/blockexplorer/block/${encodeURIComponent(blockId)}`, class: 'btn-singleview', title: i18n.blockchainViewBlockexplorer }, renderIcon('block'))
: null;
const herePath = (listScope() || {}).path;
const onItself = (() => { try { return !!herePath && decodeURIComponent(String(viewHref || '').split(/[?#]/)[0]) === decodeURIComponent(String(herePath)); } catch (_) { return false; } })();
const contentBtn = viewHref && !onItself
? a({ href: viewHref, class: 'btn-singleview btn-content', title: i18n.visitContent }, '↗')
? a({ href: viewHref, class: 'btn-singleview btn-content', title: i18n.visitContent }, renderIcon('open'))
: null;
const donateBtn = (() => {
@ -337,32 +369,41 @@ const renderContentActions = (msgId, viewHref, opts = {}) => {
const donatable = sharedState.getDonatableAuthors ? sharedState.getDonatableAuthors() : null;
if (!donatable || !donatable.has(String(o.author))) return null;
const ref = viewHref ? `?returnTo=${encodeURIComponent(viewHref)}` : '';
return a({ href: `/wallet/donate/${encodeURIComponent(o.author)}${ref}`, class: 'btn-singleview btn-donate', title: i18n.donateTooltip }, '\u2744');
return a({ href: `/wallet/donate/${encodeURIComponent(o.author)}${ref}`, class: 'btn-singleview btn-donate', title: i18n.donateTooltip }, renderIcon('donate'));
})();
const reportHref = blockId
? `/reports?filter=create&category=CONTENT&title=${encodeURIComponent(String(o.reportTitle || blockId).slice(0, 120))}&description=${encodeURIComponent(`${viewHref || blockId}`)}`
: null;
const reportBtn = o.report !== false && reportHref && !(o.author && String(o.author) === String(myId))
? a({ href: reportHref, class: 'btn-singleview btn-report', title: i18n.reportContent }, '⚑')
? a({ href: reportHref, class: 'btn-singleview btn-report', title: i18n.reportContent }, renderIcon('report'))
: null;
const pmBtn = o.author && String(o.author) !== String(myId)
? a({ href: `/pm?recipients=${encodeURIComponent(o.author)}`, class: 'btn-singleview btn-pm', title: i18n.pmContentTooltip }, '✉')
const pmBtn = o.contact === true && o.author && String(o.author) !== String(myId)
? a({ href: `/pm?recipients=${encodeURIComponent(o.author)}`, class: 'btn-singleview btn-pm', title: i18n.pmContentTooltip }, renderIcon('mail'))
: null;
const callBtn = o.author && String(o.author) !== String(myId) && phoneOn() && !sharedState.isPubId(o.author)
? a({ href: `/phone?filter=create&to=${encodeURIComponent(o.author)}`, class: 'btn-singleview btn-call', title: i18n.phoneCallTooltip }, '✆')
const callBtn = o.contact === true && o.author && String(o.author) !== String(myId) && phoneOn() && !sharedState.isPubId(o.author)
? a({ href: `/phone?filter=create&to=${encodeURIComponent(o.author)}`, class: 'btn-singleview btn-call', title: i18n.phoneCallTooltip }, renderIcon('call'))
: null;
const deleteBtn = o.deleteAction
? form({ method: 'POST', action: o.deleteAction, class: 'content-action-form content-action-delete' },
o.returnTo && o.returnTo !== viewHref ? input({ type: 'hidden', name: 'returnTo', value: o.returnTo }) : null,
button({ type: 'submit', class: 'btn-singleview btn-delete', title: i18n.chatDelete }, '✕')
button({ type: 'submit', class: 'btn-singleview btn-delete', title: i18n.chatDelete }, renderIcon('close'))
)
: null;
if (!pinBtn && !spreadBtn && !chainBtn && !contentBtn && !reportBtn && !pmBtn && !callBtn && !deleteBtn && !donateBtn && !torrentBtn) return null;
return div({ class: 'content-actions' }, deleteBtn, spreadBtn, pinBtn, reportBtn, donateBtn, pmBtn, callBtn, torrentBtn, linkBtn, chainBtn, contentBtn);
const hideBtn = blockId && o.hide !== false && !(o.author && String(o.author) === String(myId))
? form({ method: 'POST', action: `/hide/${encodeURIComponent(blockId)}`, class: 'content-action-form' },
input({ type: 'hidden', name: 'returnTo', value: o.returnTo || viewHref || '/' }),
viewHref ? input({ type: 'hidden', name: 'href', value: viewHref }) : null,
o.reportTitle ? input({ type: 'hidden', name: 'label', value: String(o.reportTitle).slice(0, 120) }) : null,
button({ type: 'submit', class: 'btn-singleview btn-hide', title: i18n.hideContent }, renderHideIcon())
)
: null;
if (!pinBtn && !spreadBtn && !chainBtn && !contentBtn && !reportBtn && !pmBtn && !callBtn && !deleteBtn && !donateBtn && !torrentBtn && !hideBtn) return null;
return div({ class: 'content-actions' }, deleteBtn, spreadBtn, pinBtn, reportBtn, hideBtn, donateBtn, pmBtn, callBtn, torrentBtn, linkBtn, chainBtn, contentBtn);
};
exports.renderContentActions = renderContentActions;
const CONTENT_FAV_KIND = {
@ -416,8 +457,9 @@ exports.renderDocumentActions = renderDocumentActions;
const renderRelationshipBlock = (relationship, feedId) => {
const rel = relationship || {};
if (rel.me) return span({ class: 'status you' }, i18n.relationshipYou);
const muted = (() => { try { return require('../models/viewer_filters').isMuted(feedId); } catch (_) { return false; } })();
const actions = [];
const icons = { follow: '♥', unfollow: '✕', block: '⊘', unblock: '↺' };
const icons = { follow: '♥', unfollow: '✕', block: '⊘', unblock: '↺', mute: '◌', unmute: '●' };
const addAction = (action) => actions.push(
form({ action: `/${action}/${encodeURIComponent(feedId)}`, method: 'post' },
button({ type: 'submit', class: 'filter-btn' }, `${icons[action]} ${i18n[action]}`)
@ -426,7 +468,9 @@ const renderRelationshipBlock = (relationship, feedId) => {
if (rel.following) addAction('unfollow');
else if (rel.blocking) addAction('unblock');
else { addAction('follow'); addAction('block'); }
if (!rel.blocking) addAction(muted ? 'unmute' : 'mute');
return div({ class: 'relationship-status' },
muted ? span({ class: 'status blocked' }, i18n.relationshipMuted) : null,
rel.blocking && rel.blockedBy
? span({ class: 'status blocked' }, i18n.relationshipMutualBlock)
: [
@ -686,7 +730,7 @@ const renderSpreadButton = (msgKey, opts, title = null, showCount = true) => {
{ method: 'POST', action: `/spread/${encodeURIComponent(msgKey)}`, class: 'spread-form' },
button(
{ type: 'submit', class: alreadySpread ? 'btn-singleview btn-spread-on' : 'btn-singleview', title: title || i18n.spreadContent },
showCount ? `⟳ ${count}` : '⟳'
renderIcon('spread'), showCount ? span({ class: 'btn-count' }, String(count)) : null
)
);
};
@ -1946,7 +1990,7 @@ const template = (titlePrefix, ...elements) => {
return renderEmergencyBanner(featured, { compact });
} catch (_) { return null; }
};
const uxMode = currentConfig.ux?.current === "ainav" ? "ainav" : currentConfig.ux?.current === "chats" ? "chats" : currentConfig.ux?.current === "feed" ? "feed" : currentConfig.ux?.current === "phone" ? "phone" : "blocks";
const uxMode = currentConfig.ux?.current === "ainav" ? "ainav" : currentConfig.ux?.current === "chats" ? "chats" : currentConfig.ux?.current === "feed" ? "feed" : currentConfig.ux?.current === "phone" ? "phone" : currentConfig.ux?.current === "rooms" ? "rooms" : "blocks";
const themeLink = link({
rel: "stylesheet",
href: `/assets/themes/${theme}.css?v=${assetVersion()}`
@ -2039,7 +2083,7 @@ const template = (titlePrefix, ...elements) => {
);
})(),
renderPhoneTopLink(),
(uxMode === "chats" || uxMode === "feed" || uxMode === "phone")
(uxMode === "chats" || uxMode === "feed" || uxMode === "phone" || uxMode === "rooms")
? navLink({ href: "/settings", emoji: "⚙", text: i18n.settings })
: null
)
@ -2092,7 +2136,7 @@ const template = (titlePrefix, ...elements) => {
navLink({ href: "/peers", emoji: "⧖", text: i18n.peers })
)
)
) : (uxMode === "chats" || uxMode === "feed" || uxMode === "phone") ? div(
) : (uxMode === "chats" || uxMode === "feed" || uxMode === "phone" || uxMode === "rooms") ? div(
{ class: "top-bar-right" },
nav(
ul(
@ -2151,7 +2195,7 @@ const template = (titlePrefix, ...elements) => {
buildPhoneBanner(),
buildEmergencyBanner(false),
div(
{ class: uxMode === "ainav" ? "main-content ainav-only" : uxMode === "chats" ? "main-content chatsux-only" : uxMode === "feed" || uxMode === "phone" ? "main-content chatsux-only" : "main-content" },
{ class: uxMode === "ainav" ? "main-content ainav-only" : uxMode === "chats" ? "main-content chatsux-only" : uxMode === "feed" || uxMode === "phone" || uxMode === "rooms" ? "main-content chatsux-only" : "main-content" },
uxMode !== "blocks" ? null : div(
{ class: "sidebar-left" },
nav(
@ -2868,6 +2912,15 @@ const post = ({ msg, aside = false, preview = false, spreadInfo = null }) => {
p({ class: "post-text" }, ...renderStyledText(rawText))
);
}
const postTags = String(content.type || '') === 'post' && Array.isArray(content.tags) ? content.tags.filter(Boolean) : [];
if (postTags.length) {
articleElement = div(
articleElement,
div({ class: 'card-tags card-tags-mt' },
...postTags.map(tag => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`))
)
);
}
if (preview) {
return section(
@ -3158,8 +3211,8 @@ exports.editProfileView = ({ name, description, visibilityPrefs = {}, feedId = '
);
};
exports.clearnetBlogView = async ({ msgKey, text, author, authorName, contentWarning, sentAt }) => {
const { escapeHtml: esc, renderKindTag, renderClearnetPage, renderRichText } = require('./clearnet_view');
exports.clearnetBlogView = async ({ msgKey, text, author, authorName, contentWarning, sentAt, tags = [] }) => {
const { escapeHtml: esc, renderKindTag, renderClearnetPage, renderRichText, renderTagChips } = require('./clearnet_view');
const rawText = String(text || '');
const renderedHtml = sanitizeHtml(renderRichText(rawText));
const plainPreview = rawText.replace(/!\[[^\]]*\]\([^)]*\)/g, '').replace(/<[^>]+>/g, '').slice(0, 200);
@ -3189,6 +3242,7 @@ exports.clearnetBlogView = async ({ msgKey, text, author, authorName, contentWar
${cw ? `<div class="cn-blog-cw">${cw}</div>` : ''}
<hr class="cn-sep"/>
<article class="cn-blog-body">${renderedHtml}</article>
${renderTagChips(tags)}
`;
return renderClearnetPage({
title: `${titleText} | Oasis`,
@ -3334,7 +3388,7 @@ const buildClearnetHub = ({ items = {}, filterBase, filterType = '', query = '',
exports.CLEARNET_HUB_CSS = CLEARNET_HUB_CSS;
exports.clearnetHubView = async ({ authors = [], items = {}, tribes = [], filterType = '', query = '', page = 1, tribesPage = 1 }) => {
const { escapeHtml: esc, renderClearnetPage, blobUrl: cnBlob, CLEARNET_PAGER_CSS, renderClearnetPager } = require('./clearnet_view');
const { escapeHtml: esc, renderClearnetPage, renderClearnetHubSearch, blobUrl: cnBlob, CLEARNET_PAGER_CSS, renderClearnetPager } = require('./clearnet_view');
const hub = buildClearnetHub({ items, filterBase: '/c', filterType, query, page });
const extraCss = CLEARNET_HUB_CSS + CLEARNET_PAGER_CSS + `
`;
@ -3361,7 +3415,7 @@ exports.clearnetHubView = async ({ authors = [], items = {}, tribes = [], filter
ogDescription: i18n.cnHubDescription,
extraCss,
body,
headerExtra: `<form class="cn-search" method="GET" action="/c">${filterType ? `<input type="hidden" name="type" value="${esc(filterType)}"/>` : ''}<input type="text" name="q" value="${esc(query || '')}" placeholder="${esc(i18n.cnSearchPlaceholder)}" autocomplete="off"/></form>`
headerExtra: renderClearnetHubSearch({ type: filterType, query })
});
};
@ -3516,7 +3570,17 @@ const renderUserSensors = (u, opts = {}) => {
})()
: null;
const items = [];
if (show('karma')) items.push(span({ class: 'karma-line' }, `${i18n.bankingUserEngagementScore}: `, strong(String(u.karmaScore !== undefined ? u.karmaScore : 0))));
const kr = u.karmaReceived && typeof u.karmaReceived === 'object' ? u.karmaReceived : null;
const krPos = kr ? Number(kr.positive) || 0 : 0;
const krNeg = kr ? Number(kr.negative) || 0 : 0;
const krTone = !kr ? '' : krPos > krNeg ? ' karma-value-positive' : krPos < krNeg ? ' karma-value-negative' : ' karma-value-neutral';
if (show('karma')) items.push(span({ class: kr ? 'karma-line karma-line-stacked' : 'karma-line' },
span({ class: 'karma-line-main' }, `${i18n.bankingUserEngagementScore}: `, strong({ class: 'karma-value' + krTone }, String(u.karmaScore !== undefined ? u.karmaScore : 0))),
kr ? span({ class: 'karma-received-row' },
span({ class: 'karma-received karma-received-up' }, span({ class: 'karma-arrow' }, '▲'), String(krPos)),
span({ class: 'karma-received karma-received-down' }, span({ class: 'karma-arrow' }, '▼'), String(krNeg))
) : null
));
if (show('ecoTax')) items.push(span({ class: 'karma-line eco-tax-line' }, `${i18n.profileVisibilityEcoTax || 'ECO Tax'}: `, strong(fmtCarbon(u.carbonGrams))));
if (activityChip) items.push(activityChip);
if (deviceChip) items.push(deviceChip);
@ -3587,6 +3651,7 @@ exports.authorView = async ({
relationship,
ecoAddress,
karmaScore = 0,
karmaReceived = null,
estimatedUBI = 0,
lastClaimedDate = null,
totalClaimed = 0,
@ -3636,7 +3701,7 @@ exports.authorView = async ({
const userSensors = renderUserSensors({
isMe: isOwnProfile, fediverseConfigured, prefs, id: feedId,
karmaScore, carbonGrams, deviceSource, activityBucket: lastActivityBucket,
karmaScore, karmaReceived, carbonGrams, deviceSource, activityBucket: lastActivityBucket,
gpgFingerprint, ecoAddress, estimatedUBI, lastClaimedDate, totalClaimed,
larpHouse, stats,
mutual: !!(relationship && relationship.following && relationship.followsMe)
@ -4818,7 +4883,10 @@ exports.privateView = async (messagesInput, filter, decrypted = null, notice = '
}
exports.threadView = ({ messages, spreadMap = null }) => {
exports.threadView = ({ messages, spreadMap = null, hiddenRoot = false }) => {
if (!Array.isArray(messages) || !messages.length) {
return template(i18n.hiddenByYou, section(p({ class: 'muted hidden-note' }, i18n.hiddenByYou)));
}
const rootMessage = messages[0];
const rootAuthorName = rootMessage.value.meta.author.name;
@ -4829,6 +4897,7 @@ exports.threadView = ({ messages, spreadMap = null }) => {
const tpl = template(
[`@${rootAuthorName}`],
hiddenRoot ? p({ class: 'muted hidden-note' }, i18n.hiddenByYou) : null,
div(thread(messages, spreadMap))
);

View file

@ -4,7 +4,7 @@ const RAW_HTML = Symbol.for('oasis.rawHtml');
const { renderStyledText, renderStyledHtml, escapeHtml } = require("../backend/renderStyledText");
const moment = require("../server/node_modules/moment");
const { clearnetItemHref, template, i18n, userLink, renderStateChip, renderLifespanChip, renderSpreadButton, renderContentActions, renderSpreadEditWarning, renderDocumentActions, renderInviteQrCard, renderModuleStats, moduleIsEmpty, contentDeleteAction, paged } = require("./main_views");
const { clearnetItemHref, template, i18n, userLink, renderStateChip, renderLifespanChip, renderSpreadButton, renderContentActions, renderSpreadEditWarning, renderDocumentActions, renderInviteQrCard, renderModuleStats, moduleIsEmpty, contentDeleteAction, paged, renderIcon } = require("./main_views");
const { renderEncryptedChip, renderReachChip, renderClearnetSelector, renderClearnetSwitch, renderTribeOriginChip } = require("./clearnet_view");
const { config } = require("../server/SSB_server.js");
const { renderMapHtml, resolveView, makeView, fitView, parseView, viewParam, parsePick, MIN_ZOOM, MAX_ZOOM } = require("../maps/map_renderer");
@ -37,8 +37,9 @@ const renderTags = (tags) => {
let areaCounter = 0;
const lastOf = (v) => (Array.isArray(v) ? v[v.length - 1] : v);
const numOrNull = (v) => { const n = parseFloat(lastOf(v)); return isFinite(n) ? n : null; };
const isBlob = (v) => !!v && String(v).startsWith("&");
const blobSrc = (image, base) => (isBlob(image) ? `${base}/blob/${encodeURIComponent(image)}` : "");
const isSealed = (v) => !!v && String(v).startsWith("/blob/sealed/");
const isBlob = (v) => !!v && (String(v).startsWith("&") || isSealed(v));
const blobSrc = (image, base) => (isSealed(image) ? (base ? "" : String(image)) : (isBlob(image) ? `${base}/blob/${encodeURIComponent(image)}` : ""));
const buildQuery = (params) => Object.entries(params || {})
.filter(([, v]) => v !== undefined && v !== null && String(v) !== "")
@ -174,7 +175,6 @@ const renderMapOwnerActions = (filter, mapObj, params = {}) => {
const renderFilters = (filter, q, emptyMod = false, chip = null, search = null) =>
div({ class: search ? "filters activity-filter-chips activity-toolbar-row" : "filters" },
form({ method: "GET", action: "/maps", class: "ui-toolbar ui-toolbar--filters" },
input({ type: "hidden", name: "q", value: q || "" }),
...(emptyMod ? [] : [
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.mapFilterAll).toUpperCase()),
...(!chip || chip("recent") ? [button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, String(i18n.mapFilterRecent).toUpperCase())] : []),
@ -362,7 +362,7 @@ const renderPinPopup = (mk, i, ctx = {}) => {
(ctx.centerHref ? `<a class="map-center-link" href="${escapeHtml(ctx.centerHref)}">${escapeHtml(i18n.mapCenterHere)}</a>` : "") +
(own ? form({ method: "POST", action: contentDeleteAction('mapMarker', mk.key) },
ctx.returnTo ? input({ type: "hidden", name: "returnTo", value: ctx.returnTo }) : null,
button({ type: "submit", class: "btn-singleview btn-delete", title: i18n.mapDeleteMarker }, "✕")).outerHTML : "") + `</div>`
button({ type: "submit", class: "btn-singleview btn-delete", title: i18n.mapDeleteMarker }, renderIcon("close"))).outerHTML : "") + `</div>`
];
return parts.join("");
};
@ -393,7 +393,7 @@ const renderMarkersList = (markers, mapObj, ctx = {}) => {
centerHref ? a({ href: centerHref, class: "map-center-link" }, i18n.mapCenterHere) : null,
own ? form({ method: "POST", action: contentDeleteAction('mapMarker', mk.key) },
ctx.returnTo ? input({ type: "hidden", name: "returnTo", value: ctx.returnTo }) : null,
button({ type: "submit", class: "btn-singleview btn-delete", title: i18n.mapDeleteMarker }, "✕")) : null));
button({ type: "submit", class: "btn-singleview btn-delete", title: i18n.mapDeleteMarker }, renderIcon("close"))) : null));
})));
};

View file

@ -531,7 +531,6 @@ exports.singleMarketView = async (item, filter, comments = [], params = {}) => {
{ class: "filters" },
form(
{ method: "GET", action: "/market", class: "ui-toolbar ui-toolbar--filters" },
input({ type: "hidden", name: "q", value: q }),
input({ type: "hidden", name: "minPrice", value: minPrice ?? "" }),
input({ type: "hidden", name: "maxPrice", value: maxPrice ?? "" }),
input({ type: "hidden", name: "sort", value: sort }),

View file

@ -19,6 +19,7 @@ const detailHref = (type, key) => {
case 'file': return `/files/${encodeURIComponent(key)}`;
case 'document': return `/documents/${encodeURIComponent(key)}`;
case 'feed': return `/feed/${encodeURIComponent(key)}`;
case 'post': return `/blogs/${encodeURIComponent(key)}`;
case 'votes': return `/votes/${encodeURIComponent(key)}`;
case 'transfer': return `/transfers/${encodeURIComponent(key)}`;
case 'industry': return `/industry/${encodeURIComponent(key)}`;
@ -264,6 +265,16 @@ const renderContentHtml = (content, key) => {
)
);
}
case 'post':
return div({ class: 'opinion-blog' },
div({ class: 'card-section blog' },
content.contentWarning ? div({ class: 'shop-title-row' }, h2({ class: 'tribe-card-title' }, a({ href: `/blogs/${encodeURIComponent(key)}` }, content.contentWarning))) : "",
div({ class: 'blog-card-text', [RAW_HTML]: sanitizeHtml(renderStyledHtml(String(content.text || '').trim().length > 420 ? String(content.text).trim().slice(0, 420).replace(/\s+$/, '') + '…' : String(content.text || '').trim())) }),
Array.isArray(content.tags) && content.tags.length
? div({ class: 'card-tags' }, content.tags.map(tag => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)))
: null
)
);
case 'feed':
return div({ class: 'opinion-feed' },
div({ class: 'card-section feed' },
@ -392,12 +403,12 @@ exports.opinionsView = (items, filter, spreadMap = new Map(), q = '', allItems =
{ class: 'trending-card opinions-card' + (isOwn ? ' own-content' : '') },
div({ class: 'card-header activity-card-header' },
span({ class: 'pm-exposition-chip pm-exposition-whole' },
span({ class: 'pm-exposition-text' }, String(c.type || '').toUpperCase())
span({ class: 'pm-exposition-text' }, String(c.type === 'post' ? (i18n.typePost || 'BLOG') : (c.type || '')).toUpperCase())
),
renderContentActions(key, detailHref(c.type, key), {
spread: spreadMap.get(key) || null,
author: item.value.author,
reportTitle: c.title || c.question || c.concept || c.name || '',
reportTitle: c.title || c.question || c.concept || c.name || c.contentWarning || '',
...(CONTENT_TORRENTABLE.has(c.type) && typeof c.url === 'string' && c.url.startsWith('&')
? { torrentFrom: { blobId: c.url, name: c.title || c.fileName || '' } }
: {}),

View file

@ -1,6 +1,6 @@
const peersView = async ({ onlinePeers, discoveredPeers, unknownPeers, lanBroadcastActive = false, technicalPeers = [], versions = {}, staleKeys = [], onionKeys = [], ownVersion = '', lastErrors = {}, paused = false }) => {
const peersView = async ({ onlinePeers, discoveredPeers, unknownPeers, lanBroadcastActive = false, technicalPeers = [], versions = {}, staleKeys = [], onionKeys = [], ownVersion = '', lastErrors = {}, paused = false, filter = 'status', advanced = false, health = null, lan = null }) => {
const { form, button, div, h2, p, section, a, hr, input, label, br, span, table, tr, td, textarea, wbr } = require("../server/node_modules/hyperaxe");
const { template, i18n, renderStateChip } = require('./main_views');
const { template, i18n, renderStateChip, renderModuleStats } = require('./main_views');
const { NET_REASON_KEYS, canonicalKey } = require('../models/peer_health');
const majorMinor = (v) => String(v || '').split('.').slice(0, 2).map(n => parseInt(n, 10) || 0);
const isOutdated = (v) => {
@ -10,6 +10,8 @@ const peersView = async ({ onlinePeers, discoveredPeers, unknownPeers, lanBroadc
};
const stale = new Set(staleKeys || []);
const outdatedKey = (k) => isOutdated((versions || {})[k]) || (!(versions || {})[k] && stale.has(k));
const knowsVersion = (keys) => keys.some(k => (versions || {})[k] || stale.has(k));
const unspecifiedHost = (h) => /^(0\.0\.0\.0|::|\[::\])$/.test(String(h || ''));
const versionCell = (k) => {
const v = (versions || {})[k];
if (!v) return [stale.has(k) ? span({ title: i18n.peerVersionUnannounced }, renderStateChip('closed', null, String(i18n.peerOutdated).toUpperCase())) : '—'];
@ -56,7 +58,7 @@ const peersView = async ({ onlinePeers, discoveredPeers, unknownPeers, lanBroadc
};
const onion = new Set(onionKeys || []);
const overTor = (peerData) => /^onion:/i.test(String(peerData[0] || '')) || /\.onion$/i.test(String((peerData[1] || {}).host || '')) || onion.has(canonicalKey((peerData[1] || {}).key));
const renderPeerRow = (peerData) => {
const renderPeerRow = (peerData, showVersion) => {
const peer = peerData[1];
const { name, users, key } = peer;
const tor = overTor(peerData);
@ -64,9 +66,9 @@ const peersView = async ({ onlinePeers, discoveredPeers, unknownPeers, lanBroadc
const filteredUsers = (users || []).filter(u => u.id !== key);
const userCount = Array.isArray(users) ? filteredUsers.length : null;
return tr(
td({ 'data-label': i18n.peerHost || 'Pub' }, a({ href: peerUrl, class: "user-link", title: name || key }, name ? shortHost(name) : key.slice(0, 20) + '…')),
td({ 'data-label': i18n.peerHost || 'Pub' }, unspecifiedHost(name) ? '—' : a({ href: peerUrl, class: "user-link", title: name || key }, name ? shortHost(name) : key.slice(0, 20) + '…')),
td({ 'data-label': i18n.peersOasisId || 'Oasis ID' }, a({ href: peerUrl, class: 'user-link peer-key' }, key)),
td({ 'data-label': i18n.peersOasisVersion || 'Version' }, ...versionCell(key)),
showVersion ? td({ 'data-label': i18n.peersOasisVersion || 'Version' }, ...versionCell(key)) : null,
td({ 'data-label': i18n.peersTorColumn }, tor
? span({ class: 'ubi-tick-ok', title: i18n.peersTorYes }, '✓')
: span({ class: 'peer-tor-no', title: i18n.peersTorNo }, '—')),
@ -84,18 +86,20 @@ const peersView = async ({ onlinePeers, discoveredPeers, unknownPeers, lanBroadc
const renderPeerTable = (peers, emptyKey) => {
if (peers.length === 0) return p(i18n[emptyKey] || i18n.noConnections);
const showVersion = knowsVersion(peers.map(pd => pd[1].key));
return table({ class: 'block-info-table' },
tr(
td({ class: 'card-label' }, i18n.peerHost || 'Pub'),
td({ class: 'card-label' }, i18n.peersOasisId || 'Oasis ID'),
td({ class: 'card-label' }, i18n.peersOasisVersion || 'Version'),
showVersion ? td({ class: 'card-label' }, i18n.peersOasisVersion || 'Version') : null,
td({ class: 'card-label' }, i18n.peersTorColumn),
td({ class: 'card-label' }, i18n.peersReplicatedFeeds || 'Replicated')
),
...peers.map(renderPeerRow)
...peers.map(pd => renderPeerRow(pd, showVersion))
);
};
const techVersion = knowsVersion((technicalPeers || []).map(tp => tp.key));
const technicalRows = (technicalPeers || []).map(tp => {
const k = tp.key || '';
const connected = tp.state === 'connected';
@ -105,9 +109,9 @@ const peersView = async ({ onlinePeers, discoveredPeers, unknownPeers, lanBroadc
td({ 'data-label': i18n.peersOasisId }, a({ href: `/author/${encodeURIComponent(k)}`, class: 'user-link peer-key' }, k ? k.slice(0, 20) + '…' : '—')),
onion.has(canonicalKey(tp.key)) || /\.onion$/i.test(String(tp.host || ''))
? td({ 'data-label': i18n.peerAddressLabel, class: 'peer-host' }, renderStateChip('fediverse', null, 'TOR'))
: td({ 'data-label': i18n.peerAddressLabel, title: String(tp.host || ''), class: 'peer-host' }, tp.host ? shortHost(tp.host).split(/(?<=\.)/).reduce((acc, part, i) => i ? [...acc, wbr(), part] : [part], []) : '—'),
: td({ 'data-label': i18n.peerAddressLabel, title: unspecifiedHost(tp.host) ? '' : String(tp.host || ''), class: 'peer-host' }, tp.host && !unspecifiedHost(tp.host) ? shortHost(tp.host).split(/(?<=\.)/).reduce((acc, part, i) => i ? [...acc, wbr(), part] : [part], []) : '—'),
td({ 'data-label': i18n.peerPort, class: 'peer-port' }, String(tp.port || '—')),
td({ 'data-label': i18n.peersOasisVersion || 'Version' }, ...versionCell(k)),
techVersion ? td({ 'data-label': i18n.peersOasisVersion || 'Version' }, ...versionCell(k)) : null,
td({ 'data-label': i18n.peerStateLabel }, stateCell(tp)),
td({ 'data-label': i18n.peerLastChangeLabel }, String(tp.stateChange ? new Date(tp.stateChange).toISOString().slice(0, 16).replace('T', ' ') : '—')),
td(
@ -120,31 +124,44 @@ const peersView = async ({ onlinePeers, discoveredPeers, unknownPeers, lanBroadc
)
);
});
const refreshButton = form({ action: "/peers/refresh", method: "post" }, button({ type: "submit", class: "filter-btn" }, i18n.peerRefresh || 'Refresh'));
const pruneButton = form({ action: "/peers/prune", method: "post" }, button({ type: "submit", class: "filter-btn" }, i18n.peerPruneIdle || 'Remove idle'));
const exportButton = form({ action: "/peers/export", method: "get" }, button({ type: "submit", class: "filter-btn" }, i18n.peerExport || 'Export'));
const withCount = (label, n) => Number(n) > 0 ? `${label} (${n})` : label;
const peersToolbar = (tabName, total, segments) => {
if (!(Number(total) > 0)) return null;
const back = input({ type: "hidden", name: "returnTo", value: `/peers?filter=${tabName}` });
return div({ class: 'filters activity-filter-chips activity-toolbar-row' },
renderModuleStats(total, segments),
div({ class: "conn-actions peers-conn-actions filter-box" },
form({ action: "/peers/refresh", method: "post" }, back, button({ type: "submit", class: "filter-btn" }, i18n.peerRefresh || 'Refresh')),
form({ action: "/peers/prune", method: "post" }, back, tabName === 'lan' ? input({ type: "hidden", name: "scope", value: "lan" }) : null, button({ type: "submit", class: "filter-btn" }, i18n.peerPruneIdle || 'Remove idle')),
form({ action: "/peers/export", method: "get" }, button({ type: "submit", class: "filter-btn" }, i18n.peerExport || 'Export'))
)
);
};
const importForm = form(
{ action: "/peers/import", method: "post", enctype: "multipart/form-data", class: "peers-import-form" },
label({ class: 'peers-import-label' }, i18n.peerImportTitle || 'Import peer list'),
br(),
textarea({ name: "peerList", rows: "4", placeholder: i18n.peerImportPlaceholder || 'Paste one multiserver address per line…' }),
br(),
input({ type: "file", name: "peerFile", accept: ".txt,text/plain" }),
br(),
button({ type: "submit", class: "filter-btn" }, i18n.peerImport || 'Import')
);
const peersTechnicalBlock = div({ class: 'tags-header peers-technical-block' },
h2(i18n.peerConnectionsTitle || 'Connections'),
const peersTechnicalBlock = div({ class: 'peers-technical-block' },
peersToolbar('connections', technicalPeers.length, [
{ label: i18n.peerStateConnected, count: technicalPeers.filter(tp => tp.state === 'connected').length },
{ label: i18n.peerOutdated, count: technicalPeers.filter(tp => outdatedKey(tp.key)).length },
{ label: 'TOR', count: technicalPeers.filter(tp => onion.has(canonicalKey(tp.key)) || /\.onion$/i.test(String(tp.host || ''))).length }
]),
div({ class: "tags-header" }, h2(withCount(i18n.peerConnectionsTitle || 'Connections', technicalPeers.length)), p(i18n.peersConnectionsHint)),
div({ class: "peers-pause-row" }, pauseButton, p({ class: paused ? "peers-paused-note" : "peers-pause-hint" }, paused ? i18n.peersPausedNote : i18n.peersPauseHint)),
div({ class: "conn-actions peers-conn-actions filters" }, refreshButton, pruneButton, exportButton),
technicalPeers.length
? table({ class: 'block-info-table' },
tr(
td({ class: 'card-label' }, i18n.peersOasisId),
td({ class: 'card-label' }, i18n.peerAddressLabel),
td({ class: 'card-label peer-port' }, i18n.peerPort),
td({ class: 'card-label' }, i18n.peersOasisVersion || 'Version'),
techVersion ? td({ class: 'card-label' }, i18n.peersOasisVersion || 'Version') : null,
td({ class: 'card-label' }, i18n.peerStateLabel),
td({ class: 'card-label' }, i18n.peerLastChangeLabel),
td({ class: 'card-label' }, '')
@ -155,6 +172,121 @@ const peersView = async ({ onlinePeers, discoveredPeers, unknownPeers, lanBroadc
importForm
);
const fmtDate = (ts) => ts ? new Date(ts).toISOString().slice(0, 16).replace('T', ' ') : '—';
const healthRows = health && Array.isArray(health.rows) ? health.rows : [];
const healthKind = (r) => {
if (r.state === 'connected') return 'connected';
if (r.dead) return 'unreachable';
if ((Number(r.failures) || 0) > 0 || r.reason) return 'failing';
return 'reachable';
};
const healthChip = (r) => {
const k = healthKind(r);
if (k === 'connected') return renderStateChip('mutuals', null, String(i18n.peerStateConnected).toUpperCase());
if (k === 'unreachable') return renderStateChip('closed', null, String(i18n.graphosFedUnreachable).toUpperCase());
if (k === 'failing') return renderStateChip('hidden', null, String(i18n[NET_REASON_KEYS[r.reason] || 'peerErrSilent'] || i18n.peerErrOther).toUpperCase());
return renderStateChip(outdatedKey(r.key) ? 'whole' : 'mutuals', null, String(i18n.peersHealthReachable).toUpperCase());
};
const healthCount = (k) => healthRows.filter(r => healthKind(r) === k).length;
const deadCount = healthRows.filter(r => r.dead).length;
const healthVersion = knowsVersion(healthRows.map(r => r.key));
const healthBlock = !healthRows.length ? div({ class: 'peers-list' }, p(i18n.noConnections)) : div({ class: 'peers-technical-block peers-health-block' },
peersToolbar('health', healthRows.length, [
{ label: i18n.peersHealthReachable, count: healthCount('connected') + healthCount('reachable') },
{ label: i18n.peersHealthFailing, count: healthCount('failing') },
{ label: i18n.graphosFedUnreachable, count: deadCount },
{ label: i18n.peerOutdated, count: healthRows.filter(r => outdatedKey(r.key)).length },
{ label: 'TOR', count: healthRows.filter(r => r.tor).length }
]),
div({ class: "tags-header" }, h2(withCount(i18n.peersTabHealth, healthRows.length)), p(i18n.peersHealthHint)),
deadCount
? div({ class: "conn-actions peers-conn-actions filters" },
form({ action: "/peers/clear-unreachable", method: "post" }, button({ type: "submit", class: "filter-btn" }, i18n.peersHealthForget)))
: null,
healthRows.length
? table({ class: 'block-info-table' },
tr(
td({ class: 'card-label' }, i18n.peerHost || 'IP'),
td({ class: 'card-label' }, i18n.peersOasisId || 'ID'),
td({ class: 'card-label' }, i18n.graphosFedTransport),
td({ class: 'card-label' }, i18n.peerStateLabel),
td({ class: 'card-label' }, i18n.peersHealthFailures),
td({ class: 'card-label' }, i18n.peersFailingSince),
healthVersion ? td({ class: 'card-label' }, i18n.peersOasisVersion || 'Version') : null
),
...healthRows.map(r => tr(
r.tor
? td({ 'data-label': i18n.peerHost || 'IP', class: 'peer-host' }, renderStateChip('fediverse', null, 'TOR'))
: td({ 'data-label': i18n.peerHost || 'IP', title: unspecifiedHost(r.host) ? '' : String(r.host || ''), class: 'peer-host' }, r.host && !unspecifiedHost(r.host) ? shortHost(r.host) : '—'),
td({ 'data-label': i18n.peersOasisId || 'ID' }, a({ href: `/author/${encodeURIComponent(r.key)}`, class: 'user-link peer-key', title: r.name || r.key }, r.key)),
td({ 'data-label': i18n.graphosFedTransport }, r.tor ? renderStateChip('fediverse', null, 'TOR') : renderStateChip('transport', null, String(i18n.graphosFedClearnet).toUpperCase())),
td({ 'data-label': i18n.peerStateLabel }, healthChip(r)),
td({ 'data-label': i18n.peersHealthFailures }, String(Number(r.failures) || 0)),
td({ 'data-label': i18n.peersFailingSince }, r.failingSince ? fmtDate(r.failingSince) : '—'),
healthVersion ? td({ 'data-label': i18n.peersOasisVersion || 'Version' }, ...versionCell(r.key)) : null
))
)
: p(i18n.noConnections)
);
const lanRows = lan && Array.isArray(lan.rows) ? lan.rows : [];
const lanOn = !!(lan && lan.broadcasting);
const lanVersion = knowsVersion(lanRows.map(r => r.key));
const lanBlock = div({ class: 'peers-technical-block peers-lan-block' },
peersToolbar('lan', lanRows.length, [
{ label: i18n.online, count: lanRows.filter(r => r.online).length },
{ label: i18n.peerOutdated, count: lanRows.filter(r => outdatedKey(r.key)).length }
]),
div({ class: "tags-header" }, h2(withCount(i18n.peersTabLan, lanRows.length)), p(i18n.peersLanHint)),
div({ class: "peers-pause-row" },
p({ class: "peers-pause-hint" }, `${i18n.settingsLanTitle}: `, renderStateChip(lanOn ? 'mutuals' : 'hidden', null, String(lanOn ? i18n.modulesEnabledModulesLabel : i18n.modulesDisabledModulesLabel).toUpperCase()))
),
lanRows.length
? table({ class: 'block-info-table' },
tr(
td({ class: 'card-label' }, i18n.peersOasisId || 'ID'),
td({ class: 'card-label' }, i18n.peerStateLabel),
td({ class: 'card-label' }, i18n.peersLanLastSeen),
lanVersion ? td({ class: 'card-label' }, i18n.peersOasisVersion || 'Version') : null,
td({ class: 'card-label' }, '')
),
...lanRows.map(r => tr(
td({ 'data-label': i18n.peersOasisId || 'ID' }, a({ href: `/author/${encodeURIComponent(r.key)}`, class: 'user-link peer-key', title: r.name || r.key }, r.key)),
td({ 'data-label': i18n.peerStateLabel }, r.online ? renderStateChip('mutuals', null, String(i18n.online).toUpperCase()) : renderStateChip('hidden', null, String(i18n.offline).toUpperCase())),
td({ 'data-label': i18n.peersLanLastSeen }, fmtDate(r.seenAt)),
lanVersion ? td({ 'data-label': i18n.peersOasisVersion || 'Version' }, ...versionCell(r.key)) : null,
td(r.online ? null : form({ method: "POST", action: "/peers/lan/forget", class: "inline-form" },
input({ type: "hidden", name: "key", value: r.key }),
button({ type: "submit", class: "filter-btn" }, i18n.delete)
))
))
)
: p(i18n.peersLanEmpty)
);
const tabs = ['status', 'connections'].concat(advanced ? ['lan', 'health'] : []);
const tab = tabs.includes(filter) ? filter : 'status';
const tabLabel = { status: i18n.status || 'Status', connections: i18n.peerConnectionsTitle || 'Connections', lan: i18n.peersTabLan, health: i18n.peersTabHealth };
const statusToolbar = peersToolbar('status', onlineCount + discoveredCount + unknownCount, [
{ label: i18n.online, count: onlineCount },
{ label: i18n.discovered, count: discoveredCount },
{ label: i18n.unknown, count: unknownCount }
]);
const statusBlock = (onlineCount + discoveredCount + unknownCount) > 0
? div({ class: "peers-list" },
statusToolbar,
...[
[i18n.online, onlineCount, dedupOnline, 'noConnections', i18n.peersOnlineHint],
[i18n.discovered, discoveredCount, dedupDiscovered, 'noDiscovered', i18n.peersDiscoveredHint],
[i18n.unknown, unknownCount, dedupUnknown, 'noUnknownPeers', i18n.peersUnknownHint]
].filter(([, count]) => count > 0).flatMap(([title, count, list, emptyKey, hint], idx) => [
idx ? hr() : null,
div({ class: "tags-header" }, h2(withCount(title, count)), p(hint)),
renderPeerTable(list, emptyKey)
])
)
: div({ class: "peers-list" }, statusToolbar, p(i18n.noConnections));
return template(
i18n.peers,
section(
@ -162,20 +294,14 @@ const peersView = async ({ onlinePeers, discoveredPeers, unknownPeers, lanBroadc
h2(i18n.peers),
p(i18n.peerConnectionsIntro)
),
(onlineCount + discoveredCount + unknownCount) > 0
? div({ class: "peers-list" },
div({ class: "tags-header" }, h2(`${i18n.online} (${onlineCount})`)),
renderPeerTable(dedupOnline, 'noConnections'),
hr(),
div({ class: "tags-header" }, h2(`${i18n.discovered} (${discoveredCount})`)),
renderPeerTable(dedupDiscovered, 'noDiscovered'),
hr(),
div({ class: "tags-header" }, h2(`${i18n.unknown} (${unknownCount})`)),
renderPeerTable(dedupUnknown, 'noUnknownPeers')
)
: null,
peersTechnicalBlock,
p(i18n.connectionActionIntro)
div({ class: 'mode-buttons-row' },
...tabs.map(t => a({ class: tab === t ? 'filter-btn active' : 'filter-btn', href: `/peers?filter=${t}` }, String(tabLabel[t]).toUpperCase()))
),
tab === 'status' ? statusBlock : null,
tab === 'connections' ? peersTechnicalBlock : null,
tab === 'connections' ? p(i18n.connectionActionIntro) : null,
tab === 'lan' ? lanBlock : null,
tab === 'health' ? healthBlock : null
)
);
};

View file

@ -1,10 +1,11 @@
const { div, h2, p, section, button, form, input, span, audio, table, tr, td, th, br, a } = require("../server/node_modules/hyperaxe");
const { template, i18n, userLink, userLinkLabel, renderCallButton, liveClock, renderStateChip, renderVideoGrid } = require("./main_views");
const { template, i18n, userLink, userLinkLabel, renderCallButton, liveClock, renderStateChip, renderVideoGrid, renderIcon } = require("./main_views");
const { renderEncryptedChip } = require("./clearnet_view");
const { phoneNumberOf } = require("../models/phone_number");
const moment = require("../server/node_modules/moment");
const PAM_MAX_MS = require('../server/phone_module').VOICEMAIL_MAX_MS;
const GROUP_MAX = require('../server/phone_module').GROUP_MAX;
const clock = (ms) => {
const s = Math.max(0, Math.floor((Number(ms) || 0) / 1000));
@ -105,7 +106,7 @@ const NUMBER_TOKEN = /^\d{3}-\d{3}$/;
const renderCompose = (compose) => {
const recipients = Array.isArray(compose.recipients) ? compose.recipients : [];
const max = Number(compose.max) || 7;
const max = Number(compose.max) || GROUP_MAX;
const dial = String(compose.dial || '');
const full = recipients.length >= max;
const keep = () => [input({ type: 'hidden', name: 'filter', value: 'create' }), input({ type: 'hidden', name: 'to', value: compose.to || '' })];
@ -127,7 +128,7 @@ const renderCompose = (compose) => {
key('clear', '✕', 'phone-key phone-key-fn', { title: i18n.phoneDialClear })
)
),
p({ class: 'phone-empty phone-dial-hint' }, i18n.phoneLimitsHint),
p({ class: 'phone-empty phone-dial-hint' }, String(i18n.phoneLimitsHint).replace('{n}', String(max))),
div({ class: 'phone-recipients' },
span({ class: 'card-label' }, `${i18n.phoneDialInhabitants}: ${recipients.length}/${max}`),
recipients.length ? div({ class: 'phone-recipient-list' },
@ -183,7 +184,7 @@ const renderRecords = (pams) =>
const deleteCalls = (ids) => ids.length
? form({ method: 'POST', action: '/phone/delete-shown', class: 'content-action-form phone-row-delete' },
...ids.map(id => input({ type: 'hidden', name: 'calls', value: id })),
button({ type: 'submit', class: 'btn-singleview btn-delete', title: i18n.phoneDelete }, '✕')
button({ type: 'submit', class: 'btn-singleview btn-delete', title: i18n.phoneDelete }, renderIcon('close'))
)
: null;

View file

@ -75,7 +75,6 @@ const renderFilters = (filter, q, census, emptyMod) =>
section(
div({ class: "filters" },
form({ method: "GET", action: "/podcasts", class: "ui-toolbar ui-toolbar--filters" },
input({ type: "hidden", name: "q", value: q || "" }),
...(emptyMod ? [] : [
...chipButtons(BASE_FILTERS, filter, census, filterLabel),
...chipButtons(CATEGORIES, filter, census, catLabel)

View file

@ -308,7 +308,7 @@ exports.singlePollView = async (poll, comments = [], params = {}) => {
: (poll.voters.length
? div({ class: "card-assigned-list" }, ...poll.voters.map(v => userLink(v)))
: null),
renderEngagement(poll.id,
poll.tribeId ? null : renderEngagement(poll.id,
renderOpinionsVoting('/polls/opinions', poll.id, poll.opinions, href, poll.opinions_inhabitants),
renderComments(poll, comments, basePath)
)

View file

@ -1,5 +1,5 @@
const { div, h2, p, section, button, form, a, span, br, hr, label, textarea, input, select, option, img, table, tr, td, details, summary, ul, li } = require("../server/node_modules/hyperaxe")
const { clearnetItemHref, template, i18n, userLink, renderStateChip, renderLifespanChip, renderContentActions, renderInviteQrCard, renderModuleStatsBy, moduleIsEmpty, liveClock, renderPhoneChip, contentDeleteAction, paged, renderVideoGrid, renderLiveFeed } = require("./main_views")
const { clearnetItemHref, template, i18n, userLink, renderStateChip, renderLifespanChip, renderContentActions, renderInviteQrCard, renderModuleStatsBy, moduleIsEmpty, liveClock, renderPhoneChip, contentDeleteAction, paged, renderVideoGrid, renderLiveFeed, renderIcon } = require("./main_views")
const { renderEncryptedChip, renderTransportChip, renderReachChip, renderClearnetSelector, renderClearnetSwitch } = require("./clearnet_view")
const { renderStyledText } = require("../backend/renderStyledText")
const moment = require("../server/node_modules/moment")
@ -68,7 +68,7 @@ const renderModeButtons = (currentFilter, emptyMod = false, modesAvail = null) =
)
)
const renderCover = (room, cls) => room.image ? img({ loading: "lazy", src: `/blob/${encodeURIComponent(room.image)}`, class: cls, alt: "" }) : null
const renderCover = (room, cls) => room.image ? img({ loading: "lazy", src: String(room.image).startsWith("/blob/sealed/") ? room.image : `/blob/${encodeURIComponent(room.image)}`, class: cls, alt: "" }) : null
const roomIsFull = (occ) => !!occ && occ.count >= ((occ && occ.max) || ROOM_MAX)
@ -194,18 +194,6 @@ const renderRecChip = () => span({ class: "room-rec-chip" }, "● " + String(i18
const fmtDuration = (ms) => { const s = Math.max(0, Math.floor((Number(ms) || 0) / 1000)); return `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}` }
const refreshLink = (room) => a({ href: roomHref(room), class: "tribe-action-btn room-refresh-btn" }, String(i18n.liveRefresh).toUpperCase())
const renderLiveControls = (room, live) => div({ class: "room-live-controls" },
form({ method: "POST", action: "/rooms/notify", class: "phone-action-form" },
input({ type: "hidden", name: "on", value: live.notify ? "0" : "1" }),
button({ type: "submit", class: live.notify ? "tribe-action-btn" : "tribe-action-btn room-notify-off" }, "🔔 " + String(live.notify ? i18n.roomNotificationsOn : i18n.roomNotificationsOff).toUpperCase())
),
form({ method: "POST", action: "/rooms/hand", class: "phone-action-form" },
input({ type: "hidden", name: "hand", value: live.hand ? "0" : "1" }),
input({ type: "hidden", name: "returnTo", value: roomHref(room) }),
button({ type: "submit", class: live.hand ? "tribe-action-btn room-hand-btn-on" : "tribe-action-btn" }, "✋ " + String(live.hand ? i18n.roomLowerHand : i18n.roomRaiseHand).toUpperCase())
),
live.notify && safeArr(live.events).length
? form({ method: "POST", action: "/rooms/notify/clear", class: "phone-action-form" }, button({ type: "submit", class: "tribe-action-btn" }, String(i18n.roomClearNotices).toUpperCase()))
: null,
form({ method: "POST", action: "/rooms/mute", class: "phone-action-form" },
input({ type: "hidden", name: "mute", value: live.muted ? "0" : "1" }),
input({ type: "hidden", name: "returnTo", value: roomHref(room) }),
@ -214,14 +202,40 @@ const renderLiveControls = (room, live) => div({ class: "room-live-controls" },
live.videoAvailable
? form({ method: "POST", action: "/rooms/camera", class: "phone-action-form" },
input({ type: "hidden", name: "cam", value: live.cam ? "0" : "1" }),
input({ type: "hidden", name: "returnTo", value: roomHref(room) }),
input({ type: "hidden", name: "returnTo", value: `${roomHref(room)}#room-video` }),
button({ type: "submit", class: live.cam ? "tribe-action-btn room-cam-btn-on" : "tribe-action-btn" }, String(live.cam ? i18n.phoneCameraOff : i18n.phoneCameraOn).toUpperCase())
)
: null,
live.streaming != null
? form({ method: "POST", action: "/rooms/stream", class: "phone-action-form" },
input({ type: "hidden", name: "stop", value: "1" }),
input({ type: "hidden", name: "returnTo", value: roomHref(room) }),
button({ type: "submit", class: "tribe-action-btn room-cam-btn-on" }, String(i18n.roomStreamStop).toUpperCase())
)
: live.streamAvailable
? form({ method: "POST", action: "/rooms/stream", class: "phone-action-form room-stream-form" },
input({ type: "hidden", name: "returnTo", value: `${roomHref(room)}#room-video` }),
input({ type: "file", id: "room-stream-file", name: "video", accept: "video/*", required: true, class: "room-stream-input" }),
label({ for: "room-stream-file", class: "tribe-action-btn room-stream-pick" }, String(i18n.roomStreamFile).toUpperCase()),
button({ type: "submit", class: "tribe-action-btn room-cam-btn-on room-stream-go", title: i18n.roomStreamGo }, "▶")
)
: null,
form({ method: "POST", action: "/rooms/hand", class: "phone-action-form" },
input({ type: "hidden", name: "hand", value: live.hand ? "0" : "1" }),
input({ type: "hidden", name: "returnTo", value: roomHref(room) }),
button({ type: "submit", class: live.hand ? "tribe-action-btn room-hand-btn-on" : "tribe-action-btn" }, "✋ " + String(live.hand ? i18n.roomLowerHand : i18n.roomRaiseHand).toUpperCase())
),
live.recording
? form({ method: "POST", action: "/rooms/rec/stop", class: "phone-action-form" }, button({ type: "submit", class: "tribe-action-btn danger-btn" }, String(i18n.roomRecStop).toUpperCase()))
: form({ method: "POST", action: "/rooms/rec/start", class: "phone-action-form" }, button({ type: "submit", class: "tribe-action-btn" }, String(i18n.roomRec).toUpperCase())),
live.recording || safeArr(live.recordingBy).length ? renderRecChip() : null,
form({ method: "POST", action: "/rooms/notify", class: "phone-action-form" },
input({ type: "hidden", name: "on", value: live.notify ? "0" : "1" }),
button({ type: "submit", class: live.notify ? "tribe-action-btn" : "tribe-action-btn room-notify-off" }, "🔔 " + String(live.notify ? i18n.roomNotificationsOn : i18n.roomNotificationsOff).toUpperCase())
),
live.notify && safeArr(live.events).length
? form({ method: "POST", action: "/rooms/notify/clear", class: "phone-action-form" }, button({ type: "submit", class: "tribe-action-btn" }, String(i18n.roomClearNotices).toUpperCase()))
: null,
refreshLink(room),
form({ method: "POST", action: "/rooms/leave", class: "phone-action-form" },
input({ type: "hidden", name: "returnTo", value: roomHref(room) }),
@ -245,7 +259,7 @@ const renderRecordings = (recordings) => safeArr(recordings).length
span({ class: "phone-call-clock" }, fmtDuration(r.durationMs)),
a({ href: `/rooms/recordings/${encodeURIComponent(r.name)}`, class: "tribe-action-btn", download: r.name }, String(i18n.fileDownload).toUpperCase()),
form({ method: "POST", action: `/rooms/recordings/${encodeURIComponent(r.name)}/delete`, class: "phone-action-form" },
button({ type: "submit", class: "btn-singleview btn-delete", title: i18n.delete }, "✕"))
button({ type: "submit", class: "btn-singleview btn-delete", title: i18n.delete }, renderIcon("close")))
)))
)
: null
@ -260,34 +274,44 @@ const renderLivePanel = (room, live, occ, canJoin = false) => {
input({ type: "hidden", name: "on", value: on ? "0" : "1" }),
button({ type: "submit", class: on ? "tribe-action-btn danger-btn" : "tribe-action-btn" }, String(on ? i18n.phoneUnsilence : i18n.phoneSilence).toUpperCase())
)
const who = (id, muted, you, silenced = false, recording = false, turn = 0, cam = false) => div({ class: "phone-contact room-peer" + (turn ? " room-peer-raised" : "") },
const isPublic = !!live.public || (room.clearnet === true && clearnetEligible(room))
const who = (id, muted, you, silenced = false, recording = false, turn = 0, cam = false, film = false, viewer = false) => viewer
? div({ class: "phone-contact room-peer" }, userLink(id), renderReachChip(true, i18n))
: div({ class: "phone-contact room-peer" + (turn ? " room-peer-raised" : "") },
turn ? span({ class: "room-peer-hand" }, `✋ ${turn}`) : null,
userLink(id),
you ? span({ class: "room-peer-you" }, i18n.roomYou) : null,
muted ? span({ class: "room-peer-muted" }, String(i18n.roomMuted).toUpperCase()) : null,
cam ? span({ class: "room-peer-cam" }, "CAM") : null,
film ? span({ class: "room-peer-cam" }, "▶") : null,
recording ? renderRecChip() : null,
you ? null : silenceForm(id, silenced)
)
return div({ class: "phone-call-panel room-live-panel" },
head(div({ class: "card-chips-row" }, renderEncryptedChip(i18n), renderLiveChip({ count: live.count, max: live.max }), live.joinedAt ? span({ class: "phone-call-clock" }, liveClock(live.joinedAt)) : null), renderLiveControls(room, live)),
head(div({ class: "card-chips-row" }, renderEncryptedChip(i18n), isPublic ? renderReachChip(true, i18n, roomClearnetHref(room)) : null, renderLiveChip({ count: live.count, max: live.max }), live.joinedAt ? span({ class: "phone-call-clock" }, liveClock(live.joinedAt)) : null, live.streaming != null ? span({ class: "room-stream-chip", title: live.streaming || "" }, "▶ " + String(live.streaming || i18n.roomStreaming)) : null), renderLiveControls(room, live)),
live.grid
? (safeArr(live.gridOrder).length
? div({ class: "video-grid video-grid-one" },
? div({ id: "room-video", class: safeArr(live.gridOrder).length === 1 ? "video-grid video-grid-one video-grid-solo" : "video-grid video-grid-one" },
...renderLiveFeed("/phone/video/grid", "video-feed video-feed-grid"),
div({ class: "video-grid-names" }, ...safeArr(live.gridOrder).map(id => id === userId ? span({ class: "video-tile-name" }, i18n.roomYou) : userLink(id)))
div({ class: "video-grid-names" }, ...safeArr(live.gridOrder).map(key => {
const film = String(key).startsWith("film:")
const id = film ? String(key).slice(5) : key
const name = id === userId ? i18n.roomYou : userLink(id)
return film ? span({ class: "video-tile-name video-tile-film" }, "▶ ", name) : (id === userId ? span({ class: "video-tile-name" }, name) : name)
}))
)
: null)
: renderVideoGrid(safeArr(live.peers).map(p => ({ id: p.id, on: !!p.cam })), !!live.cam),
: div({ id: "room-video" }, renderVideoGrid(safeArr(live.peers).map(p => ({ id: p.id, on: !!p.cam, film: !!p.film })), !!live.cam, { film: live.streaming != null })),
div({ class: "phone-contacts" }, ...handOrder(live).map(e => e.you
? who(userId, live.muted, true, false, !!live.recording, e.turn, !!live.cam)
: who(e.peer.id, e.peer.muted, false, !!e.peer.silenced, !!e.peer.recording, e.turn, !!e.peer.cam))),
? who(userId, live.muted, true, false, !!live.recording, e.turn, !!live.cam, live.streaming != null)
: who(e.peer.id, e.peer.muted, false, !!e.peer.silenced, !!e.peer.recording, e.turn, !!e.peer.cam, !!e.peer.film, !!e.peer.viewer))),
renderRoomEvents(live)
)
}
const count = occ ? occ.count : 0
return div({ class: "phone-block room-live-panel" },
head(p({ class: "phone-empty" }, count > 0 ? String(i18n.roomSomeone).replace("{n}", String(count)) : i18n.roomNobody), canJoin ? div({ class: "room-live-controls" }, refreshLink(room), joinForm(room, occ)) : null)
head(p({ class: "phone-empty" }, count > 0 ? String(i18n.roomSomeone).replace("{n}", String(count)) : i18n.roomNobody), canJoin ? div({ class: "room-live-controls" }, refreshLink(room), joinForm(room, occ)) : null),
canJoin && room.clearnet === true && clearnetEligible(room) ? p({ class: "room-public-warning" }, i18n.roomPublicWarning) : null
)
}
@ -327,9 +351,13 @@ exports.singleRoomView = async (room, params = {}) => {
const ownerActions = isAuthor
? [
room.isClosed ? null : form({ method: "POST", action: `/rooms/close/${encodeURIComponent(room.rootId)}` },
button({ type: "submit", class: "update-btn" }, i18n.roomClose)
),
room.isClosed
? form({ method: "POST", action: `/rooms/reopen/${encodeURIComponent(room.rootId)}` },
button({ type: "submit", class: "update-btn" }, i18n.roomReopen)
)
: form({ method: "POST", action: `/rooms/close/${encodeURIComponent(room.rootId)}` },
button({ type: "submit", class: "update-btn delete-btn" }, i18n.roomClose)
),
form({ method: "GET", action: "/rooms" },
input({ type: "hidden", name: "filter", value: "edit" }),
input({ type: "hidden", name: "id", value: room.rootId }),
@ -423,8 +451,8 @@ exports.renderTribeRoomsSection = (tribe, rooms, occupancy, live, toolbar = null
exports.renderRoomItem = renderRoomItem
exports.roomStatsBy = (rooms) => renderModuleStatsBy(safeArr(rooms), r => r.isClosed ? "CLOSED" : r.type, [{ value: "OPEN", label: i18n.roomStatusOpen }, { value: "INVITE-ONLY", label: i18n.roomStatusInviteOnly }, { value: "CLOSED", label: i18n.roomStatusClosed }])
exports.clearnetRoomView = async (room) => {
const { escapeHtml: esc, renderKindTag, renderClearnetPage } = require("./clearnet_view")
exports.clearnetRoomView = async (room, { live = false, sound = true, base = "" } = {}) => {
const { escapeHtml: esc, renderKindTag, renderClearnetPage, cnZoom } = require("./clearnet_view")
const name = room.title || i18n.cnKindRoom
const extraCss = `
.cn-room-title{color:var(--fg);margin:0 0 16px 0;font-size:32px;font-weight:700;word-break:break-word}
@ -432,6 +460,13 @@ exports.clearnetRoomView = async (room) => {
.cn-room-number{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--fg);border-radius:10px;padding:4px 12px;font-size:16px;font-weight:600;color:var(--fg);background:var(--bg-sub)}
.cn-room-number .pm-exposition-icon{font-size:18px;line-height:1}
.cn-room-number .pm-exposition-text{line-height:1;letter-spacing:1px;font-family:monospace}
.cn-room-live{display:flex;flex-direction:column;align-items:center;gap:12px;margin-top:24px}
.cn-room-live-chip{align-self:flex-start;border:1px solid #2ecc71;border-radius:10px;padding:3px 10px;color:#2ecc71;font-size:13px;font-weight:700;letter-spacing:1px}
.cn-room-live .cn-zoom{display:block;width:100%;max-width:960px}
.cn-room-grid{width:100%;aspect-ratio:4/3;object-fit:contain;background:#000;border-radius:8px}
.cn-room-sound{width:100%;max-width:960px}
.cn-room-live .cn-lightbox-img{width:92vw;height:88vh;object-fit:contain}
.cn-room-live .cn-lightbox-full .cn-lightbox-img{width:100vw;height:100vh}
`
const body = `
<h1 class="cn-room-title">${esc(name)}</h1>
@ -439,6 +474,11 @@ exports.clearnetRoomView = async (room) => {
${renderKindTag("room")}
${room.number ? `<span class="pm-exposition-chip pm-exposition-whole cn-room-number"><span class="pm-exposition-icon">✆</span><span class="pm-exposition-text">${esc(room.number)}</span></span>` : ""}
</div>
${live && base ? `<section class="cn-room-live">
<span class="cn-room-live-chip">● ${esc(String(i18n.roomLiveChip || "LIVE").toUpperCase())}</span>
${cnZoom("room-live", `${base}/live`, name, { full: true, imgClass: "cn-room-grid" })}
${sound ? `<audio class="cn-room-sound" controls preload="none" src="${esc(`${base}/sound`)}"></audio>` : ""}
</section>` : ""}
`
return renderClearnetPage({
title: `${name} | Oasis`,
@ -461,3 +501,26 @@ exports.roomModesFromCensus = (census, me, occupancy) => {
closed: list.some(x => x.isClosed)
}
}
const pickerSize = (bytes) => {
const n = Number(bytes) || 0
if (n >= 1073741824) return `${(n / 1073741824).toFixed(1)} GB`
if (n >= 1048576) return `${(n / 1048576).toFixed(1)} MB`
return `${Math.max(1, Math.round(n / 1024))} KB`
}
exports.roomStreamMatchesView = ({ files, returnTo }) => template(
i18n.roomStreamPickTitle,
section(
div({ class: "tags-header module-header-line" }, h2(i18n.roomStreamPickTitle), p(i18n.roomStreamPickHint)),
div({ class: "mode-buttons-row" }, a({ href: returnTo, class: "filter-btn" }, String(i18n.phoneCancel).toUpperCase())),
ul({ class: "stream-picker-list" }, ...safeArr(files).map(f => li({ class: "stream-picker-item" },
span({ class: "stream-picker-link" }, renderIcon("film"), span({ class: "stream-picker-name" }, f.path), span({ class: "stream-picker-size" }, pickerSize(f.size))),
form({ method: "POST", action: "/rooms/stream", class: "phone-action-form" },
input({ type: "hidden", name: "file", value: f.path }),
input({ type: "hidden", name: "returnTo", value: returnTo }),
button({ type: "submit", class: "tribe-action-btn" }, String(i18n.roomStreamFile).toUpperCase())
)
)))
)
)

View file

@ -1,6 +1,6 @@
const { form, button, div, h2, p, section, input, label, select, option, img, audio: audioHyperaxe, video: videoHyperaxe, table, hr, hd, br, td, tr, th, a, span } = require("../server/node_modules/hyperaxe");
const RAW_HTML = Symbol.for('oasis.rawHtml');
const { template, i18n, userLink, renderContentActions, CONTENT_FAV_KIND, CONTENT_SPREADABLE, CONTENT_TORRENTABLE, paged, currentPerPage, LIST_PAGE_SIZES, pageSizeLabel } = require('./main_views');
const { template, i18n, userLink, renderContentActions, renderModuleStats, CONTENT_FAV_KIND, CONTENT_SPREADABLE, CONTENT_TORRENTABLE, paged, currentPerPage, LIST_PAGE_SIZES, pageSizeLabel } = require('./main_views');
const moment = require("../server/node_modules/moment");
const { renderStyledHtml, safeExternalHref } = require('../backend/renderStyledText');
const { renderStyledText } = require('../backend/renderStyledText');
@ -18,16 +18,51 @@ const industryStatusLabel = (status) => {
};
const searchView = ({ messages = [], blobs = {}, query = "", type = "", types = [], hashtag = null, results = {}, spreadMap = null, favIndex = null }) => {
const searchInput = input({
id: "search_query",
name: "query",
required: false,
type: "search",
value: query,
placeholder: i18n.searchPlaceholder
});
const renderSearchForm = ({ action, query = "", queryName = "query", hidden = [] } = {}) => {
const searchInput = input({ id: "search_query", name: queryName, required: false, type: "search", value: query, placeholder: i18n.searchPlaceholder });
searchInput.setAttribute("minlength", 3);
const resultCount = currentPerPage();
const resultsPerPageSelect = select(
{ id: "results-per-page", name: "perPage", class: "input-select search-select" },
LIST_PAGE_SIZES.map(s => option({ value: s, selected: resultCount === s ? "selected" : undefined }, pageSizeLabel(s)))
);
return form(
{ action, method: "GET", class: "search-form" },
...hidden.map(([name, value]) => input({ type: "hidden", name, value })),
div({ class: "search-filters-row" },
table({ class: "search-filters-table" },
tr(
td({ class: 'card-label' }, label({ for: "search_from" }, i18n.searchFromLabel || "From")),
td(input({ id: "search_from", type: "datetime-local", name: "from" }))
),
tr(
td({ class: 'card-label' }, label({ for: "search_to" }, i18n.searchToLabel || "To")),
td(input({ id: "search_to", type: "datetime-local", name: "to" }))
),
tr(
td({ class: 'card-label' }, label({ for: "search_inhabitant" }, "Oasis ID")),
td(input({ id: "search_inhabitant", type: "text", name: "inhabitant", placeholder: "@...=.ed25519", pattern: "@[A-Za-z0-9+/_\\-]{43}=\\.ed25519", maxlength: 56, class: "search-oasis-id" }))
)
),
table({ class: "search-filters-table" },
tr(
td({ class: 'card-label' }, label({ for: "search_query" }, i18n.searchQueryLabel || "Query")),
td(searchInput)
),
tr(
td({ class: 'card-label' }, label({ for: "results-per-page" }, i18n.searchPerPageLabel || "Results per page")),
td(resultsPerPageSelect)
)
)
),
div({ class: "search-submit-row" },
button({ type: "submit" }, i18n.searchSubmit)
)
);
};
exports.renderSearchForm = renderSearchForm;
const searchView = ({ messages = [], blobs = {}, query = "", type = "", types = [], hashtag = null, results = {}, spreadMap = null, favIndex = null }) => {
const contentTypes = [
"post", "about", "curriculum", "tribe", "market", "transfer", "feed", "votes",
@ -49,15 +84,6 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
)
);
const resultCount = currentPerPage();
const resultsPerPageSelect = select(
{
id: "results-per-page",
name: "perPage",
class: "input-select search-select"
},
LIST_PAGE_SIZES.map(s => option({ value: s, selected: resultCount === s ? "selected" : undefined }, pageSizeLabel(s)))
);
const getViewDetailsActionForSearch = (type, contentId, content) => {
switch (type) {
@ -75,7 +101,9 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
case 'bookmark': return `/bookmarks/${encodeURIComponent(contentId)}`;
case 'event': return `/events/${encodeURIComponent(contentId)}`;
case 'task': return `/tasks/${encodeURIComponent(contentId)}`;
case 'post': return `/thread/${encodeURIComponent(contentId)}#${encodeURIComponent(contentId)}`;
case 'post': return content && typeof content.root === 'string'
? `/thread/${encodeURIComponent(content.root)}#${encodeURIComponent(contentId)}`
: `/blogs/${encodeURIComponent(contentId)}`;
case 'market': return `/market/${encodeURIComponent(contentId)}`;
case 'report': return `/reports/${encodeURIComponent(contentId)}`;
case 'project': return `/projects/${encodeURIComponent(contentId)}`;
@ -124,8 +152,11 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
switch (content.type) {
case 'post':
return div({ class: 'search-post' },
content.contentWarning ? h2({ class: 'card-field' }, span({ class: 'card-value' }, content.contentWarning)) : null,
content.text ? div({ class: 'card-field' }, span({ class: 'card-value', [RAW_HTML]: sanitizeHtml(renderStyledHtml(content.text)) })) : null
content.contentWarning ? div({ class: 'shop-title-row' }, h2({ class: 'tribe-card-title' }, content.contentWarning)) : null,
content.text ? div({ class: 'blog-card-text post-text-clamped', [RAW_HTML]: sanitizeHtml(renderStyledHtml(String(content.text).trim().length > 1000 ? String(content.text).trim().slice(0, 1000).replace(/\s+$/, '') + '…' : String(content.text).trim())) }) : null,
Array.isArray(content.tags) && content.tags.length
? div({ class: 'card-tags' }, content.tags.map(tag => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)))
: null
);
case 'about':
return div({ class: 'card-section about' },
@ -596,6 +627,12 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
}
};
const typeLabel = (type) => String(i18n['type' + String(type || '').charAt(0).toUpperCase() + String(type || '').slice(1)] || type || '').toUpperCase();
const totalResults = Object.values(results).reduce((n, msgs) => n + (Array.isArray(msgs) ? msgs.length : 0), 0);
const resultStats = renderModuleStats(totalResults,
Object.entries(results)
.map(([type, msgs]) => ({ label: typeLabel(type), count: Array.isArray(msgs) ? msgs.length : 0 }))
.sort((x, y) => y.count - x.count || x.label.localeCompare(y.label)));
const pageGroups = paged(Object.entries(results).flatMap(([key, msgs]) => msgs.map((msg) => [key, msg])))
.reduce((acc, [key, msg]) => { (acc[key] = acc[key] || []).push(msg); return acc; }, {});
const resultSection = Object.entries(results).length > 0
@ -642,7 +679,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
div({ class: 'card-header activity-card-header' },
div({ class: 'card-chips-row' },
span({ class: 'pm-exposition-chip pm-exposition-whole' },
span({ class: 'pm-exposition-text' }, String(content.type || '').toUpperCase())
span({ class: 'pm-exposition-text' }, String(i18n['type' + String(content.type || '').charAt(0).toUpperCase() + String(content.type || '').slice(1)] || content.type || '').toUpperCase())
)
),
renderContentActions(contentId, getViewDetailsActionForSearch(content.type, contentId, content), {
@ -676,46 +713,18 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
: null);
let html = template(
hashtag ? `#${hashtag}` : i18n.searchTitle,
i18n.searchTitle,
section(
div({ class: "tags-header module-header-line" },
h2(hashtag ? `#${hashtag}` : i18n.searchTitle),
h2(i18n.searchTitle),
p(hashtag ? i18n.hashtagDescription : i18n.searchDescriptionLabel)
),
form(
{ action: "/search", method: "GET", class: "search-form" },
div({ class: "search-filters-row" },
table({ class: "search-filters-table" },
tr(
td({ class: 'card-label' }, label({ for: "search_from" }, i18n.searchFromLabel || "From")),
td(input({ id: "search_from", type: "datetime-local", name: "from" }))
),
tr(
td({ class: 'card-label' }, label({ for: "search_to" }, i18n.searchToLabel || "To")),
td(input({ id: "search_to", type: "datetime-local", name: "to" }))
),
tr(
td({ class: 'card-label' }, label({ for: "search_inhabitant" }, "Oasis ID")),
td(input({ id: "search_inhabitant", type: "text", name: "inhabitant", placeholder: "@...=.ed25519", pattern: "@[A-Za-z0-9+/_\\-]{43}=\\.ed25519", maxlength: 56, class: "search-oasis-id" }))
)
),
table({ class: "search-filters-table" },
tr(
td({ class: 'card-label' }, label({ for: "search_query" }, i18n.searchQueryLabel || "Query")),
td(searchInput)
),
tr(
td({ class: 'card-label' }, label({ for: "results-per-page" }, i18n.searchPerPageLabel || "Results per page")),
td(resultsPerPageSelect)
)
)
),
div({ class: "search-submit-row" },
button({ type: "submit" }, i18n.searchSubmit)
)
)
renderSearchForm({ action: "/search", query })
),
section(resultSection)
section(
resultStats ? div({ class: 'filters activity-filter-chips activity-toolbar-row search-stats-row' }, resultStats) : null,
resultSection
)
);
if (hasDocument) {

View file

@ -2,8 +2,9 @@ const { form, button, div, h2, h3, p, section, select, option, input, br, a, lab
const fs = require('fs');
const path = require('path');
const { getConfig } = require('../configs/config-manager.js');
const { template, selectedLanguage, i18n, setLanguage, INBOX_BOT_ORDER, inboxBotLabel, THEMES } = require('./main_views');
const { template, selectedLanguage, i18n, setLanguage, INBOX_BOT_ORDER, inboxBotLabel, THEMES, userLink } = require('./main_views');
const i18nBase = require("../client/assets/translations/i18n");
const moment = require("../server/node_modules/moment");
const { WORKFLOWS, currentWorkflow } = require('../models/workflows_model');
const html = require("../server/node_modules/hyperaxe");
// selector de identidades: la implementacion vive en ./identities_view.js
@ -12,6 +13,8 @@ const { renderVerificationReport, renderRebuildReport } = require('./backup_view
const snhUrl = "https://wiki.solarnethub.com/socialnet/overview";
const BLOB_CACHE_OPTIONS = [512, 1024, 2048, 5120, 10240, 0];
const plainLabel = (s) => String(s || '').replace(/!\[[^\]]*\]\([^)]*\)/g, '').replace(/\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/\s+/g, ' ').trim();
const hiddenAt = (at) => at ? moment(at).format("YYYY/MM/DD HH:mm") : "—";
const sizeLabel = (bytes) => {
let value = Number(bytes) || 0;
const units = ['B', 'KB', 'MB', 'GB'];
@ -44,7 +47,9 @@ const settingsView = (params) => {
const walletUser = currentConfig.wallet.user;
const walletFee = currentConfig.wallet.fee;
const currentWish = ['mutuals', 'only-lan', 'local'].includes(currentConfig.wish) ? currentConfig.wish : 'whole';
const currentPmVisibility = currentConfig.pmVisibility === 'mutuals' ? 'mutuals' : 'whole';
const currentPmVisibility = ['mutuals', 'following'].includes(currentConfig.pmVisibility) ? currentConfig.pmVisibility : 'whole';
const currentMentionsVisibility = ['mutuals', 'following'].includes(currentConfig.mentionsVisibility) ? currentConfig.mentionsVisibility : 'whole';
const viewerHidden = (() => { try { const vf = require('../models/viewer_filters'); return { muted: vf.mutedList(), hidden: vf.hiddenList() }; } catch (_) { return { muted: [], hidden: [] }; } })();
const currentPhone = currentConfig.phone && typeof currentConfig.phone === 'object' ? currentConfig.phone : {};
const themeElements = THEMES.map(([value, name]) => option({ value, ...(theme === value ? { selected: true } : {}) }, name));
@ -115,7 +120,7 @@ const settingsView = (params) => {
);
}
const filter = params.filter === 'multiverse' && modOn('fediverse') ? 'multiverse' : 'oasis';
const filter = params.filter === 'multiverse' && modOn('fediverse') ? 'multiverse' : params.filter === 'content' ? 'content' : 'oasis';
const oasisParts = [
updatePanel,
renderIdentities(require('../server/ssb_config').account),
@ -156,7 +161,8 @@ const settingsView = (params) => {
const aiNavEnabled = currentConfig.modules && currentConfig.modules.aiNavMod === 'on';
const chatsEnabled = currentConfig.modules && currentConfig.modules.chatsMod === 'on';
const phoneEnabled = !!(currentConfig.modules && currentConfig.modules.phoneMod !== 'off');
const cur = currentConfig.ux?.current === "ainav" ? "ainav" : currentConfig.ux?.current === "chats" ? "chats" : currentConfig.ux?.current === "feed" ? "feed" : currentConfig.ux?.current === "phone" ? "phone" : "blocks";
const roomsEnabled = !!(currentConfig.modules && currentConfig.modules.roomsMod !== 'off');
const cur = currentConfig.ux?.current === "ainav" ? "ainav" : currentConfig.ux?.current === "chats" ? "chats" : currentConfig.ux?.current === "feed" ? "feed" : currentConfig.ux?.current === "phone" ? "phone" : currentConfig.ux?.current === "rooms" ? "rooms" : "blocks";
const uxCard = (value, title, image) => label({ class: "welcome-ux-option" },
input({ type: "radio", name: "ux", value, ...(cur === value ? { checked: true } : {}) }),
img({ src: image, class: "welcome-ux-shot", alt: title }),
@ -167,7 +173,8 @@ const settingsView = (params) => {
aiNavEnabled ? uxCard("ainav", i18n.uxModeAINav || "AI", "/assets/images/ux-ainav.png") : null,
chatsEnabled ? uxCard("chats", i18n.uxModeChats || "Conversations", "/assets/images/ux-chats.png") : null,
uxCard("feed", i18n.uxModeFeed || "Microblogging", "/assets/images/ux-feed.png"),
phoneEnabled ? uxCard("phone", i18n.phoneTitle || "Phone", "/assets/images/ux-phone.png") : null
phoneEnabled ? uxCard("phone", i18n.phoneTitle || "Phone", "/assets/images/ux-phone.png") : null,
roomsEnabled ? uxCard("rooms", i18n.roomsTitle || "Rooms", "/assets/images/ux-rooms.png") : null
);
})(),
button({ type: "submit" }, i18n.saveSettings)
@ -187,22 +194,6 @@ const settingsView = (params) => {
)
)
),
section({ id: "wish" },
div({ class: "tags-header" },
h2(i18n.settingsWishTitle),
p(i18n.settingsWishDesc),
form(
{ action: "/settings/wish", method: "POST" },
select({ name: "wish" },
option({ value: "whole", ...(currentWish === "whole" ? { selected: true } : {})}, i18n.settingsWishWhole),
option({ value: "mutuals", ...(currentWish === "mutuals" ? { selected: true } : {})}, i18n.settingsWishMutuals),
option({ value: "only-lan", ...(currentWish === "only-lan" ? { selected: true } : {})}, i18n.settingsWishOnlyLan || "Only LAN"),
option({ value: "local", ...(currentWish === "local" ? { selected: true } : {})}, i18n.settingsWishLocal || "Local")
), br(), br(),
button({ type: "submit" }, i18n.saveSettings)
)
)
),
workflowSection,
section({ id: "home-page" },
div({ class: "tags-header" },
@ -282,48 +273,10 @@ const settingsView = (params) => {
)
)
),
blobCache ? section({ id: "blobcache" },
div({ class: "tags-header" },
h2(i18n.settingsBlobCacheTitle),
p(i18n.settingsBlobCacheDesc),
blobCache.usage ? p({ class: "blob-cache-usage" }, strong(`${i18n.settingsBlobCacheUsage}: `), `${sizeLabel(blobCache.usage.bytes)} · ${blobCache.usage.count} ${i18n.settingsBlobCacheFiles}`) : null,
blobCache.cleaned !== null ? p({ class: "blob-cache-notice" }, String(i18n.settingsBlobCacheCleaned).replace('{n}', String(blobCache.cleaned)).replace('{size}', sizeLabel(blobCache.freed))) : null,
form(
{ action: "/settings/blob-cache", method: "POST" },
label({ for: "blob_cache_mb" }, i18n.settingsBlobCacheLimit),
br(),
select({ id: "blob_cache_mb", name: "blob_cache_mb" }, ...BLOB_CACHE_OPTIONS.map(mb => option({ value: String(mb), ...(mb === blobCache.maxMB ? { selected: true } : {}) }, blobCacheOptionLabel(mb)))),
br(), br(),
button({ type: "submit" }, i18n.saveSettings),
blobCache.maxMB > 0 ? button({ type: "submit", formaction: "/settings/blob-cache/collect", class: "blob-cache-clean-btn" }, i18n.settingsBlobCacheCleanNow) : null
)
)
) : null,
section({ id: "replication" },
div({ class: "tags-header" },
h2(i18n.settingsReplicationTitle || 'Replication'),
p(i18n.settingsReplicationDesc || 'Configure the number of hops your peer follows out from your own feed when replicating content.'),
form(
{ action: "/settings/replication", method: "POST" },
label({ for: "replication_hops" }, i18n.settingsReplicationHopsLabel || 'Hops'),
br(),
input({
type: "number",
id: "replication_hops",
name: "hops",
min: 0,
max: 6,
value: currentHops
}),
br(), br(),
button({ type: "submit" }, i18n.saveSettings)
)
)
),
section({ id: "lan" },
div({ class: "tags-header" },
h2(i18n.settingsLanTitle || 'LAN Broadcasting'),
p(i18n.settingsLanDesc || 'Periodically announce this peer to other Oasis instances on the same local network. Disable to stop UDP broadcasts.'),
p(i18n.settingsLanDesc || 'Periodically announce this peer to other Oasis instances on the same local network.'),
form(
{ action: "/settings/lan-broadcasting", method: "POST" },
label({ for: "lanBroadcasting", class: "lan-checkbox-label" },
@ -342,20 +295,6 @@ const settingsView = (params) => {
)
)
),
section({ id: "pm-visibility" },
div({ class: "tags-header" },
h2(i18n.settingsPmVisibilityTitle),
p(i18n.settingsPmVisibilityDesc),
form(
{ action: "/settings/pm-visibility", method: "POST" },
select({ name: "pmVisibility" },
option({ value: "whole", ...(currentPmVisibility === "whole" ? { selected: true } : {})}, i18n.settingsPmVisibilityWhole),
option({ value: "mutuals", ...(currentPmVisibility === "mutuals" ? { selected: true } : {})}, i18n.settingsPmVisibilityMutuals)
), br(), br(),
button({ type: "submit" }, i18n.saveSettings)
)
)
),
modOn('phone') ? section({ id: "phone" },
div({ class: "tags-header" },
h2(i18n.phoneTitle),
@ -376,30 +315,6 @@ const settingsView = (params) => {
)
)
) : null,
modOn('inbox') ? section({ id: "inbox-bots" },
div({ class: "tags-header" },
h2(i18n.settingsInboxBotsTitle),
p(i18n.settingsInboxBotsDesc),
form(
{ action: "/settings/inbox-bots", method: "POST" },
...INBOX_BOT_ORDER.map(bot =>
label({ for: `inbox-bot-${bot}`, class: "lan-checkbox-label inbox-bot-label" },
input({
type: "checkbox",
id: `inbox-bot-${bot}`,
name: "bots",
value: bot,
class: "lan-checkbox-input",
checked: (Array.isArray(currentConfig.inboxMutedBots) && currentConfig.inboxMutedBots.includes(bot)) ? undefined : true
}),
span({ class: "lan-checkbox-text" }, inboxBotLabel(bot))
)
),
br(),
button({ type: "submit" }, i18n.saveSettings)
)
)
) : null,
modOn('wallet') ? section(
{ id: "wallet" },
div({ class: "tags-header" },
@ -461,6 +376,156 @@ const settingsView = (params) => {
)
)
];
const contentParts = [
section({ id: "wish" },
div({ class: "tags-header" },
h2(i18n.settingsWishTitle),
p(i18n.settingsWishDesc),
form(
{ action: "/settings/wish", method: "POST" },
select({ name: "wish" },
option({ value: "whole", ...(currentWish === "whole" ? { selected: true } : {})}, i18n.settingsWishWhole),
option({ value: "mutuals", ...(currentWish === "mutuals" ? { selected: true } : {})}, i18n.settingsWishMutuals),
option({ value: "only-lan", ...(currentWish === "only-lan" ? { selected: true } : {})}, i18n.settingsWishOnlyLan || "Only LAN"),
option({ value: "local", ...(currentWish === "local" ? { selected: true } : {})}, i18n.settingsWishLocal || "Local")
), br(), br(),
button({ type: "submit" }, i18n.saveSettings)
)
)
),
section({ id: "replication" },
div({ class: "tags-header" },
h2(i18n.settingsReplicationTitle || 'Replication'),
p(i18n.settingsReplicationDesc || 'Configure the number of hops your peer follows out from your own feed when replicating content.'),
form(
{ action: "/settings/replication", method: "POST" },
label({ for: "replication_hops" }, i18n.settingsReplicationHopsLabel || 'Hops'),
br(),
input({
type: "number",
id: "replication_hops",
name: "hops",
min: 0,
max: 6,
value: currentHops
}),
br(), br(),
button({ type: "submit" }, i18n.saveSettings)
)
)
),
blobCache ? section({ id: "blobcache" },
div({ class: "tags-header" },
h2(i18n.settingsBlobCacheTitle),
p(i18n.settingsBlobCacheDesc),
blobCache.usage ? p({ class: "blob-cache-usage" }, strong(`${i18n.settingsBlobCacheUsage}: `), `${sizeLabel(blobCache.usage.bytes)} · ${blobCache.usage.count} ${i18n.settingsBlobCacheFiles}`) : null,
blobCache.cleaned !== null ? p({ class: "blob-cache-notice" }, String(i18n.settingsBlobCacheCleaned).replace('{n}', String(blobCache.cleaned)).replace('{size}', sizeLabel(blobCache.freed))) : null,
form(
{ action: "/settings/blob-cache", method: "POST" },
label({ for: "blob_cache_mb" }, i18n.settingsBlobCacheLimit),
br(),
select({ id: "blob_cache_mb", name: "blob_cache_mb" }, ...BLOB_CACHE_OPTIONS.map(mb => option({ value: String(mb), ...(mb === blobCache.maxMB ? { selected: true } : {}) }, blobCacheOptionLabel(mb)))),
br(), br(),
button({ type: "submit" }, i18n.saveSettings),
blobCache.maxMB > 0 ? button({ type: "submit", formaction: "/settings/blob-cache/collect", class: "blob-cache-clean-btn" }, i18n.settingsBlobCacheCleanNow) : null
)
)
) : null,
section({ id: "pm-visibility" },
div({ class: "tags-header" },
h2(i18n.settingsPmVisibilityTitle),
p(i18n.settingsPmVisibilityDesc),
form(
{ action: "/settings/pm-visibility", method: "POST" },
select({ name: "pmVisibility" },
option({ value: "whole", ...(currentPmVisibility === "whole" ? { selected: true } : {})}, i18n.settingsPmVisibilityWhole),
option({ value: "following", ...(currentPmVisibility === "following" ? { selected: true } : {})}, i18n.settingsPmVisibilityFollowing),
option({ value: "mutuals", ...(currentPmVisibility === "mutuals" ? { selected: true } : {})}, i18n.settingsPmVisibilityMutuals)
), br(), br(),
button({ type: "submit" }, i18n.saveSettings)
)
)
),
section({ id: "mentions-visibility" },
div({ class: "tags-header" },
h2(i18n.settingsMentionsVisibilityTitle),
p(i18n.settingsMentionsVisibilityDesc),
form(
{ action: "/settings/mentions-visibility", method: "POST" },
select({ name: "mentionsVisibility" },
option({ value: "whole", ...(currentMentionsVisibility === "whole" ? { selected: true } : {})}, i18n.settingsPmVisibilityWhole),
option({ value: "following", ...(currentMentionsVisibility === "following" ? { selected: true } : {})}, i18n.settingsPmVisibilityFollowing),
option({ value: "mutuals", ...(currentMentionsVisibility === "mutuals" ? { selected: true } : {})}, i18n.settingsPmVisibilityMutuals)
), br(), br(),
button({ type: "submit" }, i18n.saveSettings)
)
)
),
modOn('inbox') ? section({ id: "inbox-bots" },
div({ class: "tags-header" },
h2(i18n.settingsInboxBotsTitle),
p(i18n.settingsInboxBotsDesc),
form(
{ action: "/settings/inbox-bots", method: "POST" },
...INBOX_BOT_ORDER.map(bot =>
label({ for: `inbox-bot-${bot}`, class: "lan-checkbox-label inbox-bot-label" },
input({
type: "checkbox",
id: `inbox-bot-${bot}`,
name: "bots",
value: bot,
class: "lan-checkbox-input",
checked: (Array.isArray(currentConfig.inboxMutedBots) && currentConfig.inboxMutedBots.includes(bot)) ? undefined : true
}),
span({ class: "lan-checkbox-text" }, inboxBotLabel(bot))
)
),
br(),
button({ type: "submit" }, i18n.saveSettings)
)
)
) : null,
section({ id: "hidden" },
div({ class: "tags-header" },
h2(i18n.settingsHiddenTitle),
p(i18n.settingsHiddenDesc),
p(strong(`${i18n.settingsMutedLabel} (${viewerHidden.muted.length})`)),
viewerHidden.muted.length
? div({ class: "viewer-hidden-block" }, table({ class: "block-info-table" },
tr(
td({ class: "card-label" }, i18n.inhabitantProfileTitle),
td({ class: "card-label" }, i18n.date),
td({ class: "card-label" }, "")
),
...viewerHidden.muted.map(m => tr(
td({ "data-label": i18n.inhabitantProfileTitle }, userLink(m.id)),
td({ "data-label": i18n.date }, hiddenAt(m.at)),
td({ "data-label": "" }, form({ action: `/unmute/${encodeURIComponent(m.id)}`, method: "POST" },
input({ type: "hidden", name: "returnTo", value: "/settings?filter=content#hidden" }),
button({ type: "submit", class: "filter-btn" }, i18n.unmute)))
))
))
: p({ class: "muted" }, i18n.settingsMutedEmpty),
p(strong(`${i18n.settingsHiddenLabel} (${viewerHidden.hidden.length})`)),
viewerHidden.hidden.length
? div({ class: "viewer-hidden-block" }, table({ class: "block-info-table" },
tr(
td({ class: "card-label" }, i18n.settingsTabContent),
td({ class: "card-label" }, i18n.date),
td({ class: "card-label" }, "")
),
...viewerHidden.hidden.map(h => tr(
td({ "data-label": i18n.settingsTabContent }, h.href ? a({ href: h.href }, plainLabel(h.label) || h.id) : span(plainLabel(h.label) || h.id)),
td({ "data-label": i18n.date }, hiddenAt(h.at)),
td({ "data-label": "" }, form({ action: `/unhide/${encodeURIComponent(h.id)}`, method: "POST" },
input({ type: "hidden", name: "returnTo", value: "/settings?filter=content#hidden" }),
button({ type: "submit", class: "filter-btn" }, i18n.unhideContent)))
))
))
: p({ class: "muted" }, i18n.settingsHiddenEmpty)
)
),
];
const multiverseParts = modOn('fediverse') ? [
section({ id: "mastodon" },
div({ class: "tags-header" },
@ -586,10 +651,11 @@ const settingsView = (params) => {
),
div({ class: "mode-buttons-row" },
a({ class: filter === 'oasis' ? 'filter-btn active' : 'filter-btn', href: '/settings?filter=oasis' }, String(i18n.linkBoxOasis).toUpperCase()),
a({ class: filter === 'content' ? 'filter-btn active' : 'filter-btn', href: '/settings?filter=content' }, String(i18n.settingsTabContent).toUpperCase()),
modOn('fediverse') ? a({ class: filter === 'multiverse' ? 'filter-btn active' : 'filter-btn', href: '/settings?filter=multiverse' }, String(i18n.multiverse).toUpperCase()) : null
)
),
...(filter === 'multiverse' ? multiverseParts : oasisParts)
...(filter === 'multiverse' ? multiverseParts : filter === 'content' ? contentParts : oasisParts)
);
};

View file

@ -80,10 +80,11 @@ const renderTorrentSeeds = (t, spreadMap) => {
const info = spreadInfoOf(t, spreadMap);
return String(info && typeof info.count === "number" ? info.count : (info && Array.isArray(info.voters) ? info.voters.length : 0));
};
const renderTorrentSpread = (t, spreadMap) => String(t.author) === String(userId) ? "" : renderSpreadButton(t.key, spreadInfoOf(t, spreadMap), i18n.seedAction, false);
const renderTorrentSpread = (t, spreadMap) => String(t.author) === String(userId) ? "—" : renderSpreadButton(t.key, spreadInfoOf(t, spreadMap), i18n.seedAction, false);
const renderTorrentTable = exports.renderTorrentTable = (torrents, filter, params = {}) => {
const returnTo = buildReturnTo(filter, params);
const extraColumns = typeof params.rowFor === "function";
if (!torrents.length) return p(params.q ? i18n.torrentNoMatch : i18n.noTorrents);
@ -91,21 +92,18 @@ const renderTorrentTable = exports.renderTorrentTable = (torrents, filter, param
{ border: "1", class: "torrent-table" },
tr(
th(i18n.createdAt || "DATE"),
th(i18n.authorLabel || "AUTHOR"),
th(i18n.torrentTitleLabel || "TITLE"),
th(i18n.torrentSizeLabel || "SIZE"),
th(i18n.seedsLabel),
th(i18n.seedAction),
th({ class: "torrent-spread-cell" }, i18n.seedsLabel),
th({ class: "torrent-spread-cell" }, i18n.seedAction),
th(""),
th(""),
th("")
...(extraColumns ? [th(""), th("")] : [])
),
torrents.map((t) => {
const row = typeof params.rowFor === "function" ? params.rowFor(t) : null;
return tr(
td({ class: "row-chips" }, t.tribeOrigin ? renderTribeOriginChip(t.tribeOrigin) : t.clearnet === true ? renderReachChip(true, i18n, clearnetItemHref('torrents', t.title, t.key)) : null, ...(row && Array.isArray(row.titleChips) ? row.titleChips.filter(Boolean) : [])),
td({ "data-label": i18n.createdAt || "DATE" }, moment(t.createdAt).format("YYYY/MM/DD HH:mm")),
td({ class: "row-author" }, userLink(t.author)),
td({ class: "row-title", "data-label": i18n.torrentTitleLabel || "TITLE" }, t.title || ""),
td({ "data-label": i18n.torrentSizeLabel || "SIZE" }, formatSize(t.size)),
td({ class: "torrent-spread-cell", "data-label": i18n.seedsLabel }, t.tribeOrigin || row ? "" : renderTorrentSeeds(t, params.spreadMap)),
@ -124,25 +122,7 @@ const renderTorrentTable = exports.renderTorrentTable = (torrents, filter, param
button({ type: "submit", class: "filter-btn" }, i18n.torrentDetailsButton)
)
),
td(
row
? (row.download || "")
: t.url && t.url.startsWith("&")
? div({ class: "torrent-card-actions" }, renderTorrentSourceDownload(t.key, t.source), renderTorrentDownload(torrentDownloadHref(t.url, t.title)))
: ""
),
td({ class: "row-actions" },
row
? (row.actions || "")
: t.tribeOrigin ? "" : renderContentActions(t.key, `/torrents/${encodeURIComponent(t.key)}`, {
author: t.author,
favKind: 'torrents',
isFavorite: t.isFavorite,
returnTo,
reportTitle: t.title,
deleteAction: String(t.author) === String(userId) ? contentDeleteAction('torrent', t.key) : undefined
})
)
...(extraColumns ? [td(row ? (row.download || "") : ""), td({ class: "row-actions" }, row ? (row.actions || "") : "")] : [])
);
})
);
@ -308,7 +288,6 @@ exports.torrentsView = async (torrents, filter = "all", torrentId = null, params
{ class: "filters" },
form(
{ method: "GET", action: "/torrents", class: "ui-toolbar ui-toolbar--filters" },
input({ type: "hidden", name: "q", value: q }),
input({ type: "hidden", name: "sort", value: sort }),
...(emptyMod ? [] : [
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.torrentFilterAll).toUpperCase()),
@ -449,7 +428,7 @@ exports.singleTorrentView = async (torrentObj, filter = "all", comments = [], pa
: null
);
})(),
renderEngagement(torrentObj.key,
torrentObj.tribeId ? null : renderEngagement(torrentObj.key,
renderOpinionsVoting('/torrents/opinions', torrentObj.key, torrentObj.opinions, returnTo, torrentObj.opinions_inhabitants),
renderTorrentCommentsSection(torrentObj.key, comments, returnTo)
)
@ -466,7 +445,6 @@ exports.singleTorrentView = async (torrentObj, filter = "all", comments = [], pa
{ class: "filters" },
form(
{ method: "GET", action: "/torrents", class: "ui-toolbar ui-toolbar--filters" },
input({ type: "hidden", name: "q", value: q }),
input({ type: "hidden", name: "sort", value: sort }),
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.torrentFilterAll).toUpperCase()), ...(mediaChip("favorites") ? [button(
...(mediaChip("recent") ? [button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, String(i18n.torrentFilterRecent).toUpperCase())] : []),

View file

@ -295,7 +295,6 @@ exports.transferView = async (transfers, filter, transferId, params = {}) => {
{ class: "filters" },
form(
{ method: "GET", action: "/transfers", class: "ui-toolbar ui-toolbar--filters" },
input({ type: "hidden", name: "q", value: q }),
input({ type: "hidden", name: "minAmount", value: String(minAmountRaw ?? "") }),
input({ type: "hidden", name: "maxAmount", value: String(maxAmountRaw ?? "") }),
input({ type: "hidden", name: "sort", value: sort }),
@ -558,7 +557,6 @@ exports.singleTransferView = async (transfer, filter, params = {}) => {
{ class: "filters" },
form(
{ method: "GET", action: "/transfers", class: "ui-toolbar ui-toolbar--filters" },
input({ type: "hidden", name: "q", value: q }),
input({ type: "hidden", name: "minAmount", value: String(params.minAmount ?? "") }),
input({ type: "hidden", name: "maxAmount", value: String(params.maxAmount ?? "") }),
input({ type: "hidden", name: "sort", value: sort }),

View file

@ -11,12 +11,15 @@ const { sanitizeHtml } = require('../backend/sanitizeHtml');
const userId = config.keys.id;
const capFirst = (s) => String(s || '').charAt(0).toUpperCase() + String(s || '').slice(1);
const typeLabel = (type) => i18n['type' + capFirst(type)] || type;
const filterButton = (mode, currentFilter) =>
form({ method: 'GET', action: '/trending' },
input({ type: 'hidden', name: 'filter', value: mode }),
button(
{ type: 'submit', class: currentFilter === mode ? 'filter-btn active' : 'filter-btn' },
String(i18n[mode + 'Button'] || mode).toUpperCase()
String(i18n[mode + 'Button'] || typeLabel(mode)).toUpperCase()
)
);
@ -162,6 +165,16 @@ const renderTrendingCard = (item, votes, categories, seenTitles, spreadMap = new
: null
)
);
} else if (c.type === 'post') {
contentHtml = div({ class: 'trending-blog' },
div({ class: 'card-section blog' },
c.contentWarning ? div({ class: 'shop-title-row' }, h2({ class: 'tribe-card-title' }, a({ href: `/blogs/${encodeURIComponent(item.key)}` }, c.contentWarning))) : "",
div({ class: 'blog-card-text', [RAW_HTML]: sanitizeHtml(renderStyledHtml(String(c.text || '').trim().length > 420 ? String(c.text).trim().slice(0, 420).replace(/\s+$/, '') + '…' : String(c.text || '').trim())) }),
Array.isArray(c.tags) && c.tags.length
? div({ class: 'card-tags' }, c.tags.map(tag => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)))
: null
)
);
} else if (c.type === 'feed') {
const { text, refeeds } = c;
contentHtml = div({ class: 'trending-feed' },
@ -221,6 +234,7 @@ const renderTrendingCard = (item, votes, categories, seenTitles, spreadMap = new
file: 'files',
document: 'documents',
feed: 'feed',
post: 'blogs',
votes: 'votes',
transfer: 'transfers',
industry: 'industry',
@ -249,12 +263,12 @@ const renderTrendingCard = (item, votes, categories, seenTitles, spreadMap = new
div(
{ class: 'card-header activity-card-header' },
span({ class: 'pm-exposition-chip pm-exposition-whole' },
span({ class: 'pm-exposition-text' }, String(i18n['type' + String(c.type || '').charAt(0).toUpperCase() + String(c.type || '').slice(1)] || c.type || '').toUpperCase())
span({ class: 'pm-exposition-text' }, String(typeLabel(c.type) || '').toUpperCase())
),
renderContentActions(item.key, detailHref, {
spread: spreadMap.get(item.key) || null,
author: item.value.author,
reportTitle: c.title || c.question || c.concept || '',
reportTitle: c.title || c.question || c.concept || c.contentWarning || '',
...(CONTENT_TORRENTABLE.has(c.type) && typeof c.url === 'string' && c.url.startsWith('&')
? { torrentFrom: { blobId: c.url, name: c.title || c.fileName || '' } }
: {}),
@ -301,7 +315,7 @@ exports.trendingView = (items, filter, categories = opinionCategories, spreadMap
const baseFilters = ['ALL', 'RECENT', 'MINE', 'TOP'];
const contentFilters = [
['votes', 'event', 'task', 'report'],
['feed', 'project', 'industry', 'shopProduct', 'transfer'],
['feed', 'post', 'project', 'industry', 'shopProduct', 'transfer'],
['audio', 'bookmark', 'document', 'file', 'image', 'torrent', 'video'],
['podcast', 'podcastEpisode', 'campaign', 'logisticsRoute']
];

View file

@ -22,6 +22,7 @@ const { renderCalendarCard } = require('./calendars_view');
const { renderInhabitantCard } = require('./inhabitants_view');
const { config } = require('../server/SSB_server.js');
const { renderStyledText, safeExternalHref } = require('../backend/renderStyledText');
const { renderSearchForm } = require('./search_view');
const { renderMapCard, renderMapLocationVisitLabel, renderMapEmbed } = require("./maps_view");
const { renderTribeRoomsSection, roomStatsBy } = require('./rooms_view');
@ -110,18 +111,19 @@ const renderMediaBlob = (value, fallbackSrc = null, attrs = {}) => {
if (!s) return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null
if (s.startsWith('/assets/') || s.startsWith('/blob/') || s.startsWith('/image/')) return img({ loading: 'lazy', src: s, ...attrs })
if (s.startsWith('&')) return img({ loading: 'lazy', src: `/blob/${encodeURIComponent(s)}`, ...attrs })
const mVideo = s.match(/\[video:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/)
if (mVideo) return video({ controls: true, class: attrs.class || 'post-video', src: `/blob/${encodeURIComponent(mVideo[1])}`, preload: 'metadata' })
const mAudio = s.match(/\[audio:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/)
if (mAudio) return audio({ controls: true, class: attrs.class || 'post-audio', src: `/blob/${encodeURIComponent(mAudio[1])}` })
const mImg = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/)
if (mImg) return img({ loading: 'lazy', src: `/blob/${encodeURIComponent(mImg[1])}`, ...attrs })
const mVideo = s.match(/\[video:[^\]]*\]\(\s*(&[^)\s]+\.sha256|\/blob\/sealed\/[^)\s]+)\s*\)/)
if (mVideo) return video({ controls: true, class: attrs.class || 'post-video', src: toBlobUrl(mVideo[1]), preload: 'metadata' })
const mAudio = s.match(/\[audio:[^\]]*\]\(\s*(&[^)\s]+\.sha256|\/blob\/sealed\/[^)\s]+)\s*\)/)
if (mAudio) return audio({ controls: true, class: attrs.class || 'post-audio', src: toBlobUrl(mAudio[1]) })
const mImg = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256|\/blob\/sealed\/[^)\s]+)\s*\)/)
if (mImg) return img({ loading: 'lazy', src: toBlobUrl(mImg[1]), ...attrs })
return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null
}
const toBlobUrl = (raw) => {
if (!raw) return null
const s = String(raw).trim()
if (s.startsWith('/blob/sealed/')) return s
if (s.startsWith('&')) return `/blob/${encodeURIComponent(s)}`
const m = s.match(/\((&[^)\s]+\.sha256)\s*\)/)
return m ? `/blob/${encodeURIComponent(m[1])}` : null
@ -387,6 +389,15 @@ const sectionLink = (tribe, sectionKey, label, currentSection) =>
button({ type: 'submit', class: currentSection === sectionKey ? 'filter-btn active' : 'filter-btn' }, String(label).toUpperCase())
);
const tribeTagHref = (tribe) => (tag) => `/tribe/${encodeURIComponent(tribe.id)}?section=search&q=${encodeURIComponent('#' + String(tag).toLowerCase().replace(/^#+/, ''))}`;
const tribeText = (tribe, text, opts = {}) => renderStyledText(String(text || ''), { ...opts, ...(tribe && tribe.id ? { hashtagHref: tribeTagHref(tribe) } : {}) });
const tribeExcerpt = (tribe, text, max = 200) => {
const full = String(text || '').trim();
if (!full) return null;
const cut = full.length > max ? full.slice(0, max).replace(/\s+$/, '') + '…' : full;
return p({ class: 'post-text post-text-pre' }, ...tribeText(tribe, cut));
};
const renderSectionNav = (tribe, section) => {
const firstGroup = [{ key: 'activity', label: i18n.tribeSectionActivity }, { key: 'inhabitants', label: i18n.tribeSectionInhabitants }];
if (!tribe.parentTribeId) firstGroup.push({ key: 'subtribes', label: i18n.tribeSubTribes });
@ -449,7 +460,7 @@ const contentTypeVerb = (ct) => {
};
const contentTypeName = (ct) => {
const map = { event: i18n.tribeSectionEvents, task: i18n.tribeSectionTasks, votation: i18n.tribeSectionVotations, poll: i18n.pollsTitle, forum: i18n.tribeSectionForum, 'forum-reply': i18n.tribeSectionForum, blog: i18n.blogTitle, media: i18n.tribeSectionMedia, feed: i18n.tribeSectionFeed, pad: i18n.tribeSectionPads || 'PADS', chat: i18n.tribeSectionChats || 'CHATS', calendar: i18n.tribeSectionCalendars || 'CALENDARS', map: i18n.tribeSectionMaps || 'MAPS', wiki: i18n.tribeSectionWiki || 'WIKI' };
const map = { event: i18n.tribeSectionEvents, task: i18n.tribeSectionTasks, votation: i18n.tribeSectionVotations, poll: String(i18n.pollsTitle || 'POLLS').toUpperCase(), forum: i18n.tribeSectionForum, 'forum-reply': i18n.tribeSectionForum, blog: String(i18n.blogTitle || 'BLOGS').toUpperCase(), media: i18n.tribeSectionMedia, feed: i18n.tribeSectionFeed, pad: i18n.tribeSectionPads || 'PADS', chat: i18n.tribeSectionChats || 'CHATS', calendar: i18n.tribeSectionCalendars || 'CALENDARS', map: i18n.tribeSectionMaps || 'MAPS', wiki: i18n.tribeSectionWiki || 'WIKI' };
return map[ct] || ct;
};
@ -494,7 +505,7 @@ const renderTribeActivitySection = (tribe, sectionData) => {
),
div({ class: 'tribe-card-body' },
item.title ? div({ class: 'card-field' }, span({ class: 'card-value' }, item.title)) : null,
item.description ? p(String(item.description).substring(0, 200)) : null,
tribeExcerpt(tribe, item.description),
replies.map(r => div({ class: 'thread-reply-item' },
r.text ? p({ class: 'post-text post-text-pre' }, String(r.text)) : null,
div({ class: 'card-footer thread-reply-footer' },
@ -546,7 +557,7 @@ const renderTribeActivitySection = (tribe, sectionData) => {
span({ class: 'card-value' }, item.title)
) : null,
mediaContent,
item.description && !(item.contentType === 'media' && item.mediaType === 'bookmark' && item.description === item.url) ? p(item.description.substring(0, 200)) : null
item.description && !(item.contentType === 'media' && item.mediaType === 'bookmark' && item.description === item.url) ? tribeExcerpt(tribe, item.description) : null
),
p({ class: 'card-footer' },
span({ class: 'date-link' }, `${date}`),
@ -589,11 +600,8 @@ const renderTribeTrendingSection = (tribe, sectionData, query) => {
const renderTribeTagsSection = (tribe, sectionData, query) => {
const tagsList = Array.isArray(sectionData?.tags) ? sectionData.tags : [];
const selectedTag = sectionData?.selectedTag || '';
const filteredItems = Array.isArray(sectionData?.filteredItems) ? sectionData.filteredItems : [];
const tribeUrl = `/tribe/${encodeURIComponent(tribe.id)}`;
const sortedTags = tagsList.slice().sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
const threadsById = reachIndexOf(tagsList.flatMap(t => Array.isArray(t.items) ? t.items : []));
const tagHref = tribeTagHref(tribe);
return section(
div({ class: 'tags-header module-header-line' },
h2(i18n.tribeSectionTags || i18n.tagsTitle || 'TAGS'),
@ -608,29 +616,13 @@ const renderTribeTagsSection = (tribe, sectionData, query) => {
th(i18n.tagsTableHeaderCount || 'Count')
)),
tbody(
(selectedTag ? sortedTags : paged(sortedTags)).map(t => tr(
td(a({ href: `${tribeUrl}?section=tags&tag=${encodeURIComponent(t.tag)}` }, t.tag)),
paged(sortedTags).map(t => tr(
td(a({ href: tagHref(t.tag) }, t.tag)),
td(`${t.count}`)
))
)
)
),
selectedTag ? div({ class: 'tribe-content-list' },
h2(`#${selectedTag} (${filteredItems.length})`),
filteredItems.length === 0 ? p(i18n.tribeTagsEmpty || 'No items') :
paged(filteredItems).map(item => div({ class: 'card card-rpg' },
div({ class: 'card-header' }, h2({ class: 'card-label' }, `[${String(contentTypeName(item.contentType)).toUpperCase()}]`)),
div({ class: 'card-body' },
renderTribeReachListChip(tribe, item, threadsById),
item.title ? div({ class: 'card-field' }, span({ class: 'card-value' }, item.title)) : null,
item.description ? p(item.description.substring(0, 200)) : null
),
p({ class: 'card-footer' },
span({ class: 'date-link' }, moment(item.createdAt).format("YYYY/MM/DD HH:mm")),
userLink(item.author)
)
))
) : null
)
);
};
@ -639,16 +631,19 @@ const renderTribeSearchSection = (tribe, sectionData, query) => {
const sq = sectionData?.query || '';
const tribeUrl = `/tribe/${encodeURIComponent(tribe.id)}`;
const threadsById = reachIndexOf(results);
const hashtag = sq.trim().startsWith('#') ? sq.trim().replace(/^#+/, '').toLowerCase() : '';
const typeCounts = new Map();
for (const item of results) {
const label = String(contentTypeName(item.contentType)).toUpperCase();
typeCounts.set(label, (typeCounts.get(label) || 0) + 1);
}
const stats = renderModuleStats(results.length, [...typeCounts].map(([label, count]) => ({ label, count })).sort((x, y) => y.count - x.count || x.label.localeCompare(y.label)));
return div({ class: 'tribe-content-list' },
div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionSearch)),
form({ method: 'GET', action: tribeUrl, class: 'tribe-search-form' },
input({ type: 'hidden', name: 'section', value: 'search' }),
input({ type: 'text', name: 'q', value: sq, placeholder: i18n.tribeSearchPlaceholder, minlength: 2 }),
button({ type: 'submit', class: 'create-button' }, i18n.tribeSectionSearch)
),
div({ class: 'tags-header module-header-line' }, h2(i18n.tribeSectionSearch), p(hashtag ? i18n.hashtagDescription : i18n.searchDescriptionLabel)),
renderSearchForm({ action: tribeUrl, query: sq, queryName: 'q', hidden: [['section', 'search']] }),
sq.length > 0 && sq.length < 2 ? p(i18n.tribeSearchMinChars) : null,
sq.length >= 2 ? div(
h2(`${i18n.tribeSearchResults}: ${results.length}`),
stats ? div({ class: 'filters activity-filter-chips activity-toolbar-row search-stats-row' }, stats) : null,
results.length === 0 ? p(i18n.tribeSearchEmpty) :
paged(results).map(item => div({ class: 'card card-rpg' },
div({ class: 'card-header' },
@ -660,7 +655,7 @@ const renderTribeSearchSection = (tribe, sectionData, query) => {
span({ class: 'card-label' }, (i18n.title || 'Title') + ':'),
span({ class: 'card-value' }, item.title)
) : null,
item.description ? p(item.description.substring(0, 200)) : null
tribeExcerpt(tribe, item.description)
),
p({ class: 'card-footer' },
span({ class: 'date-link' }, moment(item.createdAt).format("YYYY/MM/DD HH:mm")),
@ -686,7 +681,7 @@ const renderOverviewSection = (tribe, query, sectionData) => {
recentFeed.map(m =>
div({ class: 'feed-item' },
p(`${moment(m.createdAt).format("YYYY/MM/DD HH:mm")} — `, userLink(m.author)),
p(...renderStyledText(m.description))
p(...tribeText(tribe, m.description))
)
)
),
@ -743,7 +738,7 @@ const tribeStatusOf = (item) => String(item.status || 'OPEN').toUpperCase();
const capFirst = (s) => String(s).charAt(0).toUpperCase() + String(s).slice(1);
const blobIdOf = (raw) => {
const s = String(raw || '').trim();
if (s.startsWith('&')) return s;
if (s.startsWith('&') || s.startsWith('/blob/sealed/')) return s;
const m = s.match(/\((&[^)\s]+\.sha256)\s*\)/);
return m ? m[1] : null;
};
@ -864,6 +859,7 @@ const renderFeedTribeView = (feedItems, tribe, query = {}) => {
const chip = tribeReachChipFor(tribe, m);
const controls = renderTribeReachControls(tribe, m, returnTo);
return renderFeedCard(toFeedShape(m), null, {
hashtagHref: tribeTagHref(tribe),
refeedAction: `${tribeUrl}/refeed/${encodeURIComponent(m.id)}`,
canRefeed: member,
headerActions: tribeItemActions(tribe, m, member, m.description),
@ -1193,7 +1189,7 @@ const renderForumThreadDetail = (tribe, allItems, threadId, member) => {
reachChip
),
renderTribeReachControls(tribe, thread, returnTo),
div({ class: 'forum-body' }, ...renderStyledText(thread.description || '')),
div({ class: 'forum-body' }, ...tribeText(tribe, thread.description || '')),
div({ class: 'forum-meta' },
span({ class: 'forum-positive-votes' }, `▲: ${thread.refeeds || 0}`),
span({ class: 'forum-participants' }, `${String(i18n.forumParticipants).toUpperCase()}: ${participants.size}`),
@ -1227,7 +1223,7 @@ const renderForumThreadDetail = (tribe, allItems, threadId, member) => {
div({ class: 'comment-body-row' },
div({ class: 'comment-vote-col' }, forumScoreBox(tribe, r, member, threadId)),
div({ class: 'comment-text-col' },
div(...String(r.description || '').split('\n').map(l => l.trim()).filter(Boolean).map(l => p(...renderStyledText(l, { zoomImages: true }))))
div(...String(r.description || '').split('\n').map(l => l.trim()).filter(Boolean).map(l => p(...tribeText(tribe, l, { zoomImages: true }))))
)
)
)
@ -1871,7 +1867,7 @@ exports.clearnetTribeView = async ({ tribe, items, names = {}, slug, page = 1, f
? `<div class="cn-filter-row">${btn(hrefFor('', activeAuthor), i18n.all, byAuthor.length, !activeType)}${[...typeCounts.entries()].map(([k, n]) => btn(hrefFor(k, activeAuthor), kindName(byAuthor.find(it => kindKey(it) === k)), n, activeType === k)).join('')}</div>`
: '';
const authorRow = authorCounts.size > 1
? `<div class="cn-filter-row">${btn(hrefFor(activeType, ''), i18n.all, [...authorCounts.values()].reduce((a, b) => a + b, 0), !activeAuthor)}${[...authorCounts.entries()].map(([id, n]) => btn(hrefFor(activeType, id), who(id), n, activeAuthor === id)).join('')}</div>`
? `<div class="cn-filter-row">${[...authorCounts.entries()].map(([id, n]) => btn(hrefFor(activeType, activeAuthor === id ? '' : id), who(id), n, activeAuthor === id)).join('')}</div>`
: '';
const renderItem = (it) => {
const mt = it.contentType === 'media' ? String(it.mediaType || '') : '';
@ -1879,9 +1875,9 @@ exports.clearnetTribeView = async ({ tribe, items, names = {}, slug, page = 1, f
const title = escapeHtml(it.title || '');
const blob = cnBlobUrl(it.image);
const media = !blob ? ''
: mt === 'image' ? `<img class="cn-tribe-img" src="${blob}" alt="${title}" loading="lazy"/>`
: mt === 'audio' ? `<audio class="cn-tribe-player" controls preload="metadata" src="${blob}"></audio>`
: mt === 'video' ? `<video class="cn-tribe-player" controls preload="metadata" src="${blob}"></video>`
: mt === 'image' ? `<img class="post-image" src="${blob}" alt="${title}" loading="lazy"/>`
: mt === 'audio' ? `<audio class="post-audio" controls preload="metadata" src="${blob}"></audio>`
: mt === 'video' ? `<video class="post-video" controls preload="metadata" src="${blob}"></video>`
: ['document', 'file', 'torrent'].includes(mt) ? `<a class="cn-tribe-link" href="${blob}" rel="noopener">⇩ ${title || escapeHtml(kind)}</a>`
: '';
const url = mt === 'bookmark' ? String(it.url || '') : '';
@ -1929,8 +1925,6 @@ exports.clearnetTribeView = async ({ tribe, items, names = {}, slug, page = 1, f
.cn-tribe-meta{display:flex;gap:12px;flex-wrap:wrap;align-items:baseline;color:var(--fg-dim);font-size:12px;word-break:break-all}
.cn-tribe-title{color:var(--fg);font-size:18px;font-weight:700;margin:8px 0;word-break:break-word}
.cn-tribe-text{color:var(--fg-soft);line-height:1.5;margin:8px 0;word-break:break-word}
.cn-tribe-img{display:block;max-width:100%;height:auto;border-radius:6px;border:1px solid var(--border);margin:10px 0}
.cn-tribe-player{display:block;width:100%;max-width:100%;margin:10px 0;border-radius:6px;background:#000}
.cn-tribe-link{display:inline-block;margin:8px 0;padding:6px 12px;background:var(--bg-sub);border:1px solid var(--border);border-radius:6px;color:var(--fg);word-break:break-all}
.cn-tribe-options{margin:8px 0;padding-left:20px;color:var(--fg-soft)}
.cn-tribe-replies{margin-top:12px;border-top:1px solid var(--border);padding-top:8px}

View file

@ -19,7 +19,7 @@ const { renderCommentsSection: renderSharedCommentsSection, renderCommentsLink }
const moment = require("../server/node_modules/moment");
const { renderLicenseChip, renderLicenseSelect, renderReachChip, renderClearnetSelector, renderClearnetSwitch, renderTribeOriginChip } = require('./clearnet_view');
const { clearnetItemHref, template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip, renderContentActions , renderSpreadEditWarning, renderModuleStats, moduleIsEmpty, contentDeleteAction, paged } = require("./main_views");
const { clearnetItemHref, template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip, renderContentActions, renderSpreadEditWarning, renderModuleStats, moduleIsEmpty, contentDeleteAction, paged, blobUrlOf } = require("./main_views");
const { config } = require("../server/SSB_server.js");
const { renderStyledText } = require("../backend/renderStyledText")
const { renderMapLocationVisitLabel, renderMapEmbed } = require("./maps_view");
@ -55,7 +55,7 @@ const renderVideoPlayer = (videoObj) =>
{ class: "video-container video-container-row" },
videoHyperaxe({
controls: true,
src: `/blob/${encodeURIComponent(videoObj.url)}`,
src: blobUrlOf(videoObj.url),
preload: "metadata"
})
)
@ -232,7 +232,6 @@ exports.videoView = async (videos, filter = "all", videoId = null, params = {})
{ class: "filters" },
form(
{ method: "GET", action: "/videos", class: "ui-toolbar ui-toolbar--filters" },
input({ type: "hidden", name: "q", value: q }),
input({ type: "hidden", name: "sort", value: sort }),
...(emptyMod ? [] : [
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.videoFilterAll).toUpperCase()),
@ -370,7 +369,6 @@ exports.singleVideoView = async (videoObj, filter = "all", comments = [], params
{ class: "filters" },
form(
{ method: "GET", action: "/videos", class: "ui-toolbar ui-toolbar--filters" },
input({ type: "hidden", name: "q", value: q }),
input({ type: "hidden", name: "sort", value: sort }),
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.videoFilterAll).toUpperCase()), ...(mediaChip("favorites") ? [button(
...(mediaChip("recent") ? [button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, String(i18n.videoFilterRecent).toUpperCase())] : []),

View file

@ -37,14 +37,15 @@ const stepAction = (key, profile, status) => {
if (key === "ux") {
let cfg = {}
try { cfg = require("../configs/config-manager.js").getConfig() || {} } catch (_) {}
const cur = ["ainav", "chats", "feed", "phone"].includes(cfg.ux?.current) ? cfg.ux.current : "blocks"
const cur = ["ainav", "chats", "feed", "phone", "rooms"].includes(cfg.ux?.current) ? cfg.ux.current : "blocks"
return form({ method: "POST", action: "/welcome/ux", class: "welcome-ux-form" },
div({ class: "welcome-ux-grid" },
pickCard("ux", "blocks", cur, i18n.uxModeMenus, "/assets/images/ux-blocks.png"),
cfg.modules?.aiNavMod === "on" ? pickCard("ux", "ainav", cur, i18n.uxModeAINav, "/assets/images/ux-ainav.png") : null,
cfg.modules?.chatsMod === "on" ? pickCard("ux", "chats", cur, i18n.uxModeChats, "/assets/images/ux-chats.png") : null,
pickCard("ux", "feed", cur, i18n.uxModeFeed, "/assets/images/ux-feed.png"),
cfg.modules?.phoneMod !== "off" ? pickCard("ux", "phone", cur, i18n.phoneTitle, "/assets/images/ux-phone.png") : null
cfg.modules?.phoneMod !== "off" ? pickCard("ux", "phone", cur, i18n.phoneTitle, "/assets/images/ux-phone.png") : null,
cfg.modules?.roomsMod !== "off" ? pickCard("ux", "rooms", cur, i18n.roomsTitle, "/assets/images/ux-rooms.png") : null
),
submitRow(i18n.welcomeStepUxAction)
)

View file

@ -117,7 +117,6 @@ const renderFilters = (filter, q, params = {}, census = []) => {
const labelOf = (mode) => String(i18n[`wikiFilter${mode.charAt(0).toUpperCase() + mode.slice(1)}`] || mode).toUpperCase();
return div({ class: "filters" },
form({ method: "GET", action: "/wiki", class: "ui-toolbar ui-toolbar--filters" },
input({ type: "hidden", name: "q", value: q || "" }),
tribeId ? input({ type: "hidden", name: "tribeId", value: tribeId }) : null,
...(params.emptyMod ? [] : [
...FILTERS.filter(chipVisible).map(mode => button({ type: "submit", name: "filter", value: mode, class: filter === mode ? "filter-btn active" : "filter-btn" }, labelOf(mode))),