OASIS_MOBILE/src/views/mailing_view.js
oasis-bot 948b6460d9 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).
2026-10-19 00:30:25 +02:00

321 lines
18 KiB
JavaScript

const { div, h2, h3, p, section, button, form, a, span, br, textarea, input, label, select, option, details, summary, ul, li, table, tr, td } = require("../server/node_modules/hyperaxe");
const { TEXT_CAP } = require('../backend/long_text');
const { template, i18n, userLink, renderStateChip, renderOpenClosedChip, renderContentActions, renderModuleStats, moduleIsEmpty, contentDeleteAction, paged } = require("./main_views");
const { renderStyledText } = require("../backend/renderStyledText");
const moment = require("../server/node_modules/moment");
const { config } = require("../server/SSB_server.js");
const userId = config.keys.id;
const BASE_FILTERS = ["ALL", "RECENT", "MINE", "SUBSCRIBED"];
const TYPE_FILTERS = ["OPEN", "CLOSED"];
const STATUS_FILTERS = ["ACTIVE", "ARCHIVED"];
const HISTORY_MODES = ["DATE", "THREADS", "INHABITANTS"];
const cap = (s) => s.charAt(0) + s.slice(1).toLowerCase();
const baseLabel = (f) => String(i18n[`mailingFilter${cap(f)}`] || f).toUpperCase();
const typeLabel = (t) => String(i18n[`mailingType${cap(t)}`] || t).toUpperCase();
const statusLabel = (s) => String(i18n[`mailingStatus${cap(s)}`] || s).toUpperCase();
const historyLabel = (m) => String(i18n[`mailingHistory${cap(m)}`] || m).toUpperCase();
const listHref = (list) => `/mailing/${encodeURIComponent(list.id)}`;
const fmt = (d) => moment(d).format("YYYY/MM/DD HH:mm");
const statusChip = (list) => renderStateChip(list.status === "ACTIVE" ? "mutuals" : "closed", list.status === "ACTIVE" ? "✓" : "✗", statusLabel(list.status));
const countsLine = (list) => span({ class: "card-label activity-update-counts mailing-counts" }, `👥: ${list.participantCount || 0} · 💬 ${list.messageCount || 0}`);
const subscribeForm = (list, returnTo) => {
if (list.isOwner) return null;
if (list.closed) {
return list.isMember
? form({ method: "POST", action: `/mailing/leave/${encodeURIComponent(list.id)}` }, button({ type: "submit", class: "tribe-action-btn danger-btn" }, String(i18n.subscriptionUnsubscribe).toUpperCase()))
: null;
}
return form({ method: "POST", action: "/subscriptions/toggle" },
input({ type: "hidden", name: "target", value: list.id }),
input({ type: "hidden", name: "scope", value: "mailing" }),
input({ type: "hidden", name: "on", value: list.isMember ? "0" : "1" }),
input({ type: "hidden", name: "returnTo", value: returnTo }),
button({ type: "submit", class: list.isMember ? "tribe-action-btn danger-btn" : "tribe-action-btn" }, String(list.isMember ? i18n.subscriptionUnsubscribe : i18n.subscriptionSubscribe).toUpperCase())
);
};
const writeForm = (list) => (list.status === "ACTIVE" && (!list.closed || list.isMember))
? form({ method: "GET", action: `${listHref(list)}#compose` }, input({ type: "hidden", name: "write", value: "1" }), button({ type: "submit", class: "tribe-action-btn" }, String(i18n.mailingWriteButton).toUpperCase()))
: null;
const renderTags = (tags) => (Array.isArray(tags) && tags.length)
? div({ class: "card-tags" }, ...tags.map(t => a({ href: `/search?query=%23${encodeURIComponent(t)}`, class: "tag-link" }, `#${t}`)))
: null;
const mailingChipFor = (mode, census) => {
if (mode === "ALL") return true;
if (!Array.isArray(census)) return true;
if (mode === "MINE") return census.some(l => String(l.author) === String(userId));
if (mode === "SUBSCRIBED") return census.some(l => l.isMember && String(l.author) !== String(userId));
if (mode === "RECENT") return census.length > 0;
if (mode === "OPEN") return census.some(l => !l.closed);
if (mode === "CLOSED") return census.some(l => l.closed);
if (STATUS_FILTERS.includes(mode)) return census.some(l => l.status === mode);
return census.length > 0;
};
exports.mailingChipFor = mailingChipFor;
const renderArchiveItem = (list, params = {}) =>
li({ class: "mailing-archive-item" },
div({ class: "emergency-update-head mailing-archive-head" },
div({ class: "mailing-archive-meta" },
a({ href: listHref(list), class: "user-link" }, list.title || "—"),
countsLine(list),
renderOpenClosedChip(list.listType, { statusChipOPEN: typeLabel("OPEN"), statusChipCLOSED: typeLabel("CLOSED") }),
statusChip(list),
list.isMember && !list.isOwner ? renderStateChip("mutuals", "✉", i18n.subscriptionOn) : null
),
renderContentActions(list.id, listHref(list), { author: list.author, favKind: "mailing", isFavorite: list.isFavorite, reportTitle: list.title, spread: (params.spreadMap && params.spreadMap.get(list.id)) || null, deleteAction: String(list.author) === String(userId) ? contentDeleteAction("mailingList", list.id) : null })
)
);
const chipButtons = (modes, filter, census, labelOf) =>
modes.filter(m => m === filter || mailingChipFor(m, census)).map(m => button({ type: "submit", name: "filter", value: m, class: filter === m ? "filter-btn active" : "filter-btn" }, labelOf(m)));
const renderFilters = (filter, q, census, emptyMod) =>
section(
div({ class: "filters" },
form({ method: "GET", action: "/mailing", class: "ui-toolbar ui-toolbar--filters" },
...(emptyMod ? [] : [
...chipButtons(BASE_FILTERS, filter, census, baseLabel),
...chipButtons(TYPE_FILTERS, filter, census, typeLabel),
...chipButtons(STATUS_FILTERS, filter, census, statusLabel)
]),
button({ type: "submit", name: "filter", value: "CREATE", class: "create-button" }, i18n.mailingCreate)
)
)
);
const renderForm = (list) =>
div({ class: "report-form" },
h2(list ? i18n.mailingUpdateSectionTitle : i18n.mailingCreateSectionTitle),
form({ method: "POST", action: list ? `/mailing/update/${encodeURIComponent(list.id)}` : "/mailing/create" },
label(i18n.mailingTitleLabel), br(),
input({ type: "text", name: "title", maxlength: "100", required: true, placeholder: i18n.mailingTitlePlaceholder, value: list ? list.title : "" }), br(),
label(i18n.mailingDescriptionLabel), br(),
textarea({ name: "description", rows: 4, maxlength: "1000", placeholder: i18n.mailingDescriptionPlaceholder }, list ? list.description : ""), br(),
list
? [
label(i18n.mailingStatusLabel), br(),
select({ name: "status" }, ...STATUS_FILTERS.map(st => option({ value: st, ...(st === list.status ? { selected: true } : {}) }, statusLabel(st)))), br(), br()
]
: [
label(i18n.mailingTypeLabel), br(),
select({ name: "listType" }, ...TYPE_FILTERS.map(t => option({ value: t }, typeLabel(t)))), br(), br()
],
(!list || list.closed)
? [
label(i18n.mailingMembersLabel), br(),
textarea({ name: "members", rows: 3, maxlength: "4000", placeholder: i18n.mailingMembersPlaceholder }, list ? list.members.filter(m => m !== list.author).join(", ") : ""), br()
]
: null,
label(i18n.mailingTagsLabel), br(),
input({ type: "text", name: "tags", maxlength: "200", placeholder: i18n.mailingTagsPlaceholder, value: list ? list.tags.join(", ") : "" }), br(), br(),
button({ type: "submit", class: "create-button" }, list ? i18n.mailingUpdate : i18n.mailingCreate)
)
);
const renderHeader = () => div({ class: "tags-header module-header-line" }, h2(i18n.mailingTitle), p(i18n.mailingDescription));
exports.mailingView = async (lists, filter = "ALL", params = {}) => {
const list = Array.isArray(lists) ? lists : [];
const q = String(params.q || "").trim();
const census = Array.isArray(params.censusList) ? params.censusList : list;
const f = String(filter || "ALL").toUpperCase();
const isForm = f === "CREATE" || f === "EDIT";
const emptyMod = !isForm && moduleIsEmpty(census, "ALL", "ALL", q);
return template(
i18n.mailingTitle,
section(
div({ class: "tags-header module-header-line" }, h2(i18n.mailingTitle), p(i18n.mailingDescription)),
renderFilters(isForm ? "ALL" : f, q, isForm ? [] : census, emptyMod)
),
section(
isForm
? renderForm(f === "EDIT" ? params.list : null)
: [
emptyMod ? null : div({ class: "filters activity-filter-chips activity-toolbar-row" },
renderModuleStats(list.length, [
{ label: typeLabel("OPEN"), count: list.filter(l => !l.closed).length },
{ label: typeLabel("CLOSED"), count: list.filter(l => l.closed).length },
{ label: statusLabel("ARCHIVED"), count: list.filter(l => l.status === "ARCHIVED").length }
]),
form({ method: "GET", action: "/mailing", class: "filter-box" },
input({ type: "hidden", name: "filter", value: f }),
input({ type: "text", name: "q", value: q, placeholder: i18n.mailingSearchPlaceholder, class: "filter-box__input" }),
div({ class: "filter-box__controls" }, button({ type: "submit", class: "filter-box__button" }, i18n.searchButton))
)
),
list.length ? ul({ class: "mailing-archive" }, ...paged(list.slice().sort((x, y) => (y.lastActivityTs || 0) - (x.lastActivityTs || 0))).map(l => renderArchiveItem(l, params))) : div({ class: "tribe-grid" }, p(i18n.mailingNoItems))
]
)
);
};
const renderMessage = (list, m, opts = {}) =>
div({ class: "mailing-message" },
div({ class: "emergency-update-head mailing-message-head" },
span({ class: "mailing-message-subject" }, m.subject || i18n.pmNoSubject),
list.canWrite && !opts.noReply
? div({ class: "tribe-side-actions emergency-update-actions" },
form({ method: "GET", action: `${listHref(list)}#compose`, class: "mailing-reply-form" },
input({ type: "hidden", name: "write", value: "1" }),
input({ type: "hidden", name: "reply", value: m.mid }),
input({ type: "hidden", name: "history", value: opts.mode || "DATE" }),
button({ type: "submit", class: "tribe-action-btn" }, String(i18n.pmReply || "Reply").toUpperCase())
)
)
: null
),
div({ class: "mailing-message-text" }, ...renderStyledText(m.text || "")),
p({ class: "card-footer" }, span({ class: "date-link" }, `${fmt(m.sentAt)}`), userLink(m.author))
);
const renderHistory = (list, history, mode) => {
if (!history.length) return p(i18n.mailingNoMessages);
if (mode === "THREADS") {
const groups = new Map();
for (const m of history) {
if (!groups.has(m.thread)) groups.set(m.thread, []);
groups.get(m.thread).push(m);
}
const ordered = Array.from(groups.values()).sort((x, y) => y[y.length - 1].ts - x[x.length - 1].ts);
return div({ class: "mailing-threads" }, ...ordered.map(msgs => {
const root = msgs[0];
const replies = msgs.slice(1);
return div({ class: "mailing-thread" },
renderMessage(list, root, { mode }),
replies.length
? details({ class: "mailing-thread-details" },
summary(`${replies.length} ${replies.length === 1 ? (i18n.pmReply || "reply") : (i18n.pmReplies || "replies")}`),
...replies.map(r => renderMessage(list, r, { mode }))
)
: null
);
}));
}
if (mode === "INHABITANTS") {
const groups = new Map();
for (const m of history) {
if (!groups.has(m.author)) groups.set(m.author, []);
groups.get(m.author).push(m);
}
const ordered = Array.from(groups.entries()).sort((x, y) => y[1].length - x[1].length);
return div({ class: "mailing-by-author" }, ...ordered.map(([author, msgs]) =>
div({ class: "mailing-author-group" },
h3({ class: "mailing-author-title" }, userLink(author), span({ class: "mailing-author-count" }, ` · ${msgs.length}`)),
...msgs.slice().sort((x, y) => y.ts - x.ts).map(m => renderMessage(list, m, { mode }))
)
));
}
return div({ class: "mailing-by-date" }, ...history.slice().sort((x, y) => y.ts - x.ts).map(m => renderMessage(list, m, { mode })));
};
const renderCompose = (list, params) => {
const parent = params.replyTo ? (list.history || []).find(m => m.mid === params.replyTo || m.key === params.replyTo) : null;
const draft = params.draft || null;
const subject = draft ? String(draft.subject || "") : parent ? (/^\s*RE:/i.test(parent.subject) ? parent.subject : `RE: ${parent.subject}`) : "";
const thread = parent ? parent.thread : draft ? String(draft.thread || "") : "";
return div({ class: "div-center audio-form mailing-compose", id: "compose" },
h2(parent ? i18n.mailingReplyTitle : i18n.mailingWriteTitle),
form({ method: "POST", action: `/mailing/${encodeURIComponent(list.id)}/message` },
thread ? input({ type: "hidden", name: "thread", value: thread }) : null,
params.returnTo ? input({ type: "hidden", name: "returnTo", value: params.returnTo }) : null,
label(i18n.pmSubject), br(),
input({ type: "text", name: "subject", maxlength: "150", placeholder: i18n.pmSubjectHint, value: subject }), br(), br(),
label(i18n.pmText), br(),
textarea({ name: "text", rows: 8, maxlength: String(TEXT_CAP), required: true, autofocus: true, placeholder: parent ? parent.text.split("\n").map(l => `> ${l}`).join("\n") : i18n.mailingTextPlaceholder }, draft ? String(draft.text || "") : ""), br(), br(),
button({ type: "submit", class: "create-button" }, i18n.mailingSend)
)
);
};
exports.singleMailingView = async (list, params = {}) => {
const isAuthor = String(list.author) === String(userId);
const href = listHref(list);
const census = Array.isArray(params.censusList) ? params.censusList : [];
const history = Array.isArray(list.history) ? list.history : [];
const mode = HISTORY_MODES.includes(String(params.history || "").toUpperCase()) ? String(params.history).toUpperCase() : "THREADS";
const q = String(params.q || "").trim();
const shown = q ? history.filter(m => [m.subject, m.text, m.author].some(v => String(v || "").toLowerCase().includes(q.toLowerCase()))) : history;
const historyChips = history.length
? div({ class: "activity-sub-filter" },
...HISTORY_MODES.map(m => form({ class: "sub-filter-form", method: "GET", action: `${href}#history` },
input({ type: "hidden", name: "history", value: m }),
input({ type: "hidden", name: "q", value: q }),
button({ type: "submit", class: mode === m ? "filter-btn active" : "filter-btn" }, historyLabel(m))
))
)
: null;
const sideActions = [subscribeForm(list, href)].filter(Boolean);
const ownerActions = isAuthor
? [
form({ method: "GET", action: "/mailing" }, input({ type: "hidden", name: "filter", value: "edit" }), input({ type: "hidden", name: "id", value: list.id }), button({ type: "submit", class: "update-btn" }, i18n.mailingUpdate))
]
: [];
const side = div({ class: "tribe-side" },
div({ class: "card-header activity-card-header" },
renderContentActions(list.id, href, { author: list.author, favKind: "mailing", isFavorite: list.isFavorite, reportTitle: list.title, spread: params.spread || null, deleteAction: isAuthor ? contentDeleteAction("mailingList", list.id) : null })
),
div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, list.title)),
div({ class: "card-chips-row" }, renderOpenClosedChip(list.listType, { statusChipOPEN: typeLabel("OPEN"), statusChipCLOSED: typeLabel("CLOSED") }), statusChip(list)),
list.description ? p({ class: "tribe-side-description" }, ...renderStyledText(list.description)) : null,
table({ class: "tribe-info-table jobs-info-table" },
tr(
td({ class: "tribe-info-label" }, i18n.createdAtLabel || "Created at"),
td({ class: "tribe-info-value", colspan: "3" }, fmt(list.createdAt))
),
tr(
td({ class: "tribe-info-value", colspan: "4" }, userLink(list.author))
)
),
div({ class: "tribe-card-members" },
span({ class: "tribe-members-count" }, `${i18n.mailingSubscribers}: ${list.participantCount || 0}`)
),
sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null,
isAuthor
? div({ class: "tribe-side-actions housing-status-row" },
span({ class: "card-label" }, `${i18n.statusLabel || "Status"}: `),
statusChip(list),
form({ method: "POST", action: `/mailing/status/${encodeURIComponent(list.id)}`, class: "inline-form" },
button({ class: "tribe-action-btn", type: "submit", name: "status", value: list.status === "ACTIVE" ? "ARCHIVED" : "ACTIVE" },
String(list.status === "ACTIVE" ? i18n.mailingArchive : i18n.mailingActivate).toUpperCase())
)
)
: null,
div({ class: "tribe-card-members" },
span({ class: "tribe-members-count" }, `${i18n.mailingMessagesLabel}: ${list.messageCount || 0}`)
),
writeForm(list) ? div({ class: "tribe-side-actions" }, writeForm(list)) : null,
div({ class: "doc-export-actions" },
form({ method: "GET", action: `${href}/pdf` }, button({ type: "submit", class: "filter-btn" }, i18n.mailingGenerateReport)),
form({ method: "POST", action: `${href}/share` }, button({ type: "submit", class: "filter-btn" }, i18n.sharePm))
),
renderTags(list.tags),
ownerActions.length ? div({ class: "tribe-side-actions owner-actions" }, ...ownerActions) : null
);
const main = div({ class: "tribe-main" },
params.write && list.status === "ACTIVE" && (!list.closed || list.isMember) ? renderCompose(list, params) : null,
div({ class: "card-section mailing-history", id: "history" },
historyChips,
history.length
? form({ method: "GET", action: `${href}#history`, class: "filter-box" },
input({ type: "hidden", name: "history", value: mode }),
input({ type: "text", name: "q", value: q, placeholder: i18n.mailingHistorySearchPlaceholder, class: "filter-box__input" }),
div({ class: "filter-box__controls" }, button({ type: "submit", class: "filter-box__button" }, i18n.searchButton))
)
: null,
renderHistory(list, shown, mode)
)
);
return template(
list.title,
section(renderHeader(), renderFilters("ALL", "", census, census.length === 0)),
section(div({ class: "tribe-details" }, side, main))
);
};