Oasis 0.8.7 mobile (nodejs-project baseline)
This commit is contained in:
commit
e563b86ede
226 changed files with 141968 additions and 0 deletions
396
src/views/feed_view.js
Normal file
396
src/views/feed_view.js
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
const { div, h2, p, section, button, form, a, span, textarea, br, input, h1, label } = require("../server/node_modules/hyperaxe");
|
||||
const { template, i18n, renderOpinionsVoting, userLink, renderContentActions } = require("./main_views");
|
||||
const { config } = require("../server/SSB_server.js");
|
||||
const { renderTextWithStyles } = require("../backend/renderTextWithStyles");
|
||||
const moment = require("../server/node_modules/moment");
|
||||
const { sanitizeHtml } = require('../backend/sanitizeHtml');
|
||||
const { renderUrl } = require("../backend/renderUrl");
|
||||
|
||||
const FEED_TEXT_MIN = Number(config?.feed?.minLength ?? 1);
|
||||
const FEED_TEXT_MAX = Number(config?.feed?.maxLength ?? 280);
|
||||
|
||||
const normalizeOptions = (opts) => {
|
||||
if (typeof opts === "string") return { filter: String(opts || "ALL").toUpperCase(), q: "", tag: "", msg: "" };
|
||||
if (!opts || typeof opts !== "object") return { filter: "ALL", q: "", tag: "", msg: "" };
|
||||
return {
|
||||
filter: String(opts.filter || "ALL").toUpperCase(),
|
||||
q: typeof opts.q === "string" ? opts.q : "",
|
||||
tag: typeof opts.tag === "string" ? opts.tag : "",
|
||||
msg: typeof opts.msg === "string" ? opts.msg : ""
|
||||
};
|
||||
};
|
||||
|
||||
const formatDate = (feed) => {
|
||||
const ts = feed?.value?.timestamp || Date.parse(feed?.value?.content?.createdAt || "") || 0;
|
||||
return ts ? new Date(ts).toLocaleString() : "";
|
||||
};
|
||||
|
||||
const extractTags = (text) => {
|
||||
const list = (String(text || "").match(/#[A-Za-z0-9_]{1,32}/g) || []).map((t) => t.slice(1).toLowerCase());
|
||||
return Array.from(new Set(list));
|
||||
};
|
||||
|
||||
const rewriteHashtagLinks = (html) => {
|
||||
return String(html || '').replace(
|
||||
/href=(["'])\/hashtag\/([^"'?#\s<]+)\1/gi,
|
||||
(m, q, rawTag) => {
|
||||
let t = String(rawTag || '');
|
||||
try { t = decodeURIComponent(t); } catch {}
|
||||
t = t.replace(/[^A-Za-z0-9_]/g, '');
|
||||
const tag = t.toLowerCase();
|
||||
const query = encodeURIComponent(`#${tag}`);
|
||||
return `href=${q}/search?query=${query}${q}`;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const generateFilterButtons = (filters, currentFilter, action, extra = {}) => {
|
||||
const cur = String(currentFilter || "").toUpperCase();
|
||||
const hiddenInputs = (obj) =>
|
||||
Object.entries(obj)
|
||||
.filter(([, v]) => v !== undefined && v !== null && String(v).length > 0)
|
||||
.map(([k, v]) => input({ type: "hidden", name: k, value: String(v) }));
|
||||
|
||||
return filters.map((mode) =>
|
||||
form(
|
||||
{ method: "GET", action },
|
||||
input({ type: "hidden", name: "filter", value: mode }),
|
||||
...hiddenInputs(extra),
|
||||
button({ type: "submit", class: cur === mode ? "filter-btn active" : "filter-btn" }, i18n[mode + "Button"] || mode)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const renderVotesSummary = (opinions = {}) => {
|
||||
const entries = Object.entries(opinions).filter(([, v]) => Number(v) > 0);
|
||||
if (!entries.length) return null;
|
||||
entries.sort((a, b) => Number(b[1]) - Number(a[1]) || String(a[0]).localeCompare(String(b[0])));
|
||||
return div(
|
||||
{ class: "votes" },
|
||||
entries.map(([category, count]) => span({ class: "vote-category" }, `${category}: ${count}`))
|
||||
);
|
||||
};
|
||||
|
||||
const renderCardField = (labelText, value) =>
|
||||
div(
|
||||
{ class: "card-field" },
|
||||
span({ class: "card-label" }, labelText),
|
||||
span({ class: "card-value" }, value)
|
||||
);
|
||||
|
||||
const renderFeedCommentsSection = (feedKey, comments = []) => {
|
||||
const list = Array.isArray(comments) ? comments : [];
|
||||
const commentsCount = list.length;
|
||||
|
||||
return div(
|
||||
{ class: "vote-comments-section" },
|
||||
div(
|
||||
{ class: "comments-count" },
|
||||
span({ class: "card-label" }, i18n.voteCommentsLabel + ": "),
|
||||
span({ class: "card-value" }, String(commentsCount))
|
||||
),
|
||||
div(
|
||||
{ class: "comment-form-wrapper" },
|
||||
h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel || i18n.feedPostComment || "Post a comment"),
|
||||
form(
|
||||
{ method: "POST", action: `/feed/${encodeURIComponent(feedKey)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
|
||||
textarea({
|
||||
id: "comment-text",
|
||||
name: "text",
|
||||
rows: 4,
|
||||
class: "comment-textarea",
|
||||
placeholder: i18n.voteNewCommentPlaceholder || ""
|
||||
}),
|
||||
div({ class: "comment-file-upload" }, label(i18n.uploadMedia || "Upload media"), input({ type: "file", name: "blob" })),
|
||||
br(),
|
||||
button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton || i18n.feedPostComment || "Send")
|
||||
)
|
||||
),
|
||||
list.length
|
||||
? div(
|
||||
{ class: "comments-list" },
|
||||
list.map((c) => {
|
||||
const author = c?.value?.author || "";
|
||||
const ts = c?.value?.timestamp || c?.timestamp;
|
||||
const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
|
||||
const relDate = ts ? moment(ts).fromNow() : "";
|
||||
|
||||
const content = c?.value?.content || {};
|
||||
const text = content.text || c?.value?.text || "";
|
||||
const threadRoot = content.fork || content.root || null;
|
||||
|
||||
return div(
|
||||
{ class: "votations-comment-card" },
|
||||
span(
|
||||
{ class: "created-at" },
|
||||
span(i18n.createdBy),
|
||||
author ? userLink(author) : span("(unknown)"),
|
||||
absDate ? span(" | ") : "",
|
||||
absDate ? span({ class: "votations-comment-date" }, absDate) : "",
|
||||
relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
|
||||
relDate && threadRoot
|
||||
? a({ href: `/thread/${encodeURIComponent(threadRoot)}#${encodeURIComponent(c.key)}` }, relDate)
|
||||
: ""
|
||||
),
|
||||
p({ class: "votations-comment-text" }, ...renderUrl(text))
|
||||
);
|
||||
})
|
||||
)
|
||||
: p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet || i18n.noComments || "")
|
||||
);
|
||||
};
|
||||
|
||||
const renderFeedCard = (feed) => {
|
||||
const content = feed.value.content || {};
|
||||
const rawText = typeof content.text === "string" ? content.text : "";
|
||||
const safeText = rawText.trim();
|
||||
if (!safeText) return null;
|
||||
|
||||
const voteEntries = Object.entries(content.opinions || {});
|
||||
const totalCount = voteEntries.reduce((sum, [, count]) => sum + (Number(count) || 0), 0);
|
||||
const createdAt = formatDate(feed);
|
||||
const me = config?.keys?.id;
|
||||
|
||||
const alreadyRefeeded = Array.isArray(content.refeeds_inhabitants) && me ? content.refeeds_inhabitants.includes(me) : false;
|
||||
|
||||
const authorId = feed.value.author || content.author || "";
|
||||
const refeedsNum = Number(content.refeeds || 0) || 0;
|
||||
const commentCount = Number(content.commentCount || 0);
|
||||
const styledHtml = rewriteHashtagLinks(renderTextWithStyles(safeText));
|
||||
|
||||
return div(
|
||||
{ class: "trending-card feed-card" + (authorId && String(authorId) === String(me) ? " own-content" : "") },
|
||||
div(
|
||||
{ class: "card-header activity-card-header" },
|
||||
span(),
|
||||
renderContentActions(feed.key, `/feed/${encodeURIComponent(feed.key)}`)
|
||||
),
|
||||
div(
|
||||
{ class: "card-section feed-card-body" },
|
||||
div(
|
||||
{ class: "feed-row" },
|
||||
div(
|
||||
{ class: "refeed-column" },
|
||||
h1(String(refeedsNum)),
|
||||
form(
|
||||
{ method: "POST", action: `/feed/refeed/${encodeURIComponent(feed.key)}` },
|
||||
button({ class: alreadyRefeeded ? "refeed-btn active" : "refeed-btn", type: "submit", ...(alreadyRefeeded ? { disabled: true } : {}) }, i18n.refeedButton)
|
||||
),
|
||||
alreadyRefeeded ? p({ class: "muted" }, i18n.alreadyRefeeded) : null
|
||||
),
|
||||
div(
|
||||
{ class: "feed-main" },
|
||||
div({ class: "feed-text", innerHTML: sanitizeHtml(styledHtml) }),
|
||||
h2(
|
||||
`${i18n.totalOpinions}: ${totalCount}`,
|
||||
...(() => {
|
||||
const entries = voteEntries.filter(([, v]) => Number(v) > 0);
|
||||
if (!entries.length) return [];
|
||||
const maxVal = Math.max(...entries.map(([, v]) => Number(v)));
|
||||
const dominant = entries.filter(([, v]) => Number(v) === maxVal).map(([k]) => i18n['vote' + k.charAt(0).toUpperCase() + k.slice(1)] || k);
|
||||
return [
|
||||
span({ style: 'margin:0 8px;opacity:0.5;' }, '|'),
|
||||
span({ style: 'font-weight:700;' }, `${i18n.moreVoted || 'More Voted'}: ${dominant.join(' + ')}`)
|
||||
];
|
||||
})()
|
||||
),
|
||||
p(
|
||||
{ class: "card-footer" },
|
||||
span({ class: "date-link" }, `${createdAt} ${i18n.performed} `),
|
||||
userLink(authorId),
|
||||
content._textEdited ? span({ class: "edited-badge" }, ` · ${i18n.edited || "edited"}`) : null
|
||||
)
|
||||
)
|
||||
),
|
||||
div(
|
||||
{ class: "card-comments-summary" },
|
||||
span({ class: "card-label" }, `${i18n.voteCommentsLabel || "Comments"}:`),
|
||||
span({ class: "card-value" }, String(commentCount)),
|
||||
br(),
|
||||
br(),
|
||||
form(
|
||||
{ method: "GET", action: `/feed/${encodeURIComponent(feed.key)}` },
|
||||
button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton || i18n.feedOpenDiscussion || "Open Discussion")
|
||||
)
|
||||
),
|
||||
div(
|
||||
{ class: "card-comments-summary feed-opinions-section" },
|
||||
div(
|
||||
{ class: "comments-count" },
|
||||
span({ class: "card-label" }, (i18n.opinionsTitle || "Opinions") + ": "),
|
||||
span({ class: "card-value" }, String(totalCount))
|
||||
),
|
||||
renderOpinionsVoting('/feed/opinions', feed.key, content.opinions, null, content.opinions_inhabitants)
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
exports.feedView = (feeds, opts = "ALL") => {
|
||||
const { filter, q, tag, msg } = normalizeOptions(opts);
|
||||
|
||||
const title =
|
||||
filter === "MINE"
|
||||
? i18n.MINEButton
|
||||
: filter === "TODAY"
|
||||
? i18n.TODAYButton
|
||||
: filter === "TOP"
|
||||
? i18n.TOPButton
|
||||
: filter === "CREATE"
|
||||
? i18n.createFeedTitle
|
||||
: tag
|
||||
? `${i18n.filteredByTag || i18n.filteredByTagTitle || "Filtered by tag"}: #${tag}`
|
||||
: q
|
||||
? `${i18n.searchTitle || "Search"}: “${q}”`
|
||||
: i18n.feedTitle;
|
||||
|
||||
const header = div({ class: "tags-header" }, h2(title), p(i18n.FeedshareYourOpinions));
|
||||
const successBanner = msg === 'feedPublished'
|
||||
? div({ class: 'feed-success-msg' }, p('✓ ' + (i18n.feedPublishedSuccess || 'Feed published successfully!')))
|
||||
: null;
|
||||
|
||||
const extra = { q, tag };
|
||||
|
||||
return template(
|
||||
title,
|
||||
section(
|
||||
header,
|
||||
successBanner,
|
||||
div(
|
||||
{ class: "mode-buttons-row" },
|
||||
...generateFilterButtons(["ALL", "MINE", "TODAY", "TOP"], filter, "/feed", extra),
|
||||
form({ method: "GET", action: "/feed/create" }, button({ type: "submit", class: "create-button filter-btn" }, i18n.createFeedTitle || "Create Feed"))
|
||||
),
|
||||
div(
|
||||
{ class: "feed-tools-row" },
|
||||
form(
|
||||
{ method: "GET", action: "/feed", class: "feed-search-form" },
|
||||
input({ type: "hidden", name: "filter", value: filter }),
|
||||
tag ? input({ type: "hidden", name: "tag", value: tag }) : null,
|
||||
input({ type: "text", name: "q", value: q, placeholder: i18n.searchPlaceholder || "Search", class: "feed-search-input" }),
|
||||
button({ type: "submit", class: "filter-btn feed-search-btn" }, i18n.searchButton || "Search")
|
||||
)
|
||||
),
|
||||
section(
|
||||
filter === "CREATE"
|
||||
? form(
|
||||
{ method: "POST", action: "/feed/create" },
|
||||
textarea({
|
||||
name: "text",
|
||||
placeholder: i18n.feedPlaceholder,
|
||||
required: true,
|
||||
minlength: String(FEED_TEXT_MIN),
|
||||
maxlength: String(FEED_TEXT_MAX),
|
||||
rows: 4,
|
||||
cols: 50
|
||||
}),
|
||||
br(),
|
||||
button({ type: "submit", class: "create-button" }, i18n.createFeedButton)
|
||||
)
|
||||
: feeds && feeds.length > 0
|
||||
? div({ class: "feed-container" }, feeds.map((feed) => renderFeedCard(feed)).filter(Boolean))
|
||||
: div({ class: "no-results" }, p(i18n.noFeedsFound))
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
exports.feedCreateView = (opts = {}) => {
|
||||
const { q, tag } = normalizeOptions(opts);
|
||||
|
||||
return template(
|
||||
i18n.createFeedTitle,
|
||||
section(
|
||||
div({ class: "tags-header" }, h2(i18n.createFeedTitle), p(i18n.FeedshareYourOpinions)),
|
||||
div({ class: "mode-buttons-row" }, ...generateFilterButtons(["ALL", "MINE", "TODAY", "TOP"], "CREATE", "/feed", { q, tag })),
|
||||
form(
|
||||
{ method: "POST", action: "/feed/create" },
|
||||
textarea({
|
||||
name: "text",
|
||||
required: true,
|
||||
minlength: String(FEED_TEXT_MIN),
|
||||
maxlength: String(FEED_TEXT_MAX),
|
||||
rows: 5,
|
||||
cols: 50,
|
||||
placeholder: i18n.feedPlaceholder
|
||||
}),
|
||||
br(),
|
||||
button({ type: "submit", class: "create-button" }, i18n.createFeedButton || "Send Feed!")
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
exports.singleFeedView = (feed, comments = []) => {
|
||||
const content = feed.value?.content || {};
|
||||
const rawText = typeof content.text === "string" ? content.text : "";
|
||||
const safeText = rawText.trim();
|
||||
const authorId = feed.value?.author || content.author || "";
|
||||
const createdAt = formatDate(feed);
|
||||
const styledHtml = rewriteHashtagLinks(renderTextWithStyles(safeText));
|
||||
const me = config?.keys?.id;
|
||||
const alreadyRefeeded = Array.isArray(content.refeeds_inhabitants) && me ? content.refeeds_inhabitants.includes(me) : false;
|
||||
const refeedsNum = Number(content.refeeds || 0) || 0;
|
||||
const tags = extractTags(safeText);
|
||||
|
||||
return template(
|
||||
i18n.feedDetailTitle || "Feed",
|
||||
section(
|
||||
div(
|
||||
{ class: "filters" },
|
||||
form(
|
||||
{ method: "GET", action: "/feed", class: "ui-toolbar ui-toolbar--filters" },
|
||||
button({ type: "submit", name: "filter", value: "ALL", class: "filter-btn" }, i18n.ALLButton || "ALL"),
|
||||
button({ type: "submit", name: "filter", value: "MINE", class: "filter-btn" }, i18n.MINEButton || "MINE"),
|
||||
button({ type: "submit", name: "filter", value: "TODAY", class: "filter-btn" }, i18n.TODAYButton || "TODAY"),
|
||||
button({ type: "submit", name: "filter", value: "TOP", class: "filter-btn" }, i18n.TOPButton || "TOP"),
|
||||
form({ method: "GET", action: "/feed/create" }, button({ type: "submit", class: "create-button" }, i18n.createFeedTitle || "Create Feed"))
|
||||
)
|
||||
),
|
||||
div(
|
||||
{ class: "bookmark-item card feed-detail-card" },
|
||||
br,
|
||||
div(
|
||||
{ class: "feed-row" },
|
||||
div(
|
||||
{ class: "refeed-column" },
|
||||
h1(String(refeedsNum)),
|
||||
form(
|
||||
{ method: "POST", action: `/feed/refeed/${encodeURIComponent(feed.key)}` },
|
||||
button({ class: alreadyRefeeded ? "refeed-btn active" : "refeed-btn", type: "submit", ...(alreadyRefeeded ? { disabled: true } : {}) }, i18n.refeedButton)
|
||||
),
|
||||
alreadyRefeeded ? p({ class: "muted" }, i18n.alreadyRefeeded) : null
|
||||
),
|
||||
div(
|
||||
{ class: "feed-main" },
|
||||
div({ class: "feed-text", innerHTML: sanitizeHtml(styledHtml) }),
|
||||
tags.length
|
||||
? div(
|
||||
{ class: "card-tags" },
|
||||
tags.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
|
||||
)
|
||||
: null,
|
||||
br,
|
||||
p(
|
||||
{ class: "card-footer" },
|
||||
span({ class: "date-link" }, `${createdAt} ${i18n.performed} `),
|
||||
userLink(authorId),
|
||||
content._textEdited ? span({ class: "edited-badge" }, ` · ${i18n.edited || "edited"}`) : null
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
renderFeedCommentsSection(feed.key, comments),
|
||||
div(
|
||||
{ class: "card-comments-summary feed-opinions-section" },
|
||||
div(
|
||||
{ class: "comments-count" },
|
||||
span({ class: "card-label" }, (i18n.opinionsTitle || "Opinions") + ": "),
|
||||
span({ class: "card-value" }, String(Object.values(content.opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0)))
|
||||
),
|
||||
renderOpinionsVoting('/feed/opinions', feed.key, content.opinions, null, content.opinions_inhabitants)
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue