feed_view.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388
  1. const { div, h2, p, section, button, form, a, span, textarea, br, input, h1, label } = require("../server/node_modules/hyperaxe");
  2. const { template, i18n, userLink } = 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 moment = require("../server/node_modules/moment");
  7. const { sanitizeHtml } = require('../backend/sanitizeHtml');
  8. const { renderUrl } = require("../backend/renderUrl");
  9. const FEED_TEXT_MIN = Number(config?.feed?.minLength ?? 1);
  10. const FEED_TEXT_MAX = Number(config?.feed?.maxLength ?? 280);
  11. const normalizeOptions = (opts) => {
  12. if (typeof opts === "string") return { filter: String(opts || "ALL").toUpperCase(), q: "", tag: "", msg: "" };
  13. if (!opts || typeof opts !== "object") return { filter: "ALL", q: "", tag: "", msg: "" };
  14. return {
  15. filter: String(opts.filter || "ALL").toUpperCase(),
  16. q: typeof opts.q === "string" ? opts.q : "",
  17. tag: typeof opts.tag === "string" ? opts.tag : "",
  18. msg: typeof opts.msg === "string" ? opts.msg : ""
  19. };
  20. };
  21. const formatDate = (feed) => {
  22. const ts = feed?.value?.timestamp || Date.parse(feed?.value?.content?.createdAt || "") || 0;
  23. return ts ? new Date(ts).toLocaleString() : "";
  24. };
  25. const extractTags = (text) => {
  26. const list = (String(text || "").match(/#[A-Za-z0-9_]{1,32}/g) || []).map((t) => t.slice(1).toLowerCase());
  27. return Array.from(new Set(list));
  28. };
  29. const rewriteHashtagLinks = (html) => {
  30. return String(html || '').replace(
  31. /href=(["'])\/hashtag\/([^"'?#\s<]+)\1/gi,
  32. (m, q, rawTag) => {
  33. let t = String(rawTag || '');
  34. try { t = decodeURIComponent(t); } catch {}
  35. t = t.replace(/[^A-Za-z0-9_]/g, '');
  36. const tag = t.toLowerCase();
  37. const query = encodeURIComponent(`#${tag}`);
  38. return `href=${q}/search?query=${query}${q}`;
  39. }
  40. );
  41. };
  42. const generateFilterButtons = (filters, currentFilter, action, extra = {}) => {
  43. const cur = String(currentFilter || "").toUpperCase();
  44. const hiddenInputs = (obj) =>
  45. Object.entries(obj)
  46. .filter(([, v]) => v !== undefined && v !== null && String(v).length > 0)
  47. .map(([k, v]) => input({ type: "hidden", name: k, value: String(v) }));
  48. return filters.map((mode) =>
  49. form(
  50. { method: "GET", action },
  51. input({ type: "hidden", name: "filter", value: mode }),
  52. ...hiddenInputs(extra),
  53. button({ type: "submit", class: cur === mode ? "filter-btn active" : "filter-btn" }, i18n[mode + "Button"] || mode)
  54. )
  55. );
  56. };
  57. const renderVotesSummary = (opinions = {}) => {
  58. const entries = Object.entries(opinions).filter(([, v]) => Number(v) > 0);
  59. if (!entries.length) return null;
  60. entries.sort((a, b) => Number(b[1]) - Number(a[1]) || String(a[0]).localeCompare(String(b[0])));
  61. return div(
  62. { class: "votes" },
  63. entries.map(([category, count]) => span({ class: "vote-category" }, `${category}: ${count}`))
  64. );
  65. };
  66. const renderCardField = (labelText, value) =>
  67. div(
  68. { class: "card-field" },
  69. span({ class: "card-label" }, labelText),
  70. span({ class: "card-value" }, value)
  71. );
  72. const renderFeedCommentsSection = (feedKey, comments = []) => {
  73. const list = Array.isArray(comments) ? comments : [];
  74. const commentsCount = list.length;
  75. return div(
  76. { class: "vote-comments-section" },
  77. div(
  78. { class: "comments-count" },
  79. span({ class: "card-label" }, i18n.voteCommentsLabel + ": "),
  80. span({ class: "card-value" }, String(commentsCount))
  81. ),
  82. div(
  83. { class: "comment-form-wrapper" },
  84. h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel || i18n.feedPostComment || "Post a comment"),
  85. form(
  86. { method: "POST", action: `/feed/${encodeURIComponent(feedKey)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
  87. textarea({
  88. id: "comment-text",
  89. name: "text",
  90. rows: 4,
  91. class: "comment-textarea",
  92. placeholder: i18n.voteNewCommentPlaceholder || ""
  93. }),
  94. div({ class: "comment-file-upload" }, label(i18n.uploadMedia || "Upload media"), input({ type: "file", name: "blob" })),
  95. br(),
  96. button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton || i18n.feedPostComment || "Send")
  97. )
  98. ),
  99. list.length
  100. ? div(
  101. { class: "comments-list" },
  102. list.map((c) => {
  103. const author = c?.value?.author || "";
  104. const ts = c?.value?.timestamp || c?.timestamp;
  105. const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
  106. const relDate = ts ? moment(ts).fromNow() : "";
  107. const userName = author && author.includes("@") ? author.split("@")[1] : author;
  108. const content = c?.value?.content || {};
  109. const text = content.text || c?.value?.text || "";
  110. const threadRoot = content.fork || content.root || null;
  111. return div(
  112. { class: "votations-comment-card" },
  113. span(
  114. { class: "created-at" },
  115. span(i18n.createdBy),
  116. author ? a({ href: `/author/${encodeURIComponent(author)}` }, `@${userName}`) : span("(unknown)"),
  117. absDate ? span(" | ") : "",
  118. absDate ? span({ class: "votations-comment-date" }, absDate) : "",
  119. relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
  120. relDate && threadRoot
  121. ? a({ href: `/thread/${encodeURIComponent(threadRoot)}#${encodeURIComponent(c.key)}` }, relDate)
  122. : ""
  123. ),
  124. p({ class: "votations-comment-text" }, ...renderUrl(text))
  125. );
  126. })
  127. )
  128. : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet || i18n.noComments || "")
  129. );
  130. };
  131. const renderFeedCard = (feed) => {
  132. const content = feed.value.content || {};
  133. const rawText = typeof content.text === "string" ? content.text : "";
  134. const safeText = rawText.trim();
  135. if (!safeText) return null;
  136. const voteEntries = Object.entries(content.opinions || {});
  137. const totalCount = voteEntries.reduce((sum, [, count]) => sum + (Number(count) || 0), 0);
  138. const createdAt = formatDate(feed);
  139. const me = config?.keys?.id;
  140. const alreadyRefeeded = Array.isArray(content.refeeds_inhabitants) && me ? content.refeeds_inhabitants.includes(me) : false;
  141. const alreadyVoted = Array.isArray(content.opinions_inhabitants) && me ? content.opinions_inhabitants.includes(me) : false;
  142. const authorId = content.author || feed.value.author || "";
  143. const refeedsNum = Number(content.refeeds || 0) || 0;
  144. const commentCount = Number(content.commentCount || 0);
  145. const styledHtml = rewriteHashtagLinks(renderTextWithStyles(safeText));
  146. return div(
  147. { class: "feed-card" },
  148. div(
  149. { class: "feed-row" },
  150. div(
  151. { class: "refeed-column" },
  152. h1(String(refeedsNum)),
  153. form(
  154. { method: "POST", action: `/feed/refeed/${encodeURIComponent(feed.key)}` },
  155. button({ class: alreadyRefeeded ? "refeed-btn active" : "refeed-btn", type: "submit", ...(alreadyRefeeded ? { disabled: true } : {}) }, i18n.refeedButton)
  156. ),
  157. alreadyRefeeded ? p({ class: "muted" }, i18n.alreadyRefeeded) : null
  158. ),
  159. div(
  160. { class: "feed-main" },
  161. div({ class: "feed-text", innerHTML: sanitizeHtml(styledHtml) }),
  162. h2(
  163. `${i18n.totalOpinions}: ${totalCount}`,
  164. ...(() => {
  165. const entries = voteEntries.filter(([, v]) => Number(v) > 0);
  166. if (!entries.length) return [];
  167. const maxVal = Math.max(...entries.map(([, v]) => Number(v)));
  168. const dominant = entries.filter(([, v]) => Number(v) === maxVal).map(([k]) => i18n['vote' + k.charAt(0).toUpperCase() + k.slice(1)] || k);
  169. return [
  170. span({ style: 'margin:0 8px;opacity:0.5;' }, '|'),
  171. span({ style: 'font-weight:700;' }, `${i18n.moreVoted || 'More Voted'}: ${dominant.join(' + ')}`)
  172. ];
  173. })()
  174. ),
  175. p(
  176. { class: "card-footer" },
  177. span({ class: "date-link" }, `${createdAt} ${i18n.performed} `),
  178. userLink(authorId),
  179. content._textEdited ? span({ class: "edited-badge" }, ` · ${i18n.edited || "edited"}`) : null
  180. )
  181. )
  182. ),
  183. div(
  184. { class: "card-comments-summary" },
  185. span({ class: "card-label" }, `${i18n.voteCommentsLabel || "Comments"}:`),
  186. span({ class: "card-value" }, String(commentCount)),
  187. br(),
  188. br(),
  189. form(
  190. { method: "GET", action: `/feed/${encodeURIComponent(feed.key)}` },
  191. button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton || i18n.feedOpenDiscussion || "Open Discussion")
  192. )
  193. )
  194. );
  195. };
  196. exports.feedView = (feeds, opts = "ALL") => {
  197. const { filter, q, tag, msg } = normalizeOptions(opts);
  198. const title =
  199. filter === "MINE"
  200. ? i18n.MINEButton
  201. : filter === "TODAY"
  202. ? i18n.TODAYButton
  203. : filter === "TOP"
  204. ? i18n.TOPButton
  205. : filter === "CREATE"
  206. ? i18n.createFeedTitle
  207. : tag
  208. ? `${i18n.filteredByTag || i18n.filteredByTagTitle || "Filtered by tag"}: #${tag}`
  209. : q
  210. ? `${i18n.searchTitle || "Search"}: “${q}”`
  211. : i18n.feedTitle;
  212. const header = div({ class: "tags-header" }, h2(title), p(i18n.FeedshareYourOpinions));
  213. const successBanner = msg === 'feedPublished'
  214. ? div({ class: 'feed-success-msg' }, p('✓ ' + (i18n.feedPublishedSuccess || 'Feed published successfully!')))
  215. : null;
  216. const extra = { q, tag };
  217. return template(
  218. title,
  219. section(
  220. header,
  221. successBanner,
  222. div(
  223. { class: "mode-buttons-row" },
  224. ...generateFilterButtons(["ALL", "MINE", "TODAY", "TOP"], filter, "/feed", extra),
  225. form({ method: "GET", action: "/feed/create" }, button({ type: "submit", class: "create-button filter-btn" }, i18n.createFeedTitle || "Create Feed"))
  226. ),
  227. div(
  228. { class: "feed-tools-row" },
  229. form(
  230. { method: "GET", action: "/feed", class: "feed-search-form" },
  231. input({ type: "hidden", name: "filter", value: filter }),
  232. tag ? input({ type: "hidden", name: "tag", value: tag }) : null,
  233. input({ type: "text", name: "q", value: q, placeholder: i18n.searchPlaceholder || "Search", class: "feed-search-input" }),
  234. button({ type: "submit", class: "filter-btn feed-search-btn" }, i18n.searchButton || "Search")
  235. )
  236. ),
  237. section(
  238. filter === "CREATE"
  239. ? form(
  240. { method: "POST", action: "/feed/create" },
  241. textarea({
  242. name: "text",
  243. placeholder: i18n.feedPlaceholder,
  244. required: true,
  245. minlength: String(FEED_TEXT_MIN),
  246. maxlength: String(FEED_TEXT_MAX),
  247. rows: 4,
  248. cols: 50
  249. }),
  250. br(),
  251. button({ type: "submit", class: "create-button" }, i18n.createFeedButton)
  252. )
  253. : feeds && feeds.length > 0
  254. ? div({ class: "feed-container" }, feeds.map((feed) => renderFeedCard(feed)).filter(Boolean))
  255. : div({ class: "no-results" }, p(i18n.noFeedsFound))
  256. )
  257. )
  258. );
  259. };
  260. exports.feedCreateView = (opts = {}) => {
  261. const { q, tag } = normalizeOptions(opts);
  262. return template(
  263. i18n.createFeedTitle,
  264. section(
  265. div({ class: "tags-header" }, h2(i18n.createFeedTitle), p(i18n.FeedshareYourOpinions)),
  266. div({ class: "mode-buttons-row" }, ...generateFilterButtons(["ALL", "MINE", "TODAY", "TOP"], "CREATE", "/feed", { q, tag })),
  267. form(
  268. { method: "POST", action: "/feed/create" },
  269. textarea({
  270. name: "text",
  271. required: true,
  272. minlength: String(FEED_TEXT_MIN),
  273. maxlength: String(FEED_TEXT_MAX),
  274. rows: 5,
  275. cols: 50,
  276. placeholder: i18n.feedPlaceholder
  277. }),
  278. br(),
  279. button({ type: "submit", class: "create-button" }, i18n.createFeedButton || "Send Feed!")
  280. )
  281. )
  282. );
  283. };
  284. exports.singleFeedView = (feed, comments = []) => {
  285. const content = feed.value?.content || {};
  286. const rawText = typeof content.text === "string" ? content.text : "";
  287. const safeText = rawText.trim();
  288. const authorId = content.author || feed.value?.author || "";
  289. const createdAt = formatDate(feed);
  290. const styledHtml = rewriteHashtagLinks(renderTextWithStyles(safeText));
  291. const me = config?.keys?.id;
  292. const alreadyVoted = Array.isArray(content.opinions_inhabitants) && me ? content.opinions_inhabitants.includes(me) : false;
  293. const alreadyRefeeded = Array.isArray(content.refeeds_inhabitants) && me ? content.refeeds_inhabitants.includes(me) : false;
  294. const refeedsNum = Number(content.refeeds || 0) || 0;
  295. const tags = extractTags(safeText);
  296. return template(
  297. i18n.feedDetailTitle || "Feed",
  298. section(
  299. div(
  300. { class: "filters" },
  301. form(
  302. { method: "GET", action: "/feed", class: "ui-toolbar ui-toolbar--filters" },
  303. button({ type: "submit", name: "filter", value: "ALL", class: "filter-btn" }, i18n.ALLButton || "ALL"),
  304. button({ type: "submit", name: "filter", value: "MINE", class: "filter-btn" }, i18n.MINEButton || "MINE"),
  305. button({ type: "submit", name: "filter", value: "TODAY", class: "filter-btn" }, i18n.TODAYButton || "TODAY"),
  306. button({ type: "submit", name: "filter", value: "TOP", class: "filter-btn" }, i18n.TOPButton || "TOP"),
  307. form({ method: "GET", action: "/feed/create" }, button({ type: "submit", class: "create-button" }, i18n.createFeedTitle || "Create Feed"))
  308. )
  309. ),
  310. div(
  311. { class: "bookmark-item card feed-detail-card" },
  312. br,
  313. div(
  314. { class: "feed-row" },
  315. div(
  316. { class: "refeed-column" },
  317. h1(String(refeedsNum)),
  318. form(
  319. { method: "POST", action: `/feed/refeed/${encodeURIComponent(feed.key)}` },
  320. button({ class: alreadyRefeeded ? "refeed-btn active" : "refeed-btn", type: "submit", ...(alreadyRefeeded ? { disabled: true } : {}) }, i18n.refeedButton)
  321. ),
  322. alreadyRefeeded ? p({ class: "muted" }, i18n.alreadyRefeeded) : null
  323. ),
  324. div(
  325. { class: "feed-main" },
  326. div({ class: "feed-text", innerHTML: sanitizeHtml(styledHtml) }),
  327. tags.length
  328. ? div(
  329. { class: "card-tags" },
  330. tags.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
  331. )
  332. : null,
  333. br,
  334. p(
  335. { class: "card-footer" },
  336. span({ class: "date-link" }, `${createdAt} ${i18n.performed} `),
  337. userLink(authorId),
  338. content._textEdited ? span({ class: "edited-badge" }, ` · ${i18n.edited || "edited"}`) : null
  339. )
  340. )
  341. ),
  342. div(
  343. { class: "voting-buttons" },
  344. opinionCategories.map((cat) =>
  345. form(
  346. { method: "POST", action: `/feed/opinions/${encodeURIComponent(feed.key)}/${cat}` },
  347. button(
  348. { class: alreadyVoted ? "vote-btn disabled" : "vote-btn", type: "submit", ...(alreadyVoted ? { disabled: true } : {}) },
  349. `${i18n["vote" + cat.charAt(0).toUpperCase() + cat.slice(1)] || cat} [${content.opinions?.[cat] || 0}]`
  350. )
  351. )
  352. )
  353. ),
  354. alreadyVoted ? p({ class: "muted" }, i18n.alreadyVoted) : null
  355. ),
  356. renderFeedCommentsSection(feed.key, comments)
  357. )
  358. );
  359. };