main_views.js 58 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949
  1. "use strict";
  2. const path = require("path");
  3. const fs = require("fs");
  4. const envPaths = require("../server/node_modules/env-paths");
  5. const debug = require("../server/node_modules/debug")("oasis");
  6. const highlightJs = require("../server/node_modules/highlight.js");
  7. const prettyMs = require("../server/node_modules/pretty-ms");
  8. const moment = require('../server/node_modules/moment');
  9. const { renderUrl } = require('../backend/renderUrl');
  10. const ssbClientGUI = require("../client/gui");
  11. const config = require("../server/ssb_config");
  12. const cooler = ssbClientGUI({ offline: config.offline });
  13. let ssb, userId;
  14. const getUserId = async () => {
  15. if (!ssb) ssb = await cooler.open();
  16. if (!userId) userId = ssb.id;
  17. return userId;
  18. };
  19. const { a, article, br, body, button, details, div, em, footer, form, h1, h2, head, header, hr, html, img, input, label, li, link, main, meta, nav, option, p, pre, section, select, span, summary, textarea, title, tr, ul, strong } = require("../server/node_modules/hyperaxe");
  20. const lodash = require("../server/node_modules/lodash");
  21. const markdown = require("./markdown");
  22. // set language
  23. const i18nBase = require("../client/assets/translations/i18n");
  24. let selectedLanguage = "en";
  25. let i18n = {};
  26. Object.assign(i18n, i18nBase[selectedLanguage]);
  27. exports.setLanguage = (language) => {
  28. selectedLanguage = language;
  29. const newLang = i18nBase[selectedLanguage] || i18nBase['en'];
  30. Object.keys(i18n).forEach(k => delete i18n[k]);
  31. Object.assign(i18n, newLang);
  32. };
  33. exports.i18n = i18n;
  34. exports.selectedLanguage = selectedLanguage;
  35. //markdown
  36. const markdownUrl = "https://commonmark.org/help/";
  37. const doctypeString = "<!DOCTYPE html>";
  38. const THREAD_PREVIEW_LENGTH = 3;
  39. const toAttributes = (obj) =>
  40. Object.entries(obj)
  41. .map(([key, val]) => `${key}=${val}`)
  42. .join(", ");
  43. const nbsp = "\xa0";
  44. const { getConfig } = require('../configs/config-manager.js');
  45. // menu INIT
  46. const navLink = ({ href, emoji, text, current }) =>
  47. li(
  48. a(
  49. { href, class: current ? "current" : "" },
  50. span({ class: "emoji" }, emoji),
  51. nbsp,
  52. text
  53. )
  54. );
  55. const customCSS = (filename) => {
  56. const customStyleFile = path.join(
  57. envPaths("oasis", { suffix: "" }).config,
  58. filename
  59. );
  60. try {
  61. if (fs.existsSync(customStyleFile)) {
  62. return link({ rel: "stylesheet", href: filename });
  63. }
  64. } catch (error) {
  65. return "";
  66. }
  67. };
  68. const renderPopularLink = () => {
  69. const popularMod = getConfig().modules.popularMod === 'on';
  70. if (popularMod) {
  71. return [
  72. navLink({ href: "/public/popular/day", emoji: "⌘", text: i18n.popular, class: "popular-link enabled" }),
  73. hr,
  74. ];
  75. }
  76. return '';
  77. };
  78. const renderTopicsLink = () => {
  79. const topicsMod = getConfig().modules.topicsMod === 'on';
  80. return topicsMod
  81. ? navLink({ href: "/public/latest/topics", emoji: "ϟ", text: i18n.topics, class: "topics-link enabled" })
  82. : '';
  83. };
  84. const renderSummariesLink = () => {
  85. const summariesMod = getConfig().modules.summariesMod === 'on';
  86. if (summariesMod) {
  87. return [
  88. navLink({ href: "/public/latest/summaries", emoji: "※", text: i18n.summaries, class: "summaries-link enabled" }),
  89. ];
  90. }
  91. return '';
  92. };
  93. const renderLatestLink = () => {
  94. const latestMod = getConfig().modules.latestMod === 'on';
  95. return latestMod
  96. ? navLink({ href: "/public/latest", emoji: "☄", text: i18n.latest, class: "latest-link enabled" })
  97. : '';
  98. };
  99. const renderThreadsLink = () => {
  100. const threadsMod = getConfig().modules.threadsMod === 'on';
  101. if (threadsMod) {
  102. return [
  103. navLink({ href: "/public/latest/threads", emoji: "♺", text: i18n.threads, class: "threads-link enabled" }),
  104. ];
  105. }
  106. return '';
  107. };
  108. const renderInvitesLink = () => {
  109. const invitesMod = getConfig().modules.invitesMod === 'on';
  110. return invitesMod
  111. ? navLink({ href: "/invites", emoji: "ꔹ", text: i18n.invites, class: "invites-link enabled" })
  112. : '';
  113. };
  114. const renderWalletLink = () => {
  115. const walletMod = getConfig().modules.walletMod === 'on';
  116. if (walletMod) {
  117. return [
  118. navLink({ href: "/wallet", emoji: "❄", text: i18n.wallet, class: "wallet-link enabled" }),
  119. ];
  120. }
  121. return '';
  122. };
  123. const renderLegacyLink = () => {
  124. const legacyMod = getConfig().modules.legacyMod === 'on';
  125. if (legacyMod) {
  126. return [
  127. navLink({ href: "/legacy", emoji: "ꖤ", text: i18n.legacy, class: "legacy-link enabled" }),
  128. ];
  129. }
  130. return '';
  131. };
  132. const renderCipherLink = () => {
  133. const cipherMod = getConfig().modules.cipherMod === 'on';
  134. if (cipherMod) {
  135. return [
  136. navLink({ href: "/cipher", emoji: "ꗄ", text: i18n.cipher, class: "cipher-link enabled" }),
  137. ];
  138. }
  139. return '';
  140. };
  141. const renderBookmarksLink = () => {
  142. const bookmarksMod = getConfig().modules.bookmarksMod === 'on';
  143. if (bookmarksMod) {
  144. return [
  145. hr(),
  146. navLink({ href: "/bookmarks", emoji: "ꔪ", text: i18n.bookmarksLabel, class: "bookmark-link enabled" }),
  147. ];
  148. }
  149. return '';
  150. };
  151. const renderImagesLink = () => {
  152. const imagesMod = getConfig().modules.imagesMod === 'on';
  153. if (imagesMod) {
  154. return [
  155. navLink({ href: "/images", emoji: "ꕥ", text: i18n.imagesLabel, class: "images-link enabled" }),
  156. ];
  157. }
  158. return '';
  159. };
  160. const renderVideosLink = () => {
  161. const videosMod = getConfig().modules.videosMod === 'on';
  162. if (videosMod) {
  163. return [
  164. navLink({ href: "/videos", emoji: "ꗟ", text: i18n.videosLabel, class: "videos-link enabled" }),
  165. ];
  166. }
  167. return '';
  168. };
  169. const renderAudiosLink = () => {
  170. const audiosMod = getConfig().modules.audiosMod === 'on';
  171. if (audiosMod) {
  172. return [
  173. navLink({ href: "/audios", emoji: "ꔿ", text: i18n.audiosLabel, class: "audios-link enabled" }),
  174. ];
  175. }
  176. return '';
  177. };
  178. const renderDocsLink = () => {
  179. const docsMod = getConfig().modules.docsMod === 'on';
  180. if (docsMod) {
  181. return [
  182. navLink({ href: "/documents", emoji: "ꕨ", text: i18n.docsLabel, class: "docs-link enabled" }),
  183. ];
  184. }
  185. return '';
  186. };
  187. const renderTagsLink = () => {
  188. const tagsMod = getConfig().modules.tagsMod === 'on';
  189. return tagsMod
  190. ? [
  191. navLink({ href: "/tags", emoji: "ꖶ", text: i18n.tagsLabel, class: "tags-link enabled" })
  192. ]
  193. : '';
  194. };
  195. const renderMultiverseLink = () => {
  196. const multiverseMod = getConfig().modules.multiverseMod === 'on';
  197. return multiverseMod
  198. ? [
  199. hr,
  200. navLink({ href: "/public/latest/extended", emoji: "∞", text: i18n.multiverse, class: "multiverse-link enabled" })
  201. ]
  202. : '';
  203. };
  204. const renderMarketLink = () => {
  205. const marketMod = getConfig().modules.marketMod === 'on';
  206. return marketMod
  207. ? [
  208. navLink({ href: "/market", emoji: "ꕻ", text: i18n.marketTitle }),
  209. ]
  210. : '';
  211. };
  212. const renderJobsLink = () => {
  213. const jobsMod = getConfig().modules.jobsMod === 'on';
  214. return jobsMod
  215. ? [
  216. navLink({ href: "/jobs", emoji: "ꗒ", text: i18n.jobsTitle }),
  217. ]
  218. : '';
  219. };
  220. const renderProjectsLink = () => {
  221. const projectsMod = getConfig().modules.projectsMod === 'on';
  222. return projectsMod
  223. ? [
  224. navLink({ href: "/projects", emoji: "ꕧ", text: i18n.projectsTitle }),
  225. ]
  226. : '';
  227. };
  228. const renderBankingLink = () => {
  229. const bankingMod = getConfig().modules.bankingMod === 'on';
  230. return bankingMod
  231. ? [
  232. hr(),
  233. navLink({ href: "/banking", emoji: "ꗴ", text: i18n.bankingTitle }),
  234. ]
  235. : '';
  236. };
  237. const renderTribesLink = () => {
  238. const tribesMod = getConfig().modules.tribesMod === 'on';
  239. return tribesMod
  240. ? [
  241. navLink({ href: "/tribes", emoji: "ꖥ", text: i18n.tribesTitle, class: "tribes-link enabled" }),
  242. hr(),
  243. ]
  244. : '';
  245. };
  246. const renderGovernanceLink = () => {
  247. const governanceMod = getConfig().modules.governanceMod === 'on';
  248. return governanceMod
  249. ? [
  250. navLink({ href: "/votes", emoji: "ꔰ", text: i18n.governanceTitle, class: "votes-link enabled" }),
  251. ]
  252. : '';
  253. };
  254. const renderTrendingLink = () => {
  255. const trendingMod = getConfig().modules.trendingMod === 'on';
  256. return trendingMod
  257. ? [
  258. navLink({ href: "/trending", emoji: "ꗝ", text: i18n.trendingLabel, class: "trending-link enabled" }),
  259. ]
  260. : '';
  261. };
  262. const renderReportsLink = () => {
  263. const reportsMod = getConfig().modules.reportsMod === 'on';
  264. return reportsMod
  265. ? [
  266. navLink({ href: "/reports", emoji: "ꕥ", text: i18n.reportsTitle, class: "reports-link enabled" }),
  267. ]
  268. : '';
  269. };
  270. const renderOpinionsLink = () => {
  271. const opinionsMod = getConfig().modules.opinionsMod === 'on';
  272. return opinionsMod
  273. ? [
  274. navLink({ href: "/opinions", emoji: "ꔍ", text: i18n.opinionsTitle, class: "opinions-link enabled" }),
  275. ]
  276. : '';
  277. };
  278. const renderTransfersLink = () => {
  279. const transfersMod = getConfig().modules.transfersMod === 'on';
  280. return transfersMod
  281. ? [
  282. navLink({ href: "/transfers", emoji: "ꘉ", text: i18n.transfersTitle, class: "transfers-link enabled" }),
  283. ]
  284. : '';
  285. };
  286. const renderFeedLink = () => {
  287. const feedMod = getConfig().modules.feedMod === 'on';
  288. return feedMod
  289. ? [
  290. hr(),
  291. navLink({ href: "/feed", emoji: "ꕿ", text: i18n.feedTitle, class: "feed-link enabled" }),
  292. ]
  293. : '';
  294. };
  295. const renderPixeliaLink = () => {
  296. const pixeliaMod = getConfig().modules.pixeliaMod === 'on';
  297. return pixeliaMod
  298. ? [
  299. navLink({ href: "/pixelia", emoji: "ꔘ", text: i18n.pixeliaTitle, class: "pixelia-link enabled" }),
  300. ]
  301. : '';
  302. };
  303. const renderForumLink = () => {
  304. const forumMod = getConfig().modules.forumMod === 'on';
  305. return forumMod
  306. ? [
  307. navLink({ href: "/forum", emoji: "ꕒ", text: i18n.forumTitle, class: "forum-link enabled" }),
  308. ]
  309. : '';
  310. };
  311. const renderAgendaLink = () => {
  312. const agendaMod = getConfig().modules.agendaMod === 'on';
  313. return agendaMod
  314. ? [
  315. navLink({ href: "/agenda", emoji: "ꗤ", text: i18n.agendaTitle, class: "agenda-link enabled" }),
  316. ]
  317. : '';
  318. };
  319. const renderAILink = () => {
  320. const aiMod = getConfig().modules.aiMod === 'on';
  321. return aiMod
  322. ? [
  323. navLink({ href: "/ai", emoji: "ꘜ", text: i18n.ai, class: "ai-link enabled" }),
  324. ]
  325. : '';
  326. };
  327. const renderEventsLink = () => {
  328. const eventsMod = getConfig().modules.eventsMod === 'on';
  329. return eventsMod
  330. ? [
  331. navLink({ href: "/events", emoji: "ꕆ", text: i18n.eventsLabel, class: "events-link enabled" }),
  332. ]
  333. : '';
  334. };
  335. const renderTasksLink = () => {
  336. const tasksMod = getConfig().modules.tasksMod === 'on';
  337. return tasksMod
  338. ? [
  339. navLink({ href: "/tasks", emoji: "ꖧ", text: i18n.tasksTitle, class: "tasks-link enabled" }),
  340. ]
  341. : '';
  342. };
  343. const template = (titlePrefix, ...elements) => {
  344. const currentConfig = getConfig();
  345. const theme = currentConfig.themes.current || "Dark-SNH";
  346. const themeLink = link({
  347. rel: "stylesheet",
  348. href: `/assets/themes/${theme}.css`
  349. });
  350. const nodes = html(
  351. { lang: "en" },
  352. head(
  353. title(titlePrefix, " | Oasis"),
  354. link({ rel: "stylesheet", href: "/assets/styles/style.css" }),
  355. themeLink,
  356. link({ rel: "icon", href: "/assets/images/favicon.svg" }),
  357. meta({ charset: "utf-8" }),
  358. meta({ name: "description", content: i18n.oasisDescription }),
  359. meta({ name: "viewport", content: toAttributes({ width: "device-width", "initial-scale": 1 }) })
  360. ),
  361. body(
  362. div(
  363. { class: "header" },
  364. div(
  365. { class: "top-bar-left" },
  366. a({ class: "logo-icon", href: "/" },
  367. img({ class: "logo-icon", src: "/assets/images/snh-oasis.jpg", alt: "Oasis Logo" })
  368. ),
  369. nav(
  370. ul(
  371. navLink({ href: "/profile", emoji: "⚉", text: i18n.profile }),
  372. navLink({ href: "/cv", emoji: "ꕛ", text: i18n.cvTitle }),
  373. renderLegacyLink(),
  374. renderWalletLink(),
  375. navLink({ href: "/peers", emoji: "⧖", text: i18n.peers }),
  376. renderInvitesLink(),
  377. navLink({ href: "/modules", emoji: "ꗣ", text: i18n.modules }),
  378. navLink({ href: "/settings", emoji: "⚙", text: i18n.settings })
  379. )
  380. )
  381. ),
  382. div(
  383. { class: "top-bar-right" },
  384. nav(
  385. ul(
  386. renderCipherLink(),
  387. navLink({ href: "/pm", emoji: "ꕕ", text: i18n.privateMessage }),
  388. navLink({ href: "/publish", emoji: "❂", text: i18n.publish }),
  389. renderAILink(),
  390. renderTagsLink(),
  391. navLink({ href: "/search", emoji: "ꔅ", text: i18n.search })
  392. )
  393. ),
  394. )
  395. ),
  396. div(
  397. { class: "main-content" },
  398. div(
  399. { class: "sidebar-left" },
  400. nav(
  401. ul(
  402. navLink({ href: "/mentions", emoji: "✺", text: i18n.mentions }),
  403. navLink({ href: "/inbox", emoji: "☂", text: i18n.inbox }),
  404. renderAgendaLink(),
  405. navLink({ href: "/stats", emoji: "ꕷ", text: i18n.statistics }),
  406. navLink({ href: "/blockexplorer", emoji: "ꖸ", text: i18n.blockchain }),
  407. hr,
  408. renderLatestLink(),
  409. renderThreadsLink(),
  410. renderTopicsLink(),
  411. renderSummariesLink(),
  412. renderPopularLink(),
  413. navLink({ href: "/inhabitants", emoji: "ꖘ", text: i18n.inhabitantsLabel }),
  414. renderTribesLink(),
  415. renderGovernanceLink(),
  416. renderEventsLink(),
  417. renderTasksLink(),
  418. renderReportsLink(),
  419. renderMultiverseLink()
  420. )
  421. )
  422. ),
  423. main({ id: "content", class: "main-column" }, elements),
  424. div(
  425. { class: "sidebar-right" },
  426. nav(
  427. ul(
  428. navLink({ href: "/activity", emoji: "ꔙ", text: i18n.activityTitle }),
  429. renderTrendingLink(),
  430. renderOpinionsLink(),
  431. renderForumLink(),
  432. renderFeedLink(),
  433. renderPixeliaLink(),
  434. renderBankingLink(),
  435. renderMarketLink(),
  436. renderProjectsLink(),
  437. renderJobsLink(),
  438. renderTransfersLink(),
  439. renderBookmarksLink(),
  440. renderImagesLink(),
  441. renderVideosLink(),
  442. renderAudiosLink(),
  443. renderDocsLink(),
  444. )
  445. )
  446. ),
  447. )
  448. )
  449. );
  450. return doctypeString + nodes.outerHTML;
  451. };
  452. // menu END
  453. exports.template = template;
  454. const thread = (messages) => {
  455. let lookingForTarget = true;
  456. let shallowest = Infinity;
  457. for (let i = messages.length - 1; i >= 0; i--) {
  458. const msg = messages[i];
  459. const depth = lodash.get(msg, "value.meta.thread.depth", 0);
  460. if (lookingForTarget) {
  461. const isThreadTarget = Boolean(
  462. lodash.get(msg, "value.meta.thread.target", false)
  463. );
  464. if (isThreadTarget) {
  465. lookingForTarget = false;
  466. }
  467. } else {
  468. if (depth < shallowest) {
  469. lodash.set(msg, "value.meta.thread.ancestorOfTarget", true);
  470. shallowest = depth;
  471. }
  472. }
  473. }
  474. const msgList = [];
  475. for (let i = 0; i < messages.length; i++) {
  476. const j = i + 1;
  477. const currentMsg = messages[i];
  478. const nextMsg = messages[j];
  479. const depth = (msg) => {
  480. if (msg === undefined) return 0;
  481. return lodash.get(msg, "value.meta.thread.depth", 0);
  482. };
  483. msgList.push(post({ msg: currentMsg }));
  484. if (depth(currentMsg) < depth(nextMsg)) {
  485. const isAncestor = Boolean(
  486. lodash.get(currentMsg, "value.meta.thread.ancestorOfTarget", false)
  487. );
  488. const isBlocked = Boolean(nextMsg.value.meta.blocking);
  489. const nextAuthor = lodash.get(nextMsg, "value.meta.author.name");
  490. const nextSnippet = postSnippet(
  491. lodash.has(nextMsg, "value.content.contentWarning")
  492. ? lodash.get(nextMsg, "value.content.contentWarning")
  493. : lodash.get(nextMsg, "value.content.text")
  494. );
  495. msgList.push(
  496. details(
  497. isAncestor ? { open: true } : {},
  498. summary(
  499. isBlocked
  500. ? i18n.relationshipBlockingPost
  501. : `${nextAuthor}: ${nextSnippet}`
  502. )
  503. )
  504. );
  505. } else if (depth(currentMsg) > depth(nextMsg)) {
  506. const diffDepth = depth(currentMsg) - depth(nextMsg);
  507. }
  508. }
  509. return div({ class: "thread-container" }, ...msgList);
  510. };
  511. const postSnippet = (text) => {
  512. const max = 40;
  513. text = text.trim().split("\n", 3).join("\n");
  514. text = text.replace(/_|`|\*|#|^\[@.*?]|\[|]|\(\S*?\)/g, "").trim();
  515. text = text.replace(/:$/, "");
  516. text = text.trim().split("\n", 1)[0].trim();
  517. if (text.length > max) {
  518. text = text.substring(0, max - 1) + "…";
  519. }
  520. return text;
  521. };
  522. const continueThreadComponent = (thread, isComment) => {
  523. const encoded = {
  524. next: encodeURIComponent(thread[THREAD_PREVIEW_LENGTH + 1].key),
  525. parent: encodeURIComponent(thread[0].key),
  526. };
  527. const left = thread.length - (THREAD_PREVIEW_LENGTH + 1);
  528. let continueLink;
  529. if (isComment == false) {
  530. continueLink = `/thread/${encoded.parent}#${encoded.next}`;
  531. return a(
  532. { href: continueLink },
  533. i18n.continueReading, ` ${left} `, i18n.moreComments+`${left === 1 ? "" : "s"}`
  534. );
  535. } else {
  536. continueLink = `/thread/${encoded.parent}`;
  537. return a({ href: continueLink }, i18n.readThread);
  538. }
  539. };
  540. const postAside = ({ key, value }) => {
  541. const thread = value.meta.thread;
  542. if (thread == null) return null;
  543. const isComment = value.meta.postType === "comment";
  544. let postsToShow;
  545. if (isComment) {
  546. const commentPosition = thread.findIndex((msg) => msg.key === key);
  547. postsToShow = thread.slice(
  548. commentPosition + 1,
  549. Math.min(commentPosition + (THREAD_PREVIEW_LENGTH + 1), thread.length)
  550. );
  551. } else {
  552. postsToShow = thread.slice(
  553. 1,
  554. Math.min(thread.length, THREAD_PREVIEW_LENGTH + 1)
  555. );
  556. }
  557. const fragments = postsToShow.map((p) => post({ msg: p }));
  558. if (thread.length > THREAD_PREVIEW_LENGTH + 1) {
  559. fragments.push(section(continueThreadComponent(thread, isComment)));
  560. }
  561. return fragments;
  562. };
  563. const post = ({ msg, aside = false, preview = false }) => {
  564. const encoded = {
  565. key: encodeURIComponent(msg.key),
  566. author: encodeURIComponent(msg.value?.author),
  567. parent: encodeURIComponent(msg.value?.content?.root),
  568. };
  569. const url = {
  570. author: `/author/${encoded.author}`,
  571. likeForm: `/like/${encoded.key}`,
  572. link: `/thread/${encoded.key}#${encoded.key}`,
  573. parent: `/thread/${encoded.parent}#${encoded.parent}`,
  574. avatar: msg.value?.meta?.author?.avatar?.url || '/assets/images/default-avatar.png',
  575. json: `/json/${encoded.key}`,
  576. subtopic: `/subtopic/${encoded.key}`,
  577. comment: `/comment/${encoded.key}`,
  578. };
  579. const isPrivate = Boolean(msg.value?.meta?.private);
  580. const isBlocked = Boolean(msg.value?.meta?.blocking);
  581. const isRoot = msg.value?.content?.root == null;
  582. const isFork = msg.value?.meta?.postType === "subtopic";
  583. const hasContentWarning = typeof msg.value?.content?.contentWarning === "string";
  584. const isThreadTarget = Boolean(lodash.get(msg, "value.meta.thread.target", false));
  585. const { name } = msg.value?.meta?.author || { name: "Anonymous" };
  586. const markdownContent = msg.value?.content?.text;
  587. const emptyContent = "<p>undefined</p>\n";
  588. const articleElement =
  589. markdownContent === emptyContent
  590. ? article(
  591. { class: "content" },
  592. pre({
  593. innerHTML: highlightJs.highlight(
  594. JSON.stringify(msg, null, 2),
  595. { language: "json", ignoreIllegals: true }
  596. ).value,
  597. })
  598. )
  599. : article({ class: "content", innerHTML: markdownContent });
  600. if (preview) {
  601. return section(
  602. { id: msg.key, class: "post-preview" },
  603. hasContentWarning
  604. ? details(summary(msg.value?.content?.contentWarning), articleElement)
  605. : articleElement
  606. );
  607. }
  608. const ts_received = msg.value?.meta?.timestamp?.received;
  609. if (!ts_received || !ts_received.iso8601 || !moment(ts_received.iso8601, moment.ISO_8601, true).isValid()) {
  610. return null;
  611. }
  612. const validTimestamp = moment(ts_received.iso8601, moment.ISO_8601);
  613. const timeAgo = validTimestamp.fromNow();
  614. const timeAbsolute = validTimestamp.toISOString().split(".")[0].replace("T", " ");
  615. const likeButton = msg.value?.meta?.voted
  616. ? { value: 0, class: "liked" }
  617. : { value: 1, class: null };
  618. const likeCount = msg.value?.meta?.votes?.length || 0;
  619. const maxLikedNameLength = 16;
  620. const maxLikedNames = 16;
  621. const likedByNames = msg.value?.meta?.votes
  622. .slice(0, maxLikedNames)
  623. .map((person) => person.name)
  624. .map((name) => name.slice(0, maxLikedNameLength))
  625. .join(", ");
  626. const additionalLikesMessage =
  627. likeCount > maxLikedNames ? `+${likeCount - maxLikedNames} more` : ``;
  628. const likedByMessage =
  629. likeCount > 0 ? `${likedByNames} ${additionalLikesMessage}` : null;
  630. const messageClasses = ["post"];
  631. const recps = [];
  632. const addRecps = (recpsInfo) => {
  633. recpsInfo.forEach((recp) => {
  634. recps.push(
  635. a(
  636. { href: `/author/${encodeURIComponent(recp.feedId)}` },
  637. img({ class: "avatar", src: recp.avatarUrl, alt: "" })
  638. )
  639. );
  640. });
  641. };
  642. if (isPrivate) {
  643. messageClasses.push("private");
  644. addRecps(msg.value?.meta?.recpsInfo || []);
  645. }
  646. if (isThreadTarget) {
  647. messageClasses.push("thread-target");
  648. }
  649. if (isBlocked) {
  650. messageClasses.push("blocked");
  651. return section(
  652. {
  653. id: msg.key,
  654. class: messageClasses.join(" "),
  655. },
  656. i18n.relationshipBlockingPost
  657. );
  658. }
  659. const postOptions = {
  660. post: null,
  661. comment: i18n.commentDescription({ parentUrl: url.parent }),
  662. subtopic: i18n.subtopicDescription({ parentUrl: url.parent }),
  663. mystery: i18n.mysteryDescription,
  664. };
  665. const articleContent = article(
  666. { class: "content" },
  667. hasContentWarning ? div({ class: "post-subject" }, msg.value?.content?.contentWarning) : null,
  668. articleElement
  669. );
  670. const fragment = section(
  671. {
  672. id: msg.key,
  673. class: messageClasses.join(" "),
  674. },
  675. header(
  676. div(
  677. { class: "header-content" },
  678. a(
  679. { href: url.author },
  680. img({ class: "avatar-profile", src: url.avatar, alt: "" })
  681. ),
  682. span({ class: "created-at" }, `${i18n.createdBy} `, a({ href: url.author }, "@", name), ` | ${timeAbsolute} | ${i18n.sendTime} `, a({ href: url.link }, timeAgo), ` ${i18n.timeAgo}`),
  683. isPrivate ? "🔒" : null,
  684. isPrivate ? recps : null
  685. )
  686. ),
  687. articleContent,
  688. footer(
  689. div(
  690. form(
  691. { action: url.likeForm, method: "post" },
  692. button(
  693. {
  694. name: "voteValue",
  695. type: "submit",
  696. value: likeButton.value,
  697. class: likeButton.class,
  698. title: likedByMessage,
  699. },
  700. `☉ ${likeCount}`
  701. )
  702. ),
  703. a({ href: url.comment }, i18n.comment),
  704. isPrivate || isRoot || isFork
  705. ? null
  706. : a({ href: url.subtopic }, nbsp, i18n.subtopic)
  707. ),
  708. br()
  709. )
  710. );
  711. const threadSeparator = [br()];
  712. if (aside) {
  713. return [fragment, postAside(msg), isRoot ? threadSeparator : null];
  714. } else {
  715. return fragment;
  716. }
  717. };
  718. exports.editProfileView = ({ name, description }) =>
  719. template(
  720. i18n.editProfile,
  721. section(
  722. h1(i18n.editProfile),
  723. p(i18n.editProfileDescription),
  724. form(
  725. {
  726. action: "/profile/edit",
  727. method: "POST",
  728. enctype: "multipart/form-data",
  729. },
  730. label(
  731. i18n.profileImage,
  732. br,
  733. input({ type: "file", name: "image", accept: "image/*" })
  734. ),
  735. br,br,
  736. label(i18n.profileName,
  737. br,
  738. input({ name: "name", value: name })),
  739. br,br,
  740. label(
  741. i18n.profileDescription,
  742. br,
  743. textarea(
  744. {
  745. autofocus: true,
  746. name: "description",
  747. rows: "6",
  748. },
  749. description
  750. )
  751. ),
  752. br,
  753. button(
  754. {
  755. type: "submit",
  756. },
  757. i18n.submit
  758. )
  759. )
  760. )
  761. );
  762. exports.authorView = ({
  763. avatarUrl,
  764. description,
  765. feedId,
  766. messages,
  767. firstPost,
  768. lastPost,
  769. name,
  770. relationship,
  771. ecoAddress
  772. }) => {
  773. const mention = `[@${name}](${feedId})`;
  774. const markdownMention = highlightJs.highlight(mention, { language: "markdown", ignoreIllegals: true }).value;
  775. const contactForms = [];
  776. const addForm = ({ action }) =>
  777. contactForms.push(
  778. form(
  779. {
  780. action: `/${action}/${encodeURIComponent(feedId)}`,
  781. method: "post",
  782. },
  783. button(
  784. {
  785. type: "submit",
  786. },
  787. i18n[action]
  788. )
  789. )
  790. );
  791. if (relationship.me === false) {
  792. if (relationship.following) {
  793. addForm({ action: "unfollow" });
  794. } else if (relationship.blocking) {
  795. addForm({ action: "unblock" });
  796. } else {
  797. addForm({ action: "follow" });
  798. addForm({ action: "block" });
  799. }
  800. }
  801. const relationshipMessage = (() => {
  802. if (relationship.me) return i18n.relationshipYou;
  803. const following = relationship.following === true;
  804. const followsMe = relationship.followsMe === true;
  805. if (following && followsMe) {
  806. return i18n.relationshipMutuals;
  807. }
  808. const messages = [];
  809. messages.push(
  810. following
  811. ? i18n.relationshipFollowing
  812. : i18n.relationshipNone
  813. );
  814. messages.push(
  815. followsMe
  816. ? i18n.relationshipTheyFollow
  817. : i18n.relationshipNotFollowing
  818. );
  819. return messages.join(". ") + ".";
  820. })();
  821. const prefix = section(
  822. { class: "message" },
  823. div(
  824. { class: "profile" },
  825. div({ class: "avatar-container" },
  826. img({ class: "avatar", src: avatarUrl }),
  827. h1({ class: "name" }, name),
  828. ),
  829. pre({
  830. class: "md-mention",
  831. innerHTML: markdownMention,
  832. })
  833. ),
  834. description !== "" ? article({ innerHTML: markdown(description) }) : null,
  835. footer(
  836. div(
  837. { class: "profile" },
  838. ...contactForms.map(form => span({ style: "font-weight: bold;" }, form)),
  839. relationship.me ? (
  840. span({ class: "status you" }, i18n.relationshipYou)
  841. ) : (
  842. div({ class: "relationship-status" },
  843. relationship.blocking && relationship.blockedBy
  844. ? span({ class: "status blocked" }, i18n.relationshipMutualBlock)
  845. : [
  846. relationship.blocking
  847. ? span({ class: "status blocked" }, i18n.relationshipBlocking)
  848. : null,
  849. relationship.blockedBy
  850. ? span({ class: "status blocked-by" }, i18n.relationshipBlockedBy)
  851. : null,
  852. relationship.following && relationship.followsMe
  853. ? span({ class: "status mutual" }, i18n.relationshipMutuals)
  854. : [
  855. span(
  856. { class: "status supporting" },
  857. relationship.following
  858. ? i18n.relationshipFollowing
  859. : i18n.relationshipNone
  860. ),
  861. span(
  862. { class: "status supported-by" },
  863. relationship.followsMe
  864. ? i18n.relationshipTheyFollow
  865. : i18n.relationshipNotFollowing
  866. )
  867. ]
  868. ]
  869. )
  870. ),
  871. ecoAddress
  872. ? div({ class: "eco-wallet" },
  873. p(`${i18n.bankWalletConnected}: `, strong(ecoAddress))
  874. )
  875. : div({ class: "eco-wallet" },
  876. p(i18n.ecoWalletNotConfigured || "ECOin Wallet not configured")
  877. ),
  878. relationship.me
  879. ? a({ href: `/profile/edit`, class: "btn" }, nbsp, i18n.editProfile)
  880. : null,
  881. a({ href: `/likes/${encodeURIComponent(feedId)}`, class: "btn" }, i18n.viewLikes),
  882. !relationship.me
  883. ? a(
  884. {
  885. href: `/pm?recipients=${encodeURIComponent(feedId)}`,
  886. class: "btn"
  887. },
  888. i18n.pmCreateButton
  889. )
  890. : null
  891. )
  892. )
  893. );
  894. const linkUrl = relationship.me
  895. ? "/profile/"
  896. : `/author/${encodeURIComponent(feedId)}/`;
  897. let items = messages.map((msg) => post({ msg }));
  898. if (items.length === 0) {
  899. if (lastPost === undefined) {
  900. items.push(section(div(span(i18n.feedEmpty))));
  901. } else {
  902. items.push(
  903. section(
  904. div(
  905. span(i18n.feedRangeEmpty),
  906. a({ href: `${linkUrl}` }, i18n.seeFullFeed)
  907. )
  908. )
  909. );
  910. }
  911. } else {
  912. const highestSeqNum = messages[0].value.sequence;
  913. const lowestSeqNum = messages[messages.length - 1].value.sequence;
  914. const newerPostsLink = a(
  915. {
  916. href:
  917. lastPost !== undefined && highestSeqNum < lastPost.value.sequence
  918. ? `${linkUrl}?gt=${highestSeqNum}`
  919. : "#",
  920. class:
  921. lastPost !== undefined && highestSeqNum < lastPost.value.sequence
  922. ? "btn"
  923. : "btn disabled",
  924. "aria-disabled":
  925. lastPost === undefined || highestSeqNum >= lastPost.value.sequence
  926. },
  927. i18n.newerPosts
  928. );
  929. const olderPostsLink = a(
  930. {
  931. href:
  932. lowestSeqNum > firstPost.value.sequence
  933. ? `${linkUrl}?lt=${lowestSeqNum}`
  934. : "#",
  935. class:
  936. lowestSeqNum > firstPost.value.sequence
  937. ? "btn"
  938. : "btn disabled",
  939. "aria-disabled": !(lowestSeqNum > firstPost.value.sequence)
  940. },
  941. i18n.olderPosts
  942. );
  943. const pagination = section(
  944. { class: "message" },
  945. footer(div(newerPostsLink, olderPostsLink), br())
  946. );
  947. items.unshift(pagination);
  948. items.push(pagination);
  949. }
  950. return template(i18n.profile, prefix, items);
  951. };
  952. exports.previewCommentView = async ({
  953. previewData,
  954. messages,
  955. myFeedId,
  956. parentMessage,
  957. contentWarning,
  958. }) => {
  959. if (!parentMessage || !parentMessage.value) {
  960. throw new Error("Missing parentMessage or value");
  961. }
  962. const publishAction = `/comment/${encodeURIComponent(messages[0].key)}`;
  963. const preview = generatePreview({
  964. previewData,
  965. contentWarning,
  966. action: publishAction,
  967. });
  968. return exports.commentView(
  969. { messages, myFeedId, parentMessage },
  970. preview,
  971. previewData.text,
  972. contentWarning
  973. );
  974. };
  975. exports.commentView = async (
  976. { messages, myFeedId, parentMessage },
  977. preview,
  978. text,
  979. contentWarning
  980. ) => {
  981. let markdownMention;
  982. const authorName = parentMessage?.value?.meta?.author?.name || "Anonymous";
  983. const messageElements = await Promise.all(
  984. messages.reverse().map(async (message) => {
  985. const isRootMessage = message.key === parentMessage.key;
  986. const messageAuthorName = message.value?.meta?.author?.name || "Anonymous";
  987. const authorFeedId = myFeedId;
  988. if (authorFeedId !== myFeedId) {
  989. if (message.key === parentMessage.key) {
  990. const x = `[@${messageAuthorName}](${authorFeedId})\n\n`;
  991. markdownMention = x;
  992. }
  993. }
  994. const timestamp = message?.value?.meta?.timestamp?.received;
  995. const validTimestamp = moment(timestamp, moment.ISO_8601, true);
  996. const timeAgo = validTimestamp.isValid()
  997. ? validTimestamp.fromNow()
  998. : "Invalid time";
  999. const messageId = message.key.endsWith('.sha256') ? message.key.slice(0, -7) : message.key;
  1000. const result = await post({ msg: { ...message, key: messageId } });
  1001. return result;
  1002. })
  1003. );
  1004. const action = `/comment/preview/${encodeURIComponent(messages[0].key)}`;
  1005. const method = "post";
  1006. const isPrivate = parentMessage?.value?.meta?.private;
  1007. const publicOrPrivate = isPrivate ? i18n.commentPrivate : i18n.commentPublic;
  1008. const maybeSubtopicText = isPrivate ? [null] : i18n.commentWarning;
  1009. return template(
  1010. i18n.commentTitle({ authorName }),
  1011. div({ class: "thread-container" }, messageElements),
  1012. form(
  1013. { action, method, enctype: "multipart/form-data" },
  1014. i18n.blogSubject,
  1015. br,
  1016. label(
  1017. i18n.contentWarningLabel,
  1018. input({
  1019. name: "contentWarning",
  1020. type: "text",
  1021. class: "contentWarning",
  1022. value: contentWarning ? contentWarning : "",
  1023. placeholder: i18n.contentWarningPlaceholder,
  1024. })
  1025. ),
  1026. br,
  1027. label({ for: "text" }, i18n.blogMessage),
  1028. br,
  1029. textarea(
  1030. {
  1031. autofocus: true,
  1032. required: true,
  1033. name: "text",
  1034. rows: "6",
  1035. cols: "50",
  1036. placeholder: i18n.publishWarningPlaceholder,
  1037. },
  1038. text ? text : isPrivate ? null : markdownMention
  1039. ),
  1040. br,
  1041. label(
  1042. { for: "blob" },
  1043. i18n.blogImage || "Upload Image (jpeg, jpg, png, gif) (max-size: 500px x 400px)"
  1044. ),
  1045. input({ type: "file", id: "blob", name: "blob" }),
  1046. br,
  1047. br,
  1048. button({ type: "submit" }, i18n.blogPublish)
  1049. ),
  1050. preview ? div({ class: "comment-preview" }, preview) : ""
  1051. );
  1052. };
  1053. const renderMessage = (msg) => {
  1054. const content = lodash.get(msg, "value.content", {});
  1055. const author = msg.value.author || "Anonymous";
  1056. const createdAt = new Date(msg.value.timestamp).toLocaleString();
  1057. const mentionsText = content.text || '';
  1058. return div({ class: "mention-item" }, [
  1059. div({ class: "mention-content", innerHTML: mentionsText || '[No content]' }),
  1060. p(a({ class: 'user-link', href: `/author/${encodeURIComponent(author)}` }, author)),
  1061. p(`${i18n.createdAtLabel || i18n.mentionsCreatedAt}: ${createdAt}`)
  1062. ]);
  1063. };
  1064. exports.mentionsView = ({ messages, myFeedId }) => {
  1065. const title = i18n.mentions;
  1066. const description = i18n.mentionsDescription;
  1067. if (!Array.isArray(messages) || messages.length === 0) {
  1068. return template(
  1069. title,
  1070. section(
  1071. div({ class: "tags-header" },
  1072. h2(title),
  1073. p(description)
  1074. )
  1075. ),
  1076. section(
  1077. div({ class: "mentions-list" },
  1078. p({ class: "empty" }, i18n.noMentions)
  1079. )
  1080. )
  1081. );
  1082. }
  1083. const filteredMessages = messages.filter(msg => {
  1084. const mentions = lodash.get(msg, "value.content.mentions", {});
  1085. return Object.keys(mentions).length > 0;
  1086. });
  1087. if (filteredMessages.length === 0) {
  1088. return template(
  1089. title,
  1090. section(
  1091. div({ class: "tags-header" },
  1092. h2(title),
  1093. p(description)
  1094. )
  1095. ),
  1096. section(
  1097. div({ class: "mentions-list" },
  1098. p({ class: "empty" }, i18n.noMentions)
  1099. )
  1100. )
  1101. );
  1102. }
  1103. return template(
  1104. title,
  1105. section(
  1106. div({ class: "tags-header" },
  1107. h2(title),
  1108. p(description)
  1109. )
  1110. ),
  1111. section(
  1112. div({ class: "mentions-list" },
  1113. filteredMessages.map(renderMessage)
  1114. )
  1115. )
  1116. );
  1117. };
  1118. exports.privateView = async (messagesInput, filter) => {
  1119. const messages = Array.isArray(messagesInput) ? messagesInput : messagesInput.messages;
  1120. const userId = await getUserId();
  1121. const filtered =
  1122. filter === 'sent' ? messages.filter(m => m.value.content.from === userId) :
  1123. filter === 'inbox' ? messages.filter(m => m.value.content.to?.includes(userId)) :
  1124. messages;
  1125. const linkAuthor = (id) =>
  1126. a({ class: 'user-link', href: `/author/${encodeURIComponent(id)}` }, id);
  1127. const hrefFor = {
  1128. job: (id) => `/jobs/${encodeURIComponent(id)}`,
  1129. project: (id) => `/projects/${encodeURIComponent(id)}`,
  1130. market: (id) => `/market/${encodeURIComponent(id)}`
  1131. };
  1132. const clickableCardProps = (href, extraClass = '') => {
  1133. const props = { class: `pm-card ${extraClass}` };
  1134. if (href) {
  1135. props.onclick = `window.location='${href}'`;
  1136. props.tabindex = 0;
  1137. props.onkeypress = `if(event.key==='Enter') window.location='${href}'`;
  1138. }
  1139. return props;
  1140. };
  1141. const chip = (txt) => span({ class: 'chip' }, txt);
  1142. function header({ sentAt, from, toLinks, botIcon = '', botLabel = '' }) {
  1143. return div({ class: 'pm-header' },
  1144. span({ class: 'date-link' }, `${moment(sentAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed}`),
  1145. botIcon || botLabel
  1146. ? span({ class: 'pm-from' }, `${botIcon} ${botLabel}`)
  1147. : [
  1148. span({ class: 'pm-from' }, i18n.pmFromLabel + ' ', linkAuthor(from)),
  1149. span({ class: 'pm-to' }, i18n.pmToLabel + ' ', toLinks)
  1150. ]
  1151. );
  1152. }
  1153. function actions({ key, replyId }) {
  1154. const stop = { onclick: 'event.stopPropagation()' };
  1155. return div({ class: 'pm-actions' },
  1156. form({ method: 'POST', action: `/inbox/delete/${encodeURIComponent(key)}`, class: 'delete-message-form', style: 'display:inline-block;margin-right:8px;', ...stop },
  1157. button({ type: 'submit', class: 'delete-btn' }, i18n.privateDelete)
  1158. ),
  1159. form({ method: 'GET', action: '/pm', style: 'display:inline-block;', ...stop },
  1160. input({ type: 'hidden', name: 'recipients', value: replyId }),
  1161. button({ type: 'submit', class: 'reply-btn' }, i18n.pmCreateButton)
  1162. )
  1163. );
  1164. }
  1165. function quoted(str) {
  1166. const m = str.match(/"([^"]+)"/);
  1167. return m ? m[1] : '';
  1168. }
  1169. function pickLink(str, kind) {
  1170. if (kind === 'job') {
  1171. const m = str.match(/\/jobs\/([%A-Za-z0-9/+._=-]+\.sha256)/);
  1172. return m ? m[1] : '';
  1173. }
  1174. if (kind === 'project') {
  1175. const m = str.match(/\/projects\/([%A-Za-z0-9/+._=-]+\.sha256)/);
  1176. return m ? m[1] : '';
  1177. }
  1178. if (kind === 'market') {
  1179. const m = str.match(/\/market\/([%A-Za-z0-9/+._=-]+\.sha256)/);
  1180. return m ? m[1] : '';
  1181. }
  1182. return '';
  1183. }
  1184. function JobCard({ type, sentAt, from, toLinks, text, key }) {
  1185. const isSub = type === 'JOB_SUBSCRIBED';
  1186. const icon = isSub ? '🟡' : '🟠';
  1187. const titleH = isSub ? (i18n.inboxJobSubscribedTitle || 'New subscription to your job offer') : (i18n.inboxJobUnsubscribedTitle || 'Unsubscription from your job offer');
  1188. const jobTitle = quoted(text) || 'job';
  1189. const jobId = pickLink(text, 'job');
  1190. const href = jobId ? hrefFor.job(jobId) : null;
  1191. return div(
  1192. clickableCardProps(href, `job-notification ${isSub ? 'job-sub' : 'job-unsub'}`),
  1193. header({ sentAt, from, toLinks, botIcon: icon, botLabel: i18n.pmBotJobs }),
  1194. h2({ class: 'pm-title' }, titleH),
  1195. p(
  1196. i18n.pmInhabitantWithId, ' ',
  1197. linkAuthor(from), ' ',
  1198. isSub ? i18n.pmHasSubscribedToYourJobOffer : (i18n.pmHasUnsubscribedFromYourJobOffer || 'has unsubscribed from your job offer'),
  1199. ' ',
  1200. href ? a({ class: 'job-link', href }, `"${jobTitle}"`) : `"${jobTitle}"`
  1201. ),
  1202. actions({ key, replyId: from })
  1203. );
  1204. }
  1205. function ProjectFollowCard({ type, sentAt, from, toLinks, text, key }) {
  1206. const isFollow = type === 'PROJECT_FOLLOWED';
  1207. const icon = isFollow ? '🔔' : '🔕';
  1208. const titleH = isFollow
  1209. ? (i18n.inboxProjectFollowedTitle || 'New follower of your project')
  1210. : (i18n.inboxProjectUnfollowedTitle || 'Unfollowed your project');
  1211. const projectTitle = quoted(text) || 'project';
  1212. const projectId = pickLink(text, 'project');
  1213. const href = projectId ? hrefFor.project(projectId) : null;
  1214. return div(
  1215. clickableCardProps(href, `project-${isFollow ? 'follow' : 'unfollow'}-notification`),
  1216. header({ sentAt, from, toLinks, botIcon: icon, botLabel: i18n.pmBotProjects }),
  1217. h2({ class: 'pm-title' }, titleH),
  1218. p(
  1219. i18n.pmInhabitantWithId, ' ',
  1220. a({ class: 'user-link', href: `/author/${encodeURIComponent(from)}` }, from),
  1221. ' ',
  1222. isFollow ? (i18n.pmHasFollowedYourProject || 'has followed your project') : (i18n.pmHasUnfollowedYourProject || 'has unfollowed your project'),
  1223. ' ',
  1224. href ? a({ class: 'project-link', href }, `"${projectTitle}"`) : `"${projectTitle}"`
  1225. ),
  1226. actions({ key, replyId: from })
  1227. );
  1228. }
  1229. function MarketSoldCard({ sentAt, from, toLinks, subject, text, key }) {
  1230. const itemTitle = quoted(subject) || quoted(text) || 'item';
  1231. const buyerId = (text.match(/OASIS ID:\s*([\w=/+.-]+)/) || [])[1] || from;
  1232. const price = (text.match(/for:\s*\$([\d.]+)/) || [])[1] || '';
  1233. const marketId = pickLink(text, 'market');
  1234. const href = marketId ? hrefFor.market(marketId) : null;
  1235. return div(
  1236. clickableCardProps(href, 'market-sold-notification'),
  1237. header({ sentAt, from, toLinks, botIcon: '💰', botLabel: i18n.pmBotMarket }),
  1238. h2({ class: 'pm-title' }, i18n.inboxMarketItemSoldTitle),
  1239. p(
  1240. i18n.pmYourItem, ' ',
  1241. href ? a({ class: 'market-link', href }, `"${itemTitle}"`) : `"${itemTitle}"`,
  1242. ' ',
  1243. i18n.pmHasBeenSoldTo, ' ',
  1244. linkAuthor(buyerId),
  1245. price ? ` ${i18n.pmFor} $${price}.` : '.'
  1246. ),
  1247. actions({ key, replyId: buyerId })
  1248. );
  1249. }
  1250. function ProjectPledgeCard({ sentAt, from, toLinks, content, text, key }) {
  1251. const amount = content.meta?.amount ?? (text.match(/pledged\s+([\d.]+)/)?.[1] || '0');
  1252. const projectTitle = content.meta?.projectTitle ?? (text.match(/project\s+"([^"]+)"/)?.[1] || 'project');
  1253. const projectId = content.meta?.projectId ?? pickLink(text, 'project');
  1254. const href = projectId ? hrefFor.project(projectId) : null;
  1255. return div(
  1256. clickableCardProps(href, 'project-pledge-notification'),
  1257. header({ sentAt, from, toLinks, botIcon: '💚', botLabel: i18n.pmBotProjects }),
  1258. h2({ class: 'pm-title' }, i18n.inboxProjectPledgedTitle),
  1259. p(
  1260. i18n.pmInhabitantWithId, ' ',
  1261. linkAuthor(from), ' ',
  1262. i18n.pmHasPledged, ' ',
  1263. chip(`${amount} ECO`), ' ',
  1264. i18n.pmToYourProject, ' ',
  1265. href ? a({ class: 'project-link', href }, `"${projectTitle}"`) : `"${projectTitle}"`
  1266. ),
  1267. actions({ key, replyId: from })
  1268. );
  1269. }
  1270. function clickableLinks(str) {
  1271. return str
  1272. .replace(/(@[a-zA-Z0-9/+._=-]+\.ed25519)/g,
  1273. (match, id) => `<a class="user-link" href="/author/${encodeURIComponent(id)}">${match}</a>`
  1274. )
  1275. .replace(/\/jobs\/([%A-Za-z0-9/+._=-]+\.sha256)/g,
  1276. (match, id) => `<a class="job-link" href="${hrefFor.job(id)}">${match}</a>`
  1277. )
  1278. .replace(/\/projects\/([%A-Za-z0-9/+._=-]+\.sha256)/g,
  1279. (match, id) => `<a class="project-link" href="${hrefFor.project(id)}">${match}</a>`
  1280. )
  1281. .replace(/\/market\/([%A-Za-z0-9/+._=-]+\.sha256)/g,
  1282. (match, id) => `<a class="market-link" href="${hrefFor.market(id)}">${match}</a>`
  1283. );
  1284. }
  1285. return template(
  1286. i18n.private,
  1287. section(
  1288. div({ class: 'tags-header' },
  1289. h2(i18n.private),
  1290. p(i18n.privateDescription)
  1291. ),
  1292. div({ class: 'filters' },
  1293. form({ method: 'GET', action: '/inbox' }, [
  1294. button({
  1295. type: 'submit',
  1296. name: 'filter',
  1297. value: 'inbox',
  1298. class: filter === 'inbox' ? 'filter-btn active' : 'filter-btn'
  1299. }, i18n.privateInbox),
  1300. button({
  1301. type: 'submit',
  1302. name: 'filter',
  1303. value: 'sent',
  1304. class: filter === 'sent' ? 'filter-btn active' : 'filter-btn'
  1305. }, i18n.privateSent),
  1306. button({
  1307. type: 'submit',
  1308. name: 'filter',
  1309. value: 'create',
  1310. class: 'create-button',
  1311. formaction: '/pm',
  1312. formmethod: 'GET'
  1313. }, i18n.pmCreateButton)
  1314. ])
  1315. ),
  1316. div({ class: 'message-list' },
  1317. filtered.length
  1318. ? filtered.map(msg => {
  1319. const content = msg?.value?.content;
  1320. const author = msg?.value?.author;
  1321. if (!content || !author) return div({ class: 'pm-card malformed' }, i18n.pmInvalidMessage);
  1322. const subjectRaw = content.subject || '';
  1323. const subject = subjectRaw.toUpperCase();
  1324. const text = content.text || '';
  1325. const sentAt = new Date(content.sentAt || msg.timestamp);
  1326. const from = content.from;
  1327. const toLinks = (content.to || []).map(addr => linkAuthor(addr));
  1328. if (subject === 'JOB_SUBSCRIBED' || subject === 'JOB_UNSUBSCRIBED') {
  1329. return JobCard({ type: subject, sentAt, from, toLinks, text, key: msg.key });
  1330. }
  1331. if (subject === 'PROJECT_FOLLOWED' || subject === 'PROJECT_UNFOLLOWED') {
  1332. return ProjectFollowCard({ type: subject, sentAt, from, toLinks, text, key: msg.key });
  1333. }
  1334. if (subject === 'MARKET_SOLD') {
  1335. return MarketSoldCard({ sentAt, from, toLinks, subject: subjectRaw, text, key: msg.key });
  1336. }
  1337. if (subject === 'PROJECT_PLEDGE' || content.meta?.type === 'project-pledge') {
  1338. return ProjectPledgeCard({ sentAt, from, toLinks, content, text, key: msg.key });
  1339. }
  1340. const jobTxt = text.match(/has subscribed to your job offer "([^"]+)"/);
  1341. const jobIdLegacy = pickLink(text, 'job');
  1342. if (jobTxt && jobIdLegacy) return JobCard({ type: 'JOB_SUBSCRIBED', sentAt, from, toLinks, text, key: msg.key });
  1343. const projTxt = text.match(/has created a project "([^"]+)"/);
  1344. const projIdLegacy = pickLink(text, 'project');
  1345. if (projTxt && projIdLegacy) return ProjectCreatedCard({ sentAt, from, toLinks, text, key: msg.key });
  1346. const saleTxt = subjectRaw.match(/item "([^"]+)" has been sold/) || text.match(/item "([^"]+)" has been sold/);
  1347. const marketIdLegacy = pickLink(text, 'market');
  1348. if (saleTxt && marketIdLegacy) return MarketSoldCard({ sentAt, from, toLinks, subject: subjectRaw, text, key: msg.key });
  1349. return div(
  1350. { class: 'pm-card normal-pm' },
  1351. header({ sentAt, from, toLinks }),
  1352. h2(content.subject || i18n.pmNoSubject),
  1353. p({ class: 'message-text' }, ...renderUrl(clickableLinks(text))),
  1354. actions({ key: msg.key, replyId: from })
  1355. );
  1356. })
  1357. : p({ class: 'empty' }, i18n.noPrivateMessages)
  1358. )
  1359. )
  1360. );
  1361. };
  1362. exports.publishCustomView = async () => {
  1363. const action = "/publish/custom";
  1364. const method = "post";
  1365. return template(
  1366. i18n.publishCustom,
  1367. section(
  1368. div({ class: "tags-header" },
  1369. h2(i18n.publishCustom),
  1370. p(i18n.publishCustomDescription)
  1371. ),
  1372. form(
  1373. { action, method },
  1374. textarea(
  1375. {
  1376. autofocus: true,
  1377. required: true,
  1378. name: "text",
  1379. rows: 10,
  1380. style: "width: 100%;"
  1381. },
  1382. "{\n",
  1383. ' "type": "feed",\n',
  1384. ' "hello": "world"\n',
  1385. "}"
  1386. ),
  1387. br,
  1388. br,
  1389. button({ type: "submit" }, i18n.submit)
  1390. )
  1391. ),
  1392. section(
  1393. div({ class: "tags-header" },
  1394. p(i18n.publishBasicInfo({ href: "/publish" }))
  1395. )
  1396. )
  1397. );
  1398. };
  1399. exports.threadView = ({ messages }) => {
  1400. const rootMessage = messages[0];
  1401. const rootAuthorName = rootMessage.value.meta.author.name;
  1402. const rootSnippet = postSnippet(
  1403. lodash.get(rootMessage, "value.content.text", i18n.mysteryDescription)
  1404. );
  1405. return template([`@${rootAuthorName}`],
  1406. div(
  1407. thread(messages)
  1408. )
  1409. );
  1410. };
  1411. exports.publishView = (preview, text, contentWarning) => {
  1412. return template(
  1413. i18n.publish,
  1414. section(
  1415. div({ class: "tags-header" },
  1416. h2(i18n.publishBlog),
  1417. p(i18n.publishLabel({ markdownUrl, linkTarget: "_blank" }))
  1418. )
  1419. ),
  1420. section(
  1421. div({ class: "publish-form" },
  1422. form(
  1423. {
  1424. action: "/publish/preview",
  1425. method: "post",
  1426. enctype: "multipart/form-data",
  1427. },
  1428. [
  1429. label({ for: "contentWarning" }, i18n.blogSubject),
  1430. br(),
  1431. input({
  1432. name: "contentWarning",
  1433. id: "contentWarning",
  1434. type: "text",
  1435. class: "contentWarning",
  1436. value: contentWarning || "",
  1437. placeholder: i18n.contentWarningPlaceholder
  1438. }),
  1439. br(),
  1440. label({ for: "text" }, i18n.blogMessage),
  1441. br(),
  1442. textarea(
  1443. {
  1444. required: true,
  1445. name: "text",
  1446. id: "text",
  1447. rows: "6",
  1448. cols: "50",
  1449. placeholder: i18n.publishWarningPlaceholder,
  1450. class: "publish-textarea"
  1451. },
  1452. text || ""
  1453. ),
  1454. br(),
  1455. label({ for: "blob" }, i18n.blogImage || "Upload Image (jpeg, jpg, png, gif) (max-size: 500px x 400px)"),
  1456. br(),
  1457. input({ type: "file", id: "blob", name: "blob" }),
  1458. br(), br(),
  1459. button({ type: "submit" }, i18n.blogPublish)
  1460. ]
  1461. )
  1462. )
  1463. ),
  1464. preview || "",
  1465. section(
  1466. div({ class: "tags-header" },
  1467. p(i18n.publishCustomInfo({ href: "/publish/custom" }))
  1468. )
  1469. )
  1470. );
  1471. };
  1472. const generatePreview = ({ previewData, contentWarning, action }) => {
  1473. const { authorMeta, formattedText, mentions } = previewData;
  1474. const renderedText = formattedText;
  1475. const msg = {
  1476. key: "%non-existent.preview",
  1477. value: {
  1478. author: authorMeta.id,
  1479. content: {
  1480. type: "post",
  1481. text: renderedText,
  1482. mentions: mentions,
  1483. },
  1484. timestamp: Date.now(),
  1485. meta: {
  1486. isPrivate: false,
  1487. votes: [],
  1488. author: {
  1489. name: authorMeta.name,
  1490. avatar: {
  1491. url: `http://localhost:3000/blob/${encodeURIComponent(authorMeta.image)}`,
  1492. },
  1493. },
  1494. },
  1495. },
  1496. };
  1497. if (contentWarning) {
  1498. msg.value.content.contentWarning = contentWarning;
  1499. }
  1500. if (msg.value.meta.author.avatar.url === 'http://localhost:3000/blob/%260000000000000000000000000000000000000000000%3D.sha256') {
  1501. msg.value.meta.author.avatar.url = '/assets/images/default-avatar.png';
  1502. }
  1503. const ts = new Date(msg.value.timestamp);
  1504. lodash.set(msg, "value.meta.timestamp.received.iso8601", ts.toISOString());
  1505. const ago = Date.now() - Number(ts);
  1506. const prettyAgo = prettyMs(ago, { compact: true });
  1507. lodash.set(msg, "value.meta.timestamp.received.since", prettyAgo);
  1508. return div(
  1509. section(
  1510. { class: "post-preview" },
  1511. div(
  1512. { class: "preview-content" },
  1513. h2(i18n.messagePreview),
  1514. post({ msg, preview: true })
  1515. ),
  1516. ),
  1517. section(
  1518. { class: "mention-suggestions" },
  1519. Object.keys(mentions).map((name) => {
  1520. const matches = mentions[name];
  1521. return div(
  1522. h2(i18n.mentionsMatching),
  1523. { class: "mention-card" },
  1524. a(
  1525. {
  1526. href: `/author/@${encodeURIComponent(matches[0].feed)}`,
  1527. },
  1528. img({ src: msg.value.meta.author.avatar.url, class: "avatar-profile" })
  1529. ),
  1530. br,
  1531. div(
  1532. { class: "mention-name" },
  1533. span({ class: "label" }, `${i18n.mentionsName}: `),
  1534. a(
  1535. {
  1536. href: `/author/@${encodeURIComponent(matches[0].feed)}`,
  1537. },
  1538. `@${authorMeta.name}`
  1539. )
  1540. ),
  1541. div(
  1542. { class: "mention-relationship" },
  1543. span({ class: "label" }, `${i18n.mentionsRelationship}:`),
  1544. span({ class: "relationship" }, matches[0].rel.followsMe ? i18n.relationshipMutuals : i18n.relationshipNotMutuals),
  1545. { class: "mention-relationship-details" },
  1546. span({ class: "emoji" }, matches[0].rel.followsMe ? "☍" : "⚼"),
  1547. span({ class: "mentions-listing" },
  1548. a({ class: 'user-link', href: `/author/@${encodeURIComponent(matches[0].feed)}` }, `@${matches[0].feed}`)
  1549. )
  1550. )
  1551. );
  1552. })
  1553. ),
  1554. section(
  1555. form(
  1556. { action, method: "post" },
  1557. [
  1558. input({ type: "hidden", name: "text", value: renderedText }),
  1559. input({ type: "hidden", name: "contentWarning", value: contentWarning || "" }),
  1560. input({ type: "hidden", name: "mentions", value: JSON.stringify(mentions) }),
  1561. button({ type: "submit" }, i18n.publish)
  1562. ]
  1563. )
  1564. )
  1565. );
  1566. };
  1567. exports.previewView = ({ previewData, contentWarning }) => {
  1568. const publishAction = "/publish";
  1569. const preview = generatePreview({
  1570. previewData,
  1571. contentWarning,
  1572. action: publishAction,
  1573. });
  1574. return exports.publishView(preview, previewData.formattedText, contentWarning);
  1575. };
  1576. const viewInfoBox = ({ viewTitle = null, viewDescription = null }) => {
  1577. if (!viewTitle && !viewDescription) {
  1578. return null;
  1579. }
  1580. return section(
  1581. { class: "viewInfo" },
  1582. viewTitle ? h1(viewTitle) : null,
  1583. viewDescription ? em(viewDescription) : null
  1584. );
  1585. };
  1586. exports.likesView = async ({ messages, feed, name }) => {
  1587. const authorLink = a(
  1588. { href: `/author/${encodeURIComponent(feed)}` },
  1589. "@" + name
  1590. );
  1591. return template(
  1592. ["@", name],
  1593. viewInfoBox({
  1594. viewTitle: span(authorLink),
  1595. viewDescription: span(i18n.spreadedDescription)
  1596. }),
  1597. messages.map((msg) => post({ msg }))
  1598. );
  1599. };
  1600. const messageListView = ({
  1601. messages,
  1602. viewTitle = null,
  1603. viewDescription = null,
  1604. viewElements = null,
  1605. aside = null,
  1606. }) => {
  1607. const hasHeader = !!viewElements;
  1608. const titleBlock = hasHeader
  1609. ? viewElements
  1610. : div({ class: "tags-header" },
  1611. h2(viewTitle),
  1612. p(viewDescription)
  1613. );
  1614. return template(
  1615. viewTitle,
  1616. section(titleBlock),
  1617. messages.map((msg) => post({ msg, aside }))
  1618. );
  1619. };
  1620. exports.popularView = ({ messages, prefix }) => {
  1621. const header = div({ class: "tags-header" },
  1622. h2(i18n.popular),
  1623. p(i18n.popularDescription)
  1624. );
  1625. return messageListView({
  1626. messages,
  1627. viewTitle: i18n.popular,
  1628. viewElements: [header, prefix]
  1629. });
  1630. };
  1631. exports.extendedView = ({ messages }) => {
  1632. const header = div({ class: "tags-header" },
  1633. h2(i18n.extended),
  1634. p(i18n.extendedDescription)
  1635. );
  1636. return messageListView({
  1637. messages,
  1638. viewTitle: i18n.extended,
  1639. viewElements: header
  1640. });
  1641. };
  1642. exports.latestView = ({ messages }) => {
  1643. const header = div({ class: "tags-header" },
  1644. h2(i18n.latest),
  1645. p(i18n.latestDescription)
  1646. );
  1647. return messageListView({
  1648. messages,
  1649. viewTitle: i18n.latest,
  1650. viewElements: header
  1651. });
  1652. };
  1653. exports.topicsView = ({ messages, prefix }) => {
  1654. const header = div({ class: "tags-header" },
  1655. h2(i18n.topics),
  1656. p(i18n.topicsDescription)
  1657. );
  1658. return messageListView({
  1659. messages,
  1660. viewTitle: i18n.topics,
  1661. viewElements: [header, prefix]
  1662. });
  1663. };
  1664. exports.summaryView = ({ messages }) => {
  1665. const header = div({ class: "tags-header" },
  1666. h2(i18n.summaries),
  1667. p(i18n.summariesDescription)
  1668. );
  1669. return messageListView({
  1670. messages,
  1671. viewTitle: i18n.summaries,
  1672. viewElements: header,
  1673. aside: true
  1674. });
  1675. };
  1676. exports.spreadedView = ({ messages }) => {
  1677. const header = div({ class: "tags-header" },
  1678. h2(i18n.spreaded),
  1679. p(i18n.spreadedDescription)
  1680. );
  1681. return spreadedListView({
  1682. messages,
  1683. viewTitle: i18n.spreaded,
  1684. viewElements: header
  1685. });
  1686. };
  1687. exports.threadsView = ({ messages }) => {
  1688. const header = div({ class: "tags-header" },
  1689. h2(i18n.threads),
  1690. p(i18n.threadsDescription)
  1691. );
  1692. return messageListView({
  1693. messages,
  1694. viewTitle: i18n.threads,
  1695. viewElements: header,
  1696. aside: true
  1697. });
  1698. };
  1699. exports.previewSubtopicView = async ({
  1700. previewData,
  1701. messages,
  1702. myFeedId,
  1703. contentWarning,
  1704. }) => {
  1705. const publishAction = `/subtopic/${encodeURIComponent(messages[0].key)}`;
  1706. const preview = generatePreview({
  1707. previewData,
  1708. contentWarning,
  1709. action: publishAction,
  1710. });
  1711. return exports.subtopicView(
  1712. { messages, myFeedId },
  1713. preview,
  1714. previewData.text,
  1715. contentWarning
  1716. );
  1717. };
  1718. exports.subtopicView = async (
  1719. { messages, myFeedId },
  1720. preview,
  1721. text,
  1722. contentWarning
  1723. ) => {
  1724. const subtopicForm = `/subtopic/preview/${encodeURIComponent(
  1725. messages[messages.length - 1].key
  1726. )}`;
  1727. let markdownMention;
  1728. const messageElements = await Promise.all(
  1729. messages.reverse().map((message) => {
  1730. debug("%O", message);
  1731. const authorName = message.value.meta.author.name;
  1732. const authorFeedId = message.value.author;
  1733. if (authorFeedId !== myFeedId) {
  1734. if (message.key === messages[0].key) {
  1735. const x = `[@${authorName}](${authorFeedId})\n\n`;
  1736. markdownMention = x;
  1737. }
  1738. }
  1739. return post({ msg: message });
  1740. })
  1741. );
  1742. const authorName = messages[messages.length - 1].value.meta.author.name;
  1743. return template(
  1744. i18n.subtopicTitle({ authorName }),
  1745. div({ class: "thread-container" }, messageElements),
  1746. form(
  1747. { action: subtopicForm, method: "post", enctype: "multipart/form-data" },
  1748. i18n.blogSubject,
  1749. br,
  1750. label(
  1751. i18n.contentWarningLabel,
  1752. input({
  1753. name: "contentWarning",
  1754. type: "text",
  1755. class: "contentWarning",
  1756. value: contentWarning ? contentWarning : "",
  1757. placeholder: i18n.contentWarningPlaceholder,
  1758. })
  1759. ),
  1760. br,
  1761. label({ for: "text" }, i18n.blogMessage),
  1762. br,
  1763. textarea(
  1764. {
  1765. autofocus: true,
  1766. required: true,
  1767. name: "text",
  1768. rows: "6",
  1769. cols: "50",
  1770. placeholder: i18n.publishWarningPlaceholder,
  1771. },
  1772. text ? text : markdownMention
  1773. ),
  1774. br,
  1775. label(
  1776. { for: "blob" },
  1777. i18n.blogImage || "Upload Image (jpeg, jpg, png, gif) (max-size: 500px x 400px)"
  1778. ),
  1779. input({ type: "file", id: "blob", name: "blob" }),
  1780. br,
  1781. br,
  1782. button({ type: "submit" }, i18n.blogPublish)
  1783. ),
  1784. preview ? div({ class: "comment-preview" }, preview) : ""
  1785. );
  1786. };