feed_view.js 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. const { div, h2, p, section, button, form, a, span, textarea, br, input, h1 } = require("../server/node_modules/hyperaxe");
  2. const { template, i18n } = require("./main_views");
  3. const { config } = require("../server/SSB_server.js");
  4. const { renderTextWithStyles } = require("../backend/renderTextWithStyles");
  5. const opinionCategories = require("../backend/opinion_categories");
  6. const { sanitizeHtml } = require('../backend/sanitizeHtml');
  7. const FEED_TEXT_MIN = Number(config?.feed?.minLength ?? 1);
  8. const FEED_TEXT_MAX = Number(config?.feed?.maxLength ?? 280);
  9. const normalizeOptions = (opts) => {
  10. if (typeof opts === "string") return { filter: String(opts || "ALL").toUpperCase(), q: "", tag: "", msg: "" };
  11. if (!opts || typeof opts !== "object") return { filter: "ALL", q: "", tag: "", msg: "" };
  12. return {
  13. filter: String(opts.filter || "ALL").toUpperCase(),
  14. q: typeof opts.q === "string" ? opts.q : "",
  15. tag: typeof opts.tag === "string" ? opts.tag : "",
  16. msg: typeof opts.msg === "string" ? opts.msg : ""
  17. };
  18. };
  19. const formatDate = (feed) => {
  20. const ts = feed?.value?.timestamp || Date.parse(feed?.value?.content?.createdAt || "") || 0;
  21. return ts ? new Date(ts).toLocaleString() : "";
  22. };
  23. const extractTags = (text) => {
  24. const list = (String(text || "").match(/#[A-Za-z0-9_]{1,32}/g) || []).map((t) => t.slice(1).toLowerCase());
  25. return Array.from(new Set(list));
  26. };
  27. const rewriteHashtagLinks = (html) => {
  28. return String(html || '').replace(
  29. /href=(["'])\/hashtag\/([^"'?#\s<]+)\1/gi,
  30. (m, q, rawTag) => {
  31. let t = String(rawTag || '');
  32. try { t = decodeURIComponent(t); } catch {}
  33. t = t.replace(/[^A-Za-z0-9_]/g, '');
  34. const tag = t.toLowerCase();
  35. const query = encodeURIComponent(`#${tag}`);
  36. return `href=${q}/search?query=${query}${q}`;
  37. }
  38. );
  39. };
  40. const generateFilterButtons = (filters, currentFilter, action, extra = {}) => {
  41. const cur = String(currentFilter || "").toUpperCase();
  42. const hiddenInputs = (obj) =>
  43. Object.entries(obj)
  44. .filter(([, v]) => v !== undefined && v !== null && String(v).length > 0)
  45. .map(([k, v]) => input({ type: "hidden", name: k, value: String(v) }));
  46. return filters.map((mode) =>
  47. form(
  48. { method: "GET", action },
  49. input({ type: "hidden", name: "filter", value: mode }),
  50. ...hiddenInputs(extra),
  51. button({ type: "submit", class: cur === mode ? "filter-btn active" : "filter-btn" }, i18n[mode + "Button"] || mode)
  52. )
  53. );
  54. };
  55. const renderVotesSummary = (opinions = {}) => {
  56. const entries = Object.entries(opinions).filter(([, v]) => Number(v) > 0);
  57. if (!entries.length) return null;
  58. entries.sort((a, b) => Number(b[1]) - Number(a[1]) || String(a[0]).localeCompare(String(b[0])));
  59. return div(
  60. { class: "votes" },
  61. entries.map(([category, count]) => span({ class: "vote-category" }, `${category}: ${count}`))
  62. );
  63. };
  64. const renderFeedCard = (feed) => {
  65. const content = feed.value.content || {};
  66. const rawText = typeof content.text === "string" ? content.text : "";
  67. const safeText = rawText.trim();
  68. if (!safeText) return null;
  69. const voteEntries = Object.entries(content.opinions || {});
  70. const totalCount = voteEntries.reduce((sum, [, count]) => sum + (Number(count) || 0), 0);
  71. const createdAt = formatDate(feed);
  72. const me = config?.keys?.id;
  73. const alreadyRefeeded = Array.isArray(content.refeeds_inhabitants) && me ? content.refeeds_inhabitants.includes(me) : false;
  74. const alreadyVoted = Array.isArray(content.opinions_inhabitants) && me ? content.opinions_inhabitants.includes(me) : false;
  75. const authorId = content.author || feed.value.author || "";
  76. const refeedsNum = Number(content.refeeds || 0) || 0;
  77. const styledHtml = rewriteHashtagLinks(renderTextWithStyles(safeText));
  78. return div(
  79. { class: "feed-card" },
  80. div(
  81. { class: "feed-row" },
  82. div(
  83. { class: "refeed-column" },
  84. h1(String(refeedsNum)),
  85. form(
  86. { method: "POST", action: `/feed/refeed/${encodeURIComponent(feed.key)}` },
  87. button({ class: alreadyRefeeded ? "refeed-btn active" : "refeed-btn", type: "submit", disabled: !!alreadyRefeeded }, i18n.refeedButton)
  88. ),
  89. alreadyRefeeded ? p({ class: "muted" }, i18n.alreadyRefeeded) : null
  90. ),
  91. div(
  92. { class: "feed-main" },
  93. div({ class: "feed-text", innerHTML: sanitizeHtml(styledHtml) }),
  94. h2(
  95. `${i18n.totalOpinions}: ${totalCount}`,
  96. ...(() => {
  97. const entries = voteEntries.filter(([, v]) => Number(v) > 0);
  98. if (!entries.length) return [];
  99. const maxVal = Math.max(...entries.map(([, v]) => Number(v)));
  100. const dominant = entries.filter(([, v]) => Number(v) === maxVal).map(([k]) => i18n['vote' + k.charAt(0).toUpperCase() + k.slice(1)] || k);
  101. return [
  102. span({ style: 'margin:0 8px;opacity:0.5;' }, '|'),
  103. span({ style: 'font-weight:700;' }, `${i18n.moreVoted || 'More Voted'}: ${dominant.join(' + ')}`)
  104. ];
  105. })()
  106. ),
  107. p(
  108. { class: "card-footer" },
  109. span({ class: "date-link" }, `${createdAt} ${i18n.performed} `),
  110. a({ href: `/author/${encodeURIComponent(authorId)}`, class: "user-link" }, `${authorId}`),
  111. content._textEdited ? span({ class: "edited-badge" }, ` · ${i18n.edited || "edited"}`) : null
  112. )
  113. )
  114. ),
  115. div(
  116. { class: "votes-wrapper" },
  117. renderVotesSummary(content.opinions || {}),
  118. div(
  119. { class: "voting-buttons" },
  120. opinionCategories.map((cat) =>
  121. form(
  122. { method: "POST", action: `/feed/opinions/${encodeURIComponent(feed.key)}/${cat}` },
  123. button(
  124. { class: alreadyVoted ? "vote-btn disabled" : "vote-btn", type: "submit", disabled: !!alreadyVoted },
  125. `${i18n["vote" + cat.charAt(0).toUpperCase() + cat.slice(1)] || cat} [${content.opinions?.[cat] || 0}]`
  126. )
  127. )
  128. )
  129. ),
  130. alreadyVoted ? p({ class: "muted" }, i18n.alreadyVoted) : null
  131. )
  132. );
  133. };
  134. exports.feedView = (feeds, opts = "ALL") => {
  135. const { filter, q, tag, msg } = normalizeOptions(opts);
  136. const title =
  137. filter === "MINE"
  138. ? i18n.MINEButton
  139. : filter === "TODAY"
  140. ? i18n.TODAYButton
  141. : filter === "TOP"
  142. ? i18n.TOPButton
  143. : filter === "CREATE"
  144. ? i18n.createFeedTitle
  145. : tag
  146. ? `${i18n.filteredByTag || i18n.filteredByTagTitle || "Filtered by tag"}: #${tag}`
  147. : q
  148. ? `${i18n.searchTitle || "Search"}: “${q}”`
  149. : i18n.feedTitle;
  150. const header = div({ class: "tags-header" }, h2(title), p(i18n.FeedshareYourOpinions));
  151. const successBanner = msg === 'feedPublished'
  152. ? div({ class: 'feed-success-msg' }, p('✓ ' + (i18n.feedPublishedSuccess || 'Feed published successfully!')))
  153. : null;
  154. const extra = { q, tag };
  155. return template(
  156. title,
  157. section(
  158. header,
  159. successBanner,
  160. div(
  161. { class: "mode-buttons-row" },
  162. ...generateFilterButtons(["ALL", "MINE", "TODAY", "TOP"], filter, "/feed", extra),
  163. form({ method: "GET", action: "/feed/create" }, button({ type: "submit", class: "create-button filter-btn" }, i18n.createFeedTitle || "Create Feed"))
  164. ),
  165. div(
  166. { class: "feed-tools-row" },
  167. form(
  168. { method: "GET", action: "/feed", class: "feed-search-form" },
  169. input({ type: "hidden", name: "filter", value: filter }),
  170. tag ? input({ type: "hidden", name: "tag", value: tag }) : null,
  171. input({ type: "text", name: "q", value: q, placeholder: i18n.searchPlaceholder || "Search", class: "feed-search-input" }),
  172. button({ type: "submit", class: "filter-btn feed-search-btn" }, i18n.searchButton || "Search")
  173. )
  174. ),
  175. section(
  176. filter === "CREATE"
  177. ? form(
  178. { method: "POST", action: "/feed/create" },
  179. textarea({
  180. name: "text",
  181. placeholder: i18n.feedPlaceholder,
  182. required: true,
  183. minlength: String(FEED_TEXT_MIN),
  184. maxlength: String(FEED_TEXT_MAX),
  185. rows: 4,
  186. cols: 50
  187. }),
  188. br(),
  189. button({ type: "submit", class: "create-button" }, i18n.createFeedButton)
  190. )
  191. : feeds && feeds.length > 0
  192. ? div({ class: "feed-container" }, feeds.map((feed) => renderFeedCard(feed)).filter(Boolean))
  193. : div({ class: "no-results" }, p(i18n.noFeedsFound))
  194. )
  195. )
  196. );
  197. };
  198. exports.feedCreateView = (opts = {}) => {
  199. const { q, tag } = normalizeOptions(opts);
  200. return template(
  201. i18n.createFeedTitle,
  202. section(
  203. div({ class: "tags-header" }, h2(i18n.createFeedTitle), p(i18n.FeedshareYourOpinions)),
  204. div({ class: "mode-buttons-row" }, ...generateFilterButtons(["ALL", "MINE", "TODAY", "TOP"], "CREATE", "/feed", { q, tag })),
  205. form(
  206. { method: "POST", action: "/feed/create" },
  207. textarea({
  208. name: "text",
  209. required: true,
  210. minlength: String(FEED_TEXT_MIN),
  211. maxlength: String(FEED_TEXT_MAX),
  212. rows: 5,
  213. cols: 50,
  214. placeholder: i18n.feedPlaceholder
  215. }),
  216. br(),
  217. button({ type: "submit", class: "create-button" }, i18n.createFeedButton || "Send Feed!")
  218. )
  219. )
  220. );
  221. };