upstream: integrar Oasis 1.3.1

Base del merge a tres bandas: git 0.9.5 de epsylon, esta rama y el src de la
APK oficial 1.3.1 (la variante movil: OASIS_MOBILE, regex sin \p{}, audio y
video de Android). Corre en el mismo nodejs-mobile 18.20.4 que ya usabamos.

Se conserva de la rama: Karvan, identidades y multicuenta, topbar y barra
inferior, borrado de EXIF sin sharp, filtro hot del foro, RTC sin STUN y los
modulos apagados en movil (wallet, banking, torrents).

Se toma de upstream lo que ya hace de serie: respuesta citada en chats, ssb-lan
en movil, configuracion en ~/.ssb/oasis (sobrevive a reinstalar el backend).
El asistente de bienvenida deja elegir workflow tambien en movil.
This commit is contained in:
oasis-bot 2026-10-18 02:52:17 +02:00
parent 8d838dd8b1
commit c332afd6ea
265 changed files with 80970 additions and 29167 deletions

View file

@ -1,18 +1,20 @@
const { div, h2, p, section, button, form, a, input, label, span, textarea, br, table, tr, td } = require("../server/node_modules/hyperaxe");
const RAW_HTML = Symbol.for('oasis.rawHtml');
const { TEXT_CAP } = require('../backend/long_text');
const { renderCommentsSection: renderSharedCommentsSection } = require("./comments_view");
const { template, i18n, userLink, renderOpinionsVoting, renderEngagement, renderSpreadButton, renderContentActions } = require("./main_views");
const { clearnetItemHref, template, i18n, userLink, renderOpinionsVoting, renderEngagement, renderSpreadButton, renderContentActions, contentDeleteAction, renderSubscriptionBox, renderModuleStats, moduleIsEmpty, paged } = require("./main_views");
const { renderReachChip, renderClearnetSelector, renderClearnetSwitch } = require("./clearnet_view");
const moment = require("../server/node_modules/moment");
const { config } = require("../server/SSB_server.js");
const { renderUrl } = require("../backend/renderUrl");
const { renderTextWithStyles } = require("../backend/renderTextWithStyles");
const { renderStyledHtml } = require("../backend/renderStyledText");
const { sanitizeHtml } = require("../backend/sanitizeHtml");
const userId = config.keys.id;
const FILTERS = [
{ key: "ALL", i18n: "blogFilterAll" },
{ key: "MINE", i18n: "blogFilterMine" },
{ key: "RECENT", i18n: "blogFilterRecent" },
{ key: "MINE", i18n: "blogFilterMine" },
{ key: "FAVORITES", i18n: "blogFilterFavorites" },
{ key: "TOP", i18n: "blogFilterTop" }
];
@ -24,18 +26,30 @@ const excerpt = (text, max = 420) => {
return s.length > max ? s.slice(0, max) + "…" : s;
};
const renderFilterBar = (filter, q, showSearch = true) =>
section(
const renderFilterBar = (filter, q, showSearch = true, total = null, censusList = null) => {
const emptyMod = total !== null && moduleIsEmpty({ length: Number(total) || 0 }, filter, "ALL", q);
const censusB = Array.isArray(censusList) ? censusList : [];
const blogChip = (x) => {
const m = x.key;
if (m === filter || m === "ALL") return true;
if (m === "TOP") return censusB.length > 0;
if (m === "MINE") return censusB.some(b => String(b.author) === String(userId));
if (m === "RECENT") return censusB.length > 0;
if (m === "FAVORITES") return censusB.some(b => b.isFavorite);
return true;
};
return section(
div({ class: "filters" },
form({ method: "GET", action: "/blogs", class: "ui-toolbar ui-toolbar--filters" },
...FILTERS.map(f =>
...(emptyMod ? [] : FILTERS.filter(blogChip).map(f =>
button({ type: "submit", name: "filter", value: f.key, class: filter === f.key ? "filter-btn active" : "filter-btn" }, String(i18n[f.i18n]).toUpperCase())
),
)),
button({ type: "submit", name: "filter", value: "CREATE", class: "create-button" }, i18n.blogCreateButton)
)
),
showSearch
? div({ class: "filters" },
showSearch && !emptyMod
? div({ class: "filters activity-filter-chips activity-toolbar-row" },
total != null ? renderModuleStats(total) : null,
form({ method: "GET", action: "/blogs", class: "filter-box" },
input({ type: "hidden", name: "filter", value: filter }),
input({ type: "text", name: "q", value: q || "", placeholder: i18n.blogSearchPlaceholder, class: "filter-box__input" }),
@ -46,13 +60,14 @@ const renderFilterBar = (filter, q, showSearch = true) =>
)
: null
);
};
const renderBlogCard = (blog, filter, spreadInfo) => {
const href = `/blogs/${encodeURIComponent(blog.id)}`;
const isOwn = String(blog.author) === String(userId);
return div({ class: "trending-card blog-card" + (isOwn ? " own-content" : "") },
div({ class: "card-header activity-card-header" },
renderContentActions(blog.id, href, { spread: spreadInfo || null, author: blog.author, favKind: 'blogs', isFavorite: blog.isFavorite, reportTitle: blog.subject || blog.text })
renderContentActions(blog.id, href, { spread: spreadInfo || null, author: blog.author, favKind: 'blogs', isFavorite: blog.isFavorite, reportTitle: blog.subject || blog.text, deleteAction: isOwn ? contentDeleteAction('blog', blog.id) : undefined })
),
div({ class: "card-section blog-card-body" },
blog.subject
@ -60,28 +75,40 @@ const renderBlogCard = (blog, filter, spreadInfo) => {
h2({ class: "tribe-card-title" }, a({ href }, blog.subject))
)
: null,
div({ class: "blog-card-text", innerHTML: sanitizeHtml(renderTextWithStyles(excerpt(blog.text))) }),
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))) }),
p({ class: "card-footer" },
span({ class: "date-link" }, moment(blog.createdAt).format("YYYY/MM/DD HH:mm")),
span(" · "),
span({ class: "date-link" }, `${moment(blog.createdAt).format("YYYY/MM/DD HH:mm")}`),
userLink(blog.author)
)
)
);
};
const renderCreateForm = () =>
section(
const renderCreateForm = (draft = null) => {
const subjectValue = draft ? safeText(draft.subject) : "";
const textValue = draft ? String(draft.text || "") : "";
const allowComments = draft ? draft.allowComments !== false : true;
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)) })
)
)
: null,
div({ class: "publish-form" },
form({ action: "/blogs/create", method: "POST", enctype: "multipart/form-data" },
label({ for: "subject" }, i18n.blogSubject),
br(),
input({ type: "text", name: "subject", id: "subject", maxlength: "150", placeholder: i18n.contentWarningPlaceholder }),
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: "7000", placeholder: i18n.publishWarningPlaceholder }),
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),
@ -92,16 +119,20 @@ const renderCreateForm = () =>
div({ class: "blog-allow-comments" },
input({ type: "hidden", name: "allowComments", value: "0" }),
label(
input({ type: "checkbox", name: "allowComments", value: "1", checked: true }),
input({ type: "checkbox", name: "allowComments", value: "1", ...(allowComments ? { checked: true } : {}) }),
" ",
i18n.blogAllowComments
)
),
renderClearnetSelector(!!(draft && draft.clearnet === true), i18n),
br(),
button({ type: "submit" }, i18n.blogPublish)
button({ type: "submit", class: "filter-btn", formaction: "/blogs/preview", attrs: { formmethod: "POST" } }, i18n.preview),
" ",
button({ type: "submit", class: "create-button" }, i18n.blogPublish)
)
)
);
};
const renderCommentsSection = (blog, comments = []) => {
const href = `/blogs/${encodeURIComponent(blog.id)}`;
@ -119,18 +150,18 @@ exports.blogView = async (blogs = [], filter = "ALL", params = {}) => {
return template(
i18n.blogTitle,
section(
div({ class: "tags-header" },
div({ class: "tags-header module-header-line" },
h2(i18n.blogTitle),
p(i18n.blogDescription)
)
),
renderFilterBar(showForm ? "ALL" : filter, params.q, !showForm),
renderFilterBar(showForm ? "CREATE" : filter, params.q, !showForm, Array.isArray(blogs) ? blogs.length : 0, params.censusList),
showForm
? renderCreateForm()
? renderCreateForm(params.draft || null)
: section(
blogs.length
? div({ class: "jobs-grid" }, ...blogs.map(b => renderBlogCard(b, filter, spreadMap.get(b.id))))
: p({ class: "no-content" }, i18n.blogNoItems)
? div({ class: "blogs-grid" }, ...paged(blogs).map(b => renderBlogCard(b, filter, spreadMap.get(b.id))))
: div({ class: "no-content-box" }, p({ class: "no-content" }, i18n.blogNoItems))
)
);
};
@ -138,14 +169,17 @@ exports.blogView = async (blogs = [], filter = "ALL", params = {}) => {
exports.singleBlogView = async (blog, comments = [], params = {}) => {
const href = `/blogs/${encodeURIComponent(blog.id)}`;
const isAuthor = String(blog.author) === String(userId);
const isClearnet = !!blog.clearnet;
const blogSide = div({ class: "tribe-side" },
div({ class: "card-header activity-card-header" },
renderContentActions(blog.id, null, { spread: params.spreads || null, author: blog.author, favKind: 'blogs', isFavorite: blog.isFavorite, reportTitle: blog.subject || blog.text })
renderContentActions(blog.id, href, { spread: params.spreads || null, author: blog.author, favKind: 'blogs', isFavorite: blog.isFavorite, reportTitle: blog.subject || blog.text, returnTo: href, deleteAction: isAuthor ? contentDeleteAction('blog', blog.id) : undefined })
),
div({ class: "shop-title-row" },
h2({ class: "tribe-card-title" }, blog.subject || i18n.blogTitle)
h2({ class: "tribe-card-title" }, blog.subject || i18n.blogTitle),
renderReachChip(isClearnet, i18n, isClearnet ? clearnetItemHref("blog", blog.subject || blog.text, blog.id) : null)
),
isAuthor ? renderClearnetSwitch("posts", blog.rootId || blog.id, isClearnet) : null,
table({ class: "tribe-info-table jobs-info-table" },
tr(
td({ class: "tribe-info-label" }, i18n.createdAtLabel || "Created at"),
@ -158,11 +192,21 @@ exports.singleBlogView = async (blog, comments = [], params = {}) => {
div({ class: "tribe-card-members" },
span({ class: "tribe-members-count" }, `${i18n.blogComments}: ${blog.commentCount || 0}`)
),
params.subscription
? renderSubscriptionBox({
target: blog.author,
scope: "blogs",
subscribed: params.subscription.subscribed === true,
count: params.subscription.count,
isOwner: isAuthor,
returnTo: href
})
: null,
);
const blogMain = div({ class: "tribe-main" },
div({ class: "job-section" },
div({ class: "blog-detail-text", innerHTML: sanitizeHtml(renderTextWithStyles(blog.text || "")) })
div({ class: "blog-detail-text", [RAW_HTML]: sanitizeHtml(renderStyledHtml(blog.text || "")) })
),
renderEngagement(blog.id,
renderOpinionsVoting('/blogs/opinions', blog.id, blog.opinions, href, blog.opinions_inhabitants),
@ -172,8 +216,8 @@ exports.singleBlogView = async (blog, comments = [], params = {}) => {
return template(
blog.subject || i18n.blogTitle,
section(div({ class: "tags-header" }, h2(i18n.blogTitle), p(i18n.blogDescription))),
renderFilterBar("ALL", params.q, false),
section(div({ class: "tags-header module-header-line" }, h2(i18n.blogTitle), p(i18n.blogDescription))),
renderFilterBar("ALL", params.q, false, null, params.censusList),
section(div({ class: "tribe-details" }, blogSide, blogMain))
);
};