OASIS_MOBILE/src/views/polls_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

329 lines
14 KiB
JavaScript

const { div, h2, p, section, button, form, a, input, label, span, textarea, br, table, tr, td } = require("../server/node_modules/hyperaxe");
const { renderCommentsSection: renderSharedCommentsSection } = require("./comments_view");
const { template, i18n, userLink, renderOpinionsVoting, renderEngagement, renderSpreadButton, renderContentActions, renderStateChip, renderLifespanChip, renderSpreadEditWarning, renderModuleStatsBy, moduleIsEmpty, contentDeleteAction, paged, renderCreatedRows } = require("./main_views");
const moment = require("../server/node_modules/moment");
const { config } = require("../server/SSB_server.js");
const { MAX_OPTIONS, MIN_OPTIONS, MAX_OPTION_LENGTH } = require("../models/polls_model_limits");
const userId = config.keys.id;
const FILTERS = [
{ key: "ALL", i18n: "pollFilterAll" },
{ key: "RECENT", i18n: "pollFilterRecent" },
{ key: "MINE", i18n: "pollFilterMine" },
{ key: "TOP", i18n: "pollFilterTop" },
{ key: "VOTED", i18n: "pollFilterVoted" },
{ key: "OPEN", i18n: "pollFilterOpen" },
{ key: "CLOSED", i18n: "pollFilterClosed" }
];
const safeArr = (v) => (Array.isArray(v) ? v : []);
const pct = (n, total) => (total > 0 ? Math.round((Number(n) || 0) / total * 100) : 0);
const letterOf = (i) => {
let n = Number(i) || 0;
let out = "";
do {
out = String.fromCharCode(65 + (n % 26)) + out;
n = Math.floor(n / 26) - 1;
} while (n >= 0);
return out;
};
const pctStep = (n, total) => Math.round(pct(n, total) / 5) * 5;
const outcomeOf = (poll) => {
if (!poll.totalVoters) return i18n.pollNoVotesYet;
const max = Math.max(...poll.options.map(o => poll.counts[o] || 0));
if (!max) return i18n.pollNoVotesYet;
const winners = poll.options
.map((o, i) => ({ o, letter: letterOf(i) }))
.filter(({ o }) => (poll.counts[o] || 0) === max);
if (winners.length > 1) return `${i18n.pollTie}: ${winners.map(w => `${w.letter})`).join(", ")}`;
return `${winners[0].letter}) ${pct(max, poll.totalVoters)}%`;
};
const renderStatusChip = (poll) =>
renderStateChip(poll.status === "OPEN" ? "whole" : "mutuals", "◆",
poll.status === "OPEN" ? i18n.pollStatusOpen : i18n.pollStatusClosed);
const renderModeChips = (poll) => [
poll.anonymous ? renderStateChip("mutuals", "◈", i18n.pollAnonymous) : null,
poll.multiple ? renderStateChip("whole", "☰", i18n.pollMultiple) : null
].filter(Boolean);
const renderResults = (poll) =>
table({ class: "poll-results" },
...poll.options.map((opt, i) =>
tr({ class: poll.myChoices.includes(opt) ? "poll-result-row poll-result-mine" : "poll-result-row" },
td({ class: "poll-result-option" }, `${letterOf(i)})`),
td({ class: "poll-result-bar" },
div({ class: "poll-bar" },
div({ class: `poll-bar-fill poll-bar-fill-${pctStep(poll.counts[opt], poll.totalVoters)} poll-hue-${i % 8}` })
)
),
td({ class: "poll-result-count" },
`${poll.counts[opt] || 0} (${pct(poll.counts[opt], poll.totalVoters)}%)`)
)
)
);
const renderBallot = (poll, returnTo, basePath, extra = null) => {
if (poll.status === "CLOSED" || poll.hasVoted) return null;
const inputType = poll.multiple ? "checkbox" : "radio";
return form({ method: "POST", action: `${basePath}/vote/${encodeURIComponent(poll.id)}`, class: "poll-ballot" },
input({ type: "hidden", name: "returnTo", value: returnTo }),
...poll.options.map((opt, i) =>
div({ class: "poll-option-row" },
label({ for: `opt-${i}-${poll.id}` },
input({
type: inputType, name: "choices", value: opt, id: `opt-${i}-${poll.id}`,
...(poll.myChoices.includes(opt) ? { checked: true } : {})
}),
" ",
`${letterOf(i)}) ${opt}`
)
)
),
div({ class: "poll-ballot-actions" },
button({ type: "submit", class: "filter-btn" }, i18n.pollVoteButton),
...(Array.isArray(extra) ? extra.filter(Boolean) : [])
)
);
};
const renderPollCard = (poll, filter, spreadInfo, basePath = "/polls") => {
const href = `${basePath}/${encodeURIComponent(poll.id)}`;
const isOwn = String(poll.author) === String(userId);
const chips = [
renderStatusChip(poll),
...renderModeChips(poll),
poll.deadline
? renderStateChip(poll.status === "OPEN" ? "whole" : "mutuals", "◷", `${i18n.pollDeadline}: ${moment(poll.deadline).format("YYYY/MM/DD HH:mm")}`)
: null,
renderLifespanChip(poll.lifetime, i18n)
].filter(Boolean);
return div({ class: "tribe-card poll-card" + (isOwn ? " own-content" : "") },
div({ class: "card-header activity-card-header" },
span(),
renderContentActions(poll.id, href, { spread: spreadInfo || null, author: poll.author, favKind: 'polls', isFavorite: poll.isFavorite, reportTitle: poll.question, deleteAction: isOwn ? contentDeleteAction('poll', poll.id) : undefined })
),
div({ class: "tribe-card-body" },
div({ class: "shop-title-row" },
h2({ class: "tribe-card-title" }, a({ href }, poll.question))
),
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
div({ class: "tribe-card-members" },
span({ class: "tribe-members-count" }, `${i18n.pollVoters}: ${poll.totalVoters}`)
)
)
);
};
const renderCreateForm = (poll = null, params = {}) => {
const isEdit = !!poll;
const action = isEdit ? `/polls/update/${encodeURIComponent(poll.id)}` : "/polls/create";
return section(
params.spreadWarning || null,
div({ class: "div-center audio-form" },
h2(isEdit ? i18n.pollEditTitle : i18n.pollCreateTitle),
form({ method: "POST", action },
label(i18n.pollQuestion),
br(),
input({ type: "text", name: "question", required: true, maxlength: "300", value: isEdit ? poll.question : "", placeholder: i18n.pollQuestionPlaceholder }),
br(), br(),
label(i18n.pollOptions),
br(),
textarea({
name: "options",
rows: String(MAX_OPTIONS),
required: true,
maxlength: String(MAX_OPTIONS * (MAX_OPTION_LENGTH + 1))
}, isEdit ? poll.options.join("\n") : ""),
br(),
div({ class: "poll-switch" },
input({ type: "hidden", name: "anonymous", value: "0" }),
label(
input({ type: "checkbox", name: "anonymous", value: "1", ...(isEdit && poll.anonymous ? { checked: true } : {}) }),
" ", i18n.pollAnonymousLabel
)
),
div({ class: "poll-switch" },
input({ type: "hidden", name: "multiple", value: "0" }),
label(
input({ type: "checkbox", name: "multiple", value: "1", ...(isEdit && poll.multiple ? { checked: true } : {}) }),
" ", i18n.pollMultipleLabel
)
),
br(),
label(i18n.pollDeadline),
br(),
input({
type: "datetime-local", name: "deadline",
min: moment().format("YYYY-MM-DDTHH:mm"),
value: isEdit && poll.deadline ? moment(poll.deadline).format("YYYY-MM-DDTHH:mm") : ""
}),
br(), br(),
label(i18n.pollTags),
br(),
input({ type: "text", name: "tags", value: isEdit ? poll.tags.join(", ") : "" }),
br(), br(),
button({ type: "submit", class: "create-button" }, isEdit ? i18n.pollUpdateButton : i18n.pollPublishButton)
)
)
);
};
const renderComments = (poll, comments, basePath) => {
const href = `${basePath}/${encodeURIComponent(poll.id)}`;
return renderSharedCommentsSection({
action: `${href}/comments`,
comments: comments,
returnTo: href
});
};
const renderFilterBar = (filter, q, showSearch = true, items = null, censusList = null) => {
const emptyMod = items !== null && moduleIsEmpty(items, filter, "ALL", q);
const censusPl = Array.isArray(censusList) ? censusList : (Array.isArray(items) ? items : []);
const pollChip = (x) => {
const m = x.key;
if (m === filter) return true;
if (m === "TOP") return censusPl.length > 0;
if (m === "MINE") return censusPl.some(pl => String(pl.author || pl.createdBy) === String(userId));
if (m === "RECENT") return censusPl.length > 0;
if (m === "VOTED") return censusPl.some(pl => pl.hasVoted || safeArr(pl.myChoices).length > 0);
if (m === "OPEN" || m === "CLOSED") return censusPl.some(pl => String(pl.status || "OPEN").toUpperCase() === m);
return true;
};
return section(
div({ class: "filters" },
form({ method: "GET", action: "/polls", class: "ui-toolbar ui-toolbar--filters" },
...(emptyMod ? [] : FILTERS.filter(pollChip).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.pollCreateButton)
)
),
showSearch && !emptyMod ? div({ class: "filters activity-filter-chips activity-toolbar-row" },
items ? renderModuleStatsBy(items, pl => String(pl.status || 'OPEN').toUpperCase(), [{ value: 'OPEN', label: i18n.pollStatusOpen }, { value: 'CLOSED', label: i18n.pollStatusClosed }]) : null,
form({ method: "GET", action: "/polls", class: "filter-box" },
input({ type: "hidden", name: "filter", value: filter }),
input({ type: "text", name: "q", value: q || "", placeholder: i18n.pollSearchPlaceholder, class: "filter-box__input" }),
div({ class: "filter-box__controls" },
button({ type: "submit", class: "filter-box__button" }, i18n.searchButton)
)
)
) : null
);
};
exports.pollsView = async (polls = [], filter = "ALL", params = {}) => {
const mode = String(filter).toUpperCase();
const spreadMap = params.spreadMap instanceof Map ? params.spreadMap : new Map();
if (mode === "CREATE" || mode === "EDIT") {
const editing = params.poll || null;
if (editing) params = { ...params, spreadWarning: await renderSpreadEditWarning(editing.id) };
return template(
i18n.pollsTitle,
section(div({ class: "tags-header module-header-line" }, h2(i18n.pollsTitle), p(i18n.pollsDescription))),
renderFilterBar(mode, params.q, false),
renderCreateForm(editing, params)
);
}
return template(
i18n.pollsTitle,
section(div({ class: "tags-header module-header-line" }, h2(i18n.pollsTitle), p(i18n.pollsDescription))),
renderFilterBar(mode, params.q, true, polls, params.censusList),
section(
polls.length
? div({ class: "jobs-grid" }, ...paged(polls).map(pl => renderPollCard(pl, mode, spreadMap.get(pl.id))))
: div({ class: "no-content-box" }, p({ class: "no-content" }, i18n.pollsNoItems))
)
);
};
exports.singlePollView = async (poll, comments = [], params = {}) => {
const basePath = "/polls";
const href = `${basePath}/${encodeURIComponent(poll.id)}`;
const isOwn = String(poll.author) === String(userId);
const chips = [renderStatusChip(poll), ...renderModeChips(poll), renderLifespanChip(poll.lifetime, i18n)].filter(Boolean);
const infoRows = [];
const pushRow = (labelText, valueNode) =>
infoRows.push(tr(
td({ class: "tribe-info-label" }, labelText),
td({ class: "tribe-info-value" }, valueNode)
));
if (poll.deadline) pushRow(i18n.pollDeadline, moment(poll.deadline).format("YYYY/MM/DD HH:mm"));
pushRow(i18n.pollOutcome, outcomeOf(poll));
const tagsNode = poll.tags.length
? div({ class: "card-tags" }, poll.tags.map(tag =>
a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)))
: null;
const ownerActions = isOwn && poll.totalVoters === 0 && poll.status === "OPEN"
? div({ class: "tribe-side-actions" },
form({ method: "GET", action: "/polls" },
input({ type: "hidden", name: "filter", value: "EDIT" }),
input({ type: "hidden", name: "id", value: poll.id }),
button({ type: "submit", class: "update-btn" }, i18n.pollUpdateButton)
)
)
: null;
const pollSide = div({ class: "tribe-side" },
div({ class: "card-header activity-card-header" },
renderContentActions(poll.id, href, { spread: params.spreads || null, author: poll.author, favKind: 'polls', isFavorite: poll.isFavorite, returnTo: href, reportTitle: poll.question, deleteAction: isOwn ? contentDeleteAction('poll', poll.id) : undefined })
),
div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, poll.question)),
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
table({ class: "tribe-info-table jobs-info-table" }, ...infoRows, ...renderCreatedRows(poll.createdAt, poll.author)),
tagsNode,
div({ class: "tribe-card-members" },
span({ class: "tribe-members-count" }, `${i18n.pollVoters}: ${poll.totalVoters}`)
),
isOwn
? div({ class: "tribe-side-actions housing-status-row" },
span({ class: "card-label" }, `${i18n.pollStatusLabel}: `),
renderStatusChip(poll),
poll.status === "OPEN"
? form({ method: "POST", action: `/polls/close/${encodeURIComponent(poll.id)}` },
button({ type: "submit", class: "status-btn project-control-btn" }, i18n.pollCloseButton)
)
: null
)
: null,
ownerActions
);
const pollMain = div({ class: "tribe-main" },
div({ class: "job-section" },
h2({ class: "job-section-title" }, i18n.pollOutcome),
renderBallot(poll, href, basePath),
renderResults(poll)
),
poll.votersHidden
? null
: (poll.voters.length
? div({ class: "card-assigned-list" }, ...poll.voters.map(v => userLink(v)))
: null),
poll.tribeId ? null : renderEngagement(poll.id,
renderOpinionsVoting('/polls/opinions', poll.id, poll.opinions, href, poll.opinions_inhabitants),
renderComments(poll, comments, basePath)
)
);
return template(
poll.question,
section(div({ class: "tags-header module-header-line" }, h2(i18n.pollsTitle), p(i18n.pollsDescription))),
renderFilterBar(String(params.filter || "ALL").toUpperCase(), params.q, false),
section(div({ class: "tribe-details" }, pollSide, pollMain))
);
};
exports.renderPollCard = renderPollCard;
exports.renderResults = renderResults;
exports.renderBallot = renderBallot;
exports.outcomeOf = outcomeOf;
exports.letterOf = letterOf;