index.js 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494
  1. "use strict";
  2. const path = require("path");
  3. const envPaths = require("env-paths");
  4. const fs = require("fs");
  5. const debug = require("debug")("oasis");
  6. const highlightJs = require("highlight.js");
  7. const MarkdownIt = require("markdown-it");
  8. const prettyMs = require("pretty-ms");
  9. const {
  10. a,
  11. article,
  12. br,
  13. body,
  14. button,
  15. details,
  16. div,
  17. em,
  18. footer,
  19. form,
  20. h1,
  21. h2,
  22. head,
  23. header,
  24. hr,
  25. html,
  26. img,
  27. input,
  28. label,
  29. li,
  30. link,
  31. main,
  32. meta,
  33. nav,
  34. option,
  35. p,
  36. pre,
  37. progress,
  38. section,
  39. select,
  40. span,
  41. summary,
  42. textarea,
  43. title,
  44. ul,
  45. } = require("hyperaxe");
  46. const lodash = require("lodash");
  47. const markdown = require("./markdown");
  48. const i18nBase = require("./i18n");
  49. let selectedLanguage = "en";
  50. let i18n = i18nBase[selectedLanguage];
  51. exports.setLanguage = (language) => {
  52. selectedLanguage = language;
  53. i18n = Object.assign({}, i18nBase.en, i18nBase[language]);
  54. };
  55. const markdownUrl = "https://commonmark.org/help/";
  56. // SNH-docs
  57. const snhUrl = "https://solarnethub.com";
  58. const projectUrl = "https://solarnethub.com/socialnet/start";
  59. const roleUrl = "https://solarnethub.com/socialnet/roleplaying";
  60. const doctypeString = "<!DOCTYPE html>";
  61. const THREAD_PREVIEW_LENGTH = 3;
  62. const toAttributes = (obj) =>
  63. Object.entries(obj)
  64. .map(([key, val]) => `${key}=${val}`)
  65. .join(", ");
  66. // non-breaking space
  67. const nbsp = "\xa0";
  68. const template = (titlePrefix, ...elements) => {
  69. const navLink = ({ href, emoji, text }) =>
  70. li(
  71. a(
  72. { href, class: titlePrefix === text ? "current" : "" },
  73. span({ class: "emoji" }, emoji),
  74. nbsp,
  75. text
  76. )
  77. );
  78. const customCSS = (filename) => {
  79. const customStyleFile = path.join(
  80. envPaths("oasis", { suffix: "" }).config,
  81. filename
  82. );
  83. try {
  84. if (fs.existsSync(customStyleFile)) {
  85. return link({ rel: "stylesheet", href: filename });
  86. }
  87. } catch (error) {
  88. return "";
  89. }
  90. };
  91. const nodes = html(
  92. { lang: "en" },
  93. head(
  94. title(titlePrefix, " | SNH-Oasis"),
  95. link({ rel: "stylesheet", href: "/theme.css" }),
  96. link({ rel: "stylesheet", href: "/assets/style.css" }),
  97. link({ rel: "stylesheet", href: "/assets/highlight.css" }),
  98. customCSS("/custom-style.css"),
  99. link({ rel: "icon", type: "image/svg+xml", href: "/assets/favicon.svg" }),
  100. meta({ charset: "utf-8" }),
  101. meta({
  102. name: "description",
  103. content: i18n.oasisDescription,
  104. }),
  105. meta({
  106. name: "viewport",
  107. content: toAttributes({ width: "device-width", "initial-scale": 1 }),
  108. })
  109. ),
  110. body(
  111. nav(
  112. ul(
  113. //navLink({ href: "/imageSearch", emoji: "✧", text: i18n.imageSearch }),
  114. navLink({ href: "/public/latest/extended", emoji: "∞", text: i18n.extended }),
  115. navLink({ href: "/public/popular/day", emoji: "⌘", text: i18n.popular }),
  116. navLink({ href: "/public/latest/threads", emoji: "♺", text: i18n.threads }),
  117. navLink({ href: "/public/latest", emoji: "☄", text: i18n.latest }),
  118. navLink({ href: "/public/latest/topics", emoji: "ϟ", text: i18n.topics }),
  119. navLink({ href: "/public/latest/summaries", emoji: "※", text: i18n.summaries }),
  120. navLink({ href: "/mentions", emoji: "✺", text: i18n.mentions }),
  121. )
  122. ),
  123. main({ id: "content" }, elements),
  124. nav(
  125. ul(
  126. navLink({ href: "/publish", emoji: "❂",text: i18n.publish }),
  127. navLink({ href: "/search", emoji: "✦", text: i18n.search }),
  128. navLink({ href: "/inbox", emoji: "☂", text: i18n.private }),
  129. navLink({ href: "/profile", emoji: "⚉", text: i18n.profile }),
  130. navLink({ href: "/invites", emoji: "❄", text: i18n.invites }),
  131. navLink({ href: "/peers", emoji: "⧖", text: i18n.peers }),
  132. navLink({ href: "/settings", emoji: "⚙", text: i18n.settings })
  133. )
  134. )
  135. )
  136. );
  137. const result = doctypeString + nodes.outerHTML;
  138. return result;
  139. };
  140. const thread = (messages) => {
  141. // this first loop is preprocessing to enable auto-expansion of forks when a
  142. // message in the fork is linked to
  143. let lookingForTarget = true;
  144. let shallowest = Infinity;
  145. for (let i = messages.length - 1; i >= 0; i--) {
  146. const msg = messages[i];
  147. const depth = lodash.get(msg, "value.meta.thread.depth", 0);
  148. if (lookingForTarget) {
  149. const isThreadTarget = Boolean(
  150. lodash.get(msg, "value.meta.thread.target", false)
  151. );
  152. if (isThreadTarget) {
  153. lookingForTarget = false;
  154. }
  155. } else {
  156. if (depth < shallowest) {
  157. lodash.set(msg, "value.meta.thread.ancestorOfTarget", true);
  158. shallowest = depth;
  159. }
  160. }
  161. }
  162. const msgList = [];
  163. for (let i = 0; i < messages.length; i++) {
  164. const j = i + 1;
  165. const currentMsg = messages[i];
  166. const nextMsg = messages[j];
  167. const depth = (msg) => {
  168. // will be undefined when checking depth(nextMsg) when currentMsg is the
  169. // last message in the thread
  170. if (msg === undefined) return 0;
  171. return lodash.get(msg, "value.meta.thread.depth", 0);
  172. };
  173. msgList.push(post({ msg: currentMsg }).outerHTML);
  174. if (depth(currentMsg) < depth(nextMsg)) {
  175. const isAncestor = Boolean(
  176. lodash.get(currentMsg, "value.meta.thread.ancestorOfTarget", false)
  177. );
  178. const isBlocked = Boolean(nextMsg.value.meta.blocking);
  179. msgList.push(`<div class="indent"><details ${isAncestor ? "open" : ""}>`);
  180. const nextAuthor = lodash.get(nextMsg, "value.meta.author.name");
  181. const nextSnippet = postSnippet(
  182. lodash.has(nextMsg, "value.content.contentWarning")
  183. ? lodash.get(nextMsg, "value.content.contentWarning")
  184. : lodash.get(nextMsg, "value.content.text")
  185. );
  186. msgList.push(
  187. summary(
  188. isBlocked
  189. ? i18n.relationshipBlockingPost
  190. : `${nextAuthor}: ${nextSnippet}`
  191. ).outerHTML
  192. );
  193. } else if (depth(currentMsg) > depth(nextMsg)) {
  194. // getting more shallow
  195. const diffDepth = depth(currentMsg) - depth(nextMsg);
  196. const shallowList = [];
  197. for (let d = 0; d < diffDepth; d++) {
  198. // on the way up it might go several depths at once
  199. shallowList.push("</details></div>");
  200. }
  201. msgList.push(shallowList);
  202. }
  203. }
  204. const htmlStrings = lodash.flatten(msgList);
  205. return div(
  206. {},
  207. { class: "thread-container", innerHTML: htmlStrings.join("") }
  208. );
  209. };
  210. const postSnippet = (text) => {
  211. const max = 40;
  212. text = text.trim().split("\n", 3).join("\n");
  213. // this is taken directly from patchwork. i'm not entirely sure what this
  214. // regex is doing
  215. text = text.replace(/_|`|\*|#|^\[@.*?]|\[|]|\(\S*?\)/g, "").trim();
  216. text = text.replace(/:$/, "");
  217. text = text.trim().split("\n", 1)[0].trim();
  218. if (text.length > max) {
  219. text = text.substring(0, max - 1) + "…";
  220. }
  221. return text;
  222. };
  223. /**
  224. * Render a section containing a link that takes users to the context for a
  225. * thread preview.
  226. *
  227. * @param {Array} thread with SSB message objects
  228. * @param {Boolean} isComment true if this is shown in the context of a comment
  229. * instead of a post
  230. */
  231. const continueThreadComponent = (thread, isComment) => {
  232. const encoded = {
  233. next: encodeURIComponent(thread[THREAD_PREVIEW_LENGTH + 1].key),
  234. parent: encodeURIComponent(thread[0].key),
  235. };
  236. const left = thread.length - (THREAD_PREVIEW_LENGTH + 1);
  237. let continueLink;
  238. if (isComment == false) {
  239. continueLink = `/thread/${encoded.parent}#${encoded.next}`;
  240. return a(
  241. { href: continueLink },
  242. `continue reading ${left} more comment${left === 1 ? "" : "s"}`
  243. );
  244. } else {
  245. continueLink = `/thread/${encoded.parent}`;
  246. return a({ href: continueLink }, "read the rest of the thread");
  247. }
  248. };
  249. /**
  250. * Render an aside with a preview of comments on a message
  251. *
  252. * For posts, up to three comments are shown, for comments, up to 3 messages
  253. * directly following this one in the thread are displayed. If there are more
  254. * messages in the thread, a link is rendered that links to the rest of the
  255. * context.
  256. *
  257. * @param {Object} post for which to display the aside
  258. */
  259. const postAside = ({ key, value }) => {
  260. const thread = value.meta.thread;
  261. if (thread == null) return null;
  262. const isComment = value.meta.postType === "comment";
  263. let postsToShow;
  264. if (isComment) {
  265. const commentPosition = thread.findIndex((msg) => msg.key === key);
  266. postsToShow = thread.slice(
  267. commentPosition + 1,
  268. Math.min(commentPosition + (THREAD_PREVIEW_LENGTH + 1), thread.length)
  269. );
  270. } else {
  271. postsToShow = thread.slice(
  272. 1,
  273. Math.min(thread.length, THREAD_PREVIEW_LENGTH + 1)
  274. );
  275. }
  276. const fragments = postsToShow.map((p) => post({ msg: p }));
  277. if (thread.length > THREAD_PREVIEW_LENGTH + 1) {
  278. fragments.push(section(continueThreadComponent(thread, isComment)));
  279. }
  280. return div({ class: "indent" }, fragments);
  281. };
  282. const post = ({ msg, aside = false }) => {
  283. const encoded = {
  284. key: encodeURIComponent(msg.key),
  285. author: encodeURIComponent(msg.value.author),
  286. parent: encodeURIComponent(msg.value.content.root),
  287. };
  288. const url = {
  289. author: `/author/${encoded.author}`,
  290. likeForm: `/like/${encoded.key}`,
  291. link: `/thread/${encoded.key}#${encoded.key}`,
  292. parent: `/thread/${encoded.parent}#${encoded.parent}`,
  293. avatar: msg.value.meta.author.avatar.url,
  294. json: `/json/${encoded.key}`,
  295. subtopic: `/subtopic/${encoded.key}`,
  296. comment: `/comment/${encoded.key}`,
  297. };
  298. const isPrivate = Boolean(msg.value.meta.private);
  299. const isBlocked = Boolean(msg.value.meta.blocking);
  300. const isRoot = msg.value.content.root == null;
  301. const isFork = msg.value.meta.postType === "subtopic";
  302. const hasContentWarning =
  303. typeof msg.value.content.contentWarning === "string";
  304. const isThreadTarget = Boolean(
  305. lodash.get(msg, "value.meta.thread.target", false)
  306. );
  307. const { name } = msg.value.meta.author;
  308. const ts_received = msg.value.meta.timestamp.received;
  309. const timeAgo = ts_received.since.replace("~", "");
  310. const timeAbsolute = ts_received.iso8601.split(".")[0].replace("T", " ");
  311. const markdownContent = markdown(
  312. msg.value.content.text,
  313. msg.value.content.mentions
  314. );
  315. const likeButton = msg.value.meta.voted
  316. ? { value: 0, class: "liked" }
  317. : { value: 1, class: null };
  318. const likeCount = msg.value.meta.votes.length;
  319. const maxLikedNameLength = 16;
  320. const maxLikedNames = 16;
  321. const likedByNames = msg.value.meta.votes
  322. .slice(0, maxLikedNames)
  323. .map((person) => person.name)
  324. .map((name) => name.slice(0, maxLikedNameLength))
  325. .join(", ");
  326. const additionalLikesMessage =
  327. likeCount > maxLikedNames ? `+${likeCount - maxLikedNames} more` : ``;
  328. const likedByMessage =
  329. likeCount > 0 ? `${likedByNames} ${additionalLikesMessage}` : null;
  330. const messageClasses = ["post"];
  331. const recps = [];
  332. const addRecps = (recpsInfo) => {
  333. recpsInfo.forEach(function (recp) {
  334. recps.push(
  335. a(
  336. { href: `/author/${encodeURIComponent(recp.feedId)}` },
  337. img({ class: "avatar", src: recp.avatarUrl, alt: "" })
  338. )
  339. );
  340. });
  341. };
  342. if (isPrivate) {
  343. messageClasses.push("private");
  344. addRecps(msg.value.meta.recpsInfo);
  345. }
  346. if (isThreadTarget) {
  347. messageClasses.push("thread-target");
  348. }
  349. // TODO: Refactor to stop using strings and use constants/symbols.
  350. const postOptions = {
  351. post: null,
  352. comment: i18n.commentDescription({ parentUrl: url.parent }),
  353. subtopic: i18n.subtopicDescription({ parentUrl: url.parent }),
  354. mystery: i18n.mysteryDescription,
  355. };
  356. const emptyContent = "<p>undefined</p>\n";
  357. const articleElement =
  358. markdownContent === emptyContent
  359. ? article(
  360. { class: "content" },
  361. pre({
  362. innerHTML: highlightJs.highlight(
  363. "json",
  364. JSON.stringify(msg, null, 2)
  365. ).value,
  366. })
  367. )
  368. : article({ class: "content", innerHTML: markdownContent });
  369. if (isBlocked) {
  370. messageClasses.push("blocked");
  371. return section(
  372. {
  373. id: msg.key,
  374. class: messageClasses.join(" "),
  375. },
  376. i18n.relationshipBlockingPost
  377. );
  378. }
  379. const articleContent = hasContentWarning
  380. ? details(summary(msg.value.content.contentWarning), articleElement)
  381. : articleElement;
  382. const fragment = section(
  383. {
  384. id: msg.key,
  385. class: messageClasses.join(" "),
  386. },
  387. header(
  388. div(
  389. span(
  390. { class: "author" },
  391. a(
  392. { href: url.author },
  393. img({ class: "avatar", src: url.avatar, alt: "" }),
  394. name
  395. )
  396. ),
  397. span({ class: "author-action" }, postOptions[msg.value.meta.postType]),
  398. span(
  399. {
  400. class: "time",
  401. title: timeAbsolute,
  402. },
  403. isPrivate ? "🔒" : null,
  404. isPrivate ? recps : null,
  405. a({ href: url.link }, nbsp, timeAgo)
  406. )
  407. )
  408. ),
  409. articleContent,
  410. // HACK: centered-footer
  411. //
  412. // Here we create an empty div with an anchor tag that can be linked to.
  413. // In our CSS we ensure that this gets centered on the screen when we
  414. // link to this anchor tag.
  415. //
  416. // This is used for redirecting users after they like a post, when we
  417. // want the like button that they just clicked to remain close-ish to
  418. // where it was before they clicked the button.
  419. div({ id: `centered-footer-${encoded.key}`, class: "centered-footer" }),
  420. footer(
  421. div(
  422. form(
  423. { action: url.likeForm, method: "post" },
  424. button(
  425. {
  426. name: "voteValue",
  427. type: "submit",
  428. value: likeButton.value,
  429. class: likeButton.class,
  430. title: likedByMessage,
  431. },
  432. `☉ ${likeCount}`
  433. )
  434. ),
  435. a({ href: url.comment }, i18n.comment),
  436. isPrivate || isRoot || isFork
  437. ? null
  438. : a({ href: url.subtopic }, nbsp, i18n.subtopic),
  439. a({ href: url.json }, nbsp, i18n.json)
  440. ),
  441. br()
  442. )
  443. );
  444. const threadSeparator = [div({ class: "text-browser" }, hr(), br())];
  445. if (aside) {
  446. return [fragment, postAside(msg), isRoot ? threadSeparator : null];
  447. } else {
  448. return fragment;
  449. }
  450. };
  451. exports.editProfileView = ({ name, description }) =>
  452. template(
  453. i18n.editProfile,
  454. section(
  455. h1(i18n.editProfile),
  456. p(i18n.editProfileDescription),
  457. form(
  458. {
  459. action: "/profile/edit",
  460. method: "POST",
  461. enctype: "multipart/form-data",
  462. },
  463. label(
  464. i18n.profileImage,
  465. input({ type: "file", name: "image", accept: "image/*" })
  466. ),
  467. label(i18n.profileName, input({ name: "name", value: name })),
  468. label(
  469. i18n.profileDescription,
  470. textarea(
  471. {
  472. autofocus: true,
  473. name: "description",
  474. },
  475. description
  476. )
  477. ),
  478. button(
  479. {
  480. type: "submit",
  481. },
  482. i18n.submit
  483. )
  484. )
  485. )
  486. );
  487. /**
  488. * @param {{avatarUrl: string, description: string, feedId: string, messages: any[], name: string, relationship: object, firstPost: object, lastPost: object}} input
  489. */
  490. exports.authorView = ({
  491. avatarUrl,
  492. description,
  493. feedId,
  494. messages,
  495. firstPost,
  496. lastPost,
  497. name,
  498. relationship,
  499. }) => {
  500. const mention = `[@${name}](${feedId})`;
  501. const markdownMention = highlightJs.highlight("markdown", mention).value;
  502. const contactForms = [];
  503. const addForm = ({ action }) =>
  504. contactForms.push(
  505. form(
  506. {
  507. action: `/${action}/${encodeURIComponent(feedId)}`,
  508. method: "post",
  509. },
  510. button(
  511. {
  512. type: "submit",
  513. },
  514. i18n[action]
  515. )
  516. )
  517. );
  518. if (relationship.me === false) {
  519. if (relationship.following) {
  520. addForm({ action: "unfollow" });
  521. } else if (relationship.blocking) {
  522. addForm({ action: "unblock" });
  523. } else {
  524. addForm({ action: "follow" });
  525. addForm({ action: "block" });
  526. }
  527. }
  528. const relationshipText = (() => {
  529. if (relationship.me === true) {
  530. return i18n.relationshipYou;
  531. } else if (
  532. relationship.following === true &&
  533. relationship.blocking === false
  534. ) {
  535. return i18n.relationshipFollowing;
  536. } else if (
  537. relationship.following === false &&
  538. relationship.blocking === true
  539. ) {
  540. return i18n.relationshipBlocking;
  541. } else if (
  542. relationship.following === false &&
  543. relationship.blocking === false
  544. ) {
  545. return i18n.relationshipNone;
  546. } else if (
  547. relationship.following === true &&
  548. relationship.blocking === true
  549. ) {
  550. return i18n.relationshipConflict;
  551. } else {
  552. throw new Error(`Unknown relationship ${JSON.stringify(relationship)}`);
  553. }
  554. })();
  555. const prefix = section(
  556. { class: "message" },
  557. div(
  558. { class: "profile" },
  559. img({ class: "avatar", src: avatarUrl }),
  560. h1(name)
  561. ),
  562. pre({
  563. class: "md-mention",
  564. innerHTML: markdownMention,
  565. }),
  566. description !== "" ? article({ innerHTML: markdown(description) }) : null,
  567. footer(
  568. div(
  569. a({ href: `/likes/${encodeURIComponent(feedId)}` }, i18n.viewLikes),
  570. span(nbsp, relationshipText),
  571. ...contactForms,
  572. relationship.me
  573. ? a({ href: `/profile/edit` }, nbsp, i18n.editProfile)
  574. : null
  575. ),
  576. br()
  577. )
  578. );
  579. const linkUrl = relationship.me
  580. ? "/profile/"
  581. : `/author/${encodeURIComponent(feedId)}/`;
  582. let items = messages.map((msg) => post({ msg }));
  583. if (items.length === 0) {
  584. if (lastPost === undefined) {
  585. items.push(section(div(span(i18n.feedEmpty))));
  586. } else {
  587. items.push(
  588. section(
  589. div(
  590. span(i18n.feedRangeEmpty),
  591. a({ href: `${linkUrl}` }, i18n.seeFullFeed)
  592. )
  593. )
  594. );
  595. }
  596. } else {
  597. const highestSeqNum = messages[0].value.sequence;
  598. const lowestSeqNum = messages[messages.length - 1].value.sequence;
  599. let newerPostsLink;
  600. if (lastPost !== undefined && highestSeqNum < lastPost.value.sequence)
  601. newerPostsLink = a(
  602. { href: `${linkUrl}?gt=${highestSeqNum}` },
  603. i18n.newerPosts
  604. );
  605. else newerPostsLink = span(i18n.newerPosts, { title: i18n.noNewerPosts });
  606. let olderPostsLink;
  607. if (lowestSeqNum > firstPost.value.sequence)
  608. olderPostsLink = a(
  609. { href: `${linkUrl}?lt=${lowestSeqNum}` },
  610. i18n.olderPosts
  611. );
  612. else
  613. olderPostsLink = span(i18n.olderPosts, { title: i18n.beginningOfFeed });
  614. const pagination = section(
  615. { class: "message" },
  616. footer(div(newerPostsLink, olderPostsLink), br())
  617. );
  618. items.unshift(pagination);
  619. items.push(pagination);
  620. }
  621. return template(i18n.profile, prefix, items);
  622. };
  623. exports.previewCommentView = async ({
  624. previewData,
  625. messages,
  626. myFeedId,
  627. parentMessage,
  628. contentWarning,
  629. }) => {
  630. const publishAction = `/comment/${encodeURIComponent(messages[0].key)}`;
  631. const preview = generatePreview({
  632. previewData,
  633. contentWarning,
  634. action: publishAction,
  635. });
  636. return exports.commentView(
  637. { messages, myFeedId, parentMessage },
  638. preview,
  639. previewData.text,
  640. contentWarning
  641. );
  642. };
  643. exports.commentView = async (
  644. { messages, myFeedId, parentMessage },
  645. preview,
  646. text,
  647. contentWarning
  648. ) => {
  649. let markdownMention;
  650. const messageElements = await Promise.all(
  651. messages.reverse().map((message) => {
  652. debug("%O", message);
  653. const authorName = message.value.meta.author.name;
  654. const authorFeedId = message.value.author;
  655. if (authorFeedId !== myFeedId) {
  656. if (message.key === parentMessage.key) {
  657. const x = `[@${authorName}](${authorFeedId})\n\n`;
  658. markdownMention = x;
  659. }
  660. }
  661. return post({ msg: message });
  662. })
  663. );
  664. const action = `/comment/preview/${encodeURIComponent(messages[0].key)}`;
  665. const method = "post";
  666. const isPrivate = parentMessage.value.meta.private;
  667. const authorName = parentMessage.value.meta.author.name;
  668. const publicOrPrivate = isPrivate ? i18n.commentPrivate : i18n.commentPublic;
  669. const maybeSubtopicText = isPrivate ? [null] : i18n.commentWarning;
  670. return template(
  671. i18n.commentTitle({ authorName }),
  672. div({ class: "thread-container" }, messageElements),
  673. preview !== undefined ? preview : "",
  674. p(
  675. ...i18n.commentLabel({ publicOrPrivate, markdownUrl }),
  676. ...maybeSubtopicText
  677. ),
  678. form(
  679. { action, method, enctype: "multipart/form-data" },
  680. label(
  681. i18n.contentWarningLabel,
  682. input({
  683. name: "contentWarning",
  684. type: "text",
  685. class: "contentWarning",
  686. value: contentWarning ? contentWarning : "",
  687. placeholder: i18n.contentWarningPlaceholder,
  688. })
  689. ),
  690. textarea(
  691. {
  692. autofocus: true,
  693. required: true,
  694. name: "text",
  695. },
  696. text ? text : isPrivate ? null : markdownMention
  697. ),
  698. button({ type: "submit" }, i18n.preview),
  699. label({ class: "file-button", for: "blob" }, i18n.attachFiles),
  700. input({ type: "file", id: "blob", name: "blob" })
  701. )
  702. );
  703. };
  704. exports.mentionsView = ({ messages }) => {
  705. return messageListView({
  706. messages,
  707. viewTitle: i18n.mentions,
  708. viewDescription: i18n.mentionsDescription,
  709. });
  710. };
  711. exports.privateView = ({ messages }) => {
  712. return messageListView({
  713. messages,
  714. viewTitle: i18n.private,
  715. viewDescription: i18n.privateDescription,
  716. });
  717. };
  718. exports.publishCustomView = async () => {
  719. const action = "/publish/custom";
  720. const method = "post";
  721. return template(
  722. i18n.publishCustom,
  723. section(
  724. h1(i18n.publishCustom),
  725. p(i18n.publishCustomDescription),
  726. form(
  727. { action, method },
  728. textarea(
  729. {
  730. autofocus: true,
  731. required: true,
  732. name: "text",
  733. },
  734. "{\n",
  735. ' "type": "test",\n',
  736. ' "hello": "world"\n',
  737. "}"
  738. ),
  739. button(
  740. {
  741. type: "submit",
  742. },
  743. i18n.submit
  744. )
  745. )
  746. ),
  747. p(i18n.publishBasicInfo({ href: "/publish" }))
  748. );
  749. };
  750. exports.threadView = ({ messages }) => {
  751. const rootMessage = messages[0];
  752. const rootAuthorName = rootMessage.value.meta.author.name;
  753. const rootSnippet = postSnippet(
  754. lodash.get(rootMessage, "value.content.text", i18n.mysteryDescription)
  755. );
  756. return template([`@${rootAuthorName}: `, rootSnippet], thread(messages));
  757. };
  758. exports.publishView = (preview, text, contentWarning) => {
  759. return template(
  760. i18n.publish,
  761. section(
  762. h1(i18n.publish),
  763. form(
  764. {
  765. action: "/publish/preview",
  766. method: "post",
  767. enctype: "multipart/form-data",
  768. },
  769. label(
  770. i18n.publishLabel({ markdownUrl, linkTarget: "_blank" }),
  771. label(
  772. i18n.contentWarningLabel,
  773. input({
  774. name: "contentWarning",
  775. type: "text",
  776. class: "contentWarning",
  777. value: contentWarning ? contentWarning : "",
  778. placeholder: i18n.contentWarningPlaceholder,
  779. })
  780. ),
  781. textarea({ required: true, name: "text", placeholder: i18n.publishWarningPlaceholder }, text ? text : "")
  782. ),
  783. button({ type: "submit" }, i18n.preview),
  784. label({ class: "file-button", for: "blob" }, i18n.attachFiles),
  785. input({ type: "file", id: "blob", name: "blob" })
  786. )
  787. ),
  788. preview ? preview : "",
  789. p(i18n.publishCustomInfo({ href: "/publish/custom" }))
  790. );
  791. };
  792. const generatePreview = ({ previewData, contentWarning, action }) => {
  793. const { authorMeta, text, mentions } = previewData;
  794. // craft message that looks like it came from the db
  795. // cb: this kinda fragile imo? this is for getting a proper post styling ya?
  796. const msg = {
  797. key: "%non-existent.preview",
  798. value: {
  799. author: authorMeta.id,
  800. // sequence: -1,
  801. content: {
  802. type: "post",
  803. text: text,
  804. },
  805. timestamp: Date.now(),
  806. meta: {
  807. isPrivate: true,
  808. votes: [],
  809. author: {
  810. name: authorMeta.name,
  811. avatar: {
  812. url: `/image/64/${encodeURIComponent(authorMeta.image)}`,
  813. },
  814. },
  815. },
  816. },
  817. };
  818. if (contentWarning) msg.value.content.contentWarning = contentWarning;
  819. const ts = new Date(msg.value.timestamp);
  820. lodash.set(msg, "value.meta.timestamp.received.iso8601", ts.toISOString());
  821. const ago = Date.now() - Number(ts);
  822. const prettyAgo = prettyMs(ago, { compact: true });
  823. lodash.set(msg, "value.meta.timestamp.received.since", prettyAgo);
  824. return div(
  825. Object.keys(mentions).length === 0
  826. ? ""
  827. : section(
  828. { class: "mention-suggestions" },
  829. h2(i18n.mentionsMatching),
  830. Object.keys(mentions).map((name) => {
  831. let matches = mentions[name];
  832. return div(
  833. matches.map((m) => {
  834. let relationship = { emoji: "", desc: "" };
  835. if (m.rel.followsMe && m.rel.following) {
  836. // mutuals get the handshake emoji
  837. relationship.emoji = "☍";
  838. relationship.desc = i18n.relationshipMutuals;
  839. } else if (m.rel.following) {
  840. // if we're following that's an eyes emoji
  841. relationship.emoji = "☌";
  842. relationship.desc = i18n.relationshipFollowing;
  843. } else if (m.rel.followsMe) {
  844. // follower has waving-hand emoji
  845. relationship.emoji = "⚼";
  846. relationship.desc = i18n.relationshipTheyFollow;
  847. } else {
  848. // no relationship has question mark emoji
  849. relationship.emoji = "❓";
  850. relationship.desc = i18n.relationshipNotFollowing;
  851. }
  852. return div(
  853. { class: "mentions-container" },
  854. a(
  855. {
  856. class: "mentions-image",
  857. href: `/author/${encodeURIComponent(m.feed)}`,
  858. },
  859. img({ src: `/image/64/${encodeURIComponent(m.img)}` })
  860. ),
  861. a(
  862. {
  863. class: "mentions-name",
  864. href: `/author/${encodeURIComponent(m.feed)}`,
  865. },
  866. m.name
  867. ),
  868. div(
  869. { class: "emo-rel" },
  870. span(
  871. { class: "emoji", title: relationship.desc },
  872. relationship.emoji
  873. ),
  874. span(
  875. { class: "mentions-listing" },
  876. `[@${m.name}](${m.feed})`
  877. )
  878. )
  879. );
  880. })
  881. );
  882. })
  883. ),
  884. section(
  885. { class: "post-preview" },
  886. post({ msg }),
  887. // doesn't need blobs, preview adds them to the text
  888. form(
  889. { action, method: "post" },
  890. input({
  891. name: "contentWarning",
  892. type: "hidden",
  893. value: contentWarning,
  894. }),
  895. input({
  896. name: "text",
  897. type: "hidden",
  898. value: text,
  899. }),
  900. button({ type: "submit" }, i18n.publish)
  901. )
  902. )
  903. );
  904. };
  905. exports.previewView = ({ previewData, contentWarning }) => {
  906. const publishAction = "/publish";
  907. const preview = generatePreview({
  908. previewData,
  909. contentWarning,
  910. action: publishAction,
  911. });
  912. return exports.publishView(preview, previewData.text, contentWarning);
  913. };
  914. exports.peersView = ({ peers }) => {
  915. const startButton = form(
  916. { action: "/settings/conn/start", method: "post" },
  917. button({ type: "submit" }, i18n.startNetworking)
  918. );
  919. const restartButton = form(
  920. { action: "/settings/conn/restart", method: "post" },
  921. button({ type: "submit" }, i18n.restartNetworking)
  922. );
  923. const stopButton = form(
  924. { action: "/settings/conn/stop", method: "post" },
  925. button({ type: "submit" }, i18n.stopNetworking)
  926. );
  927. const syncButton = form(
  928. { action: "/settings/conn/sync", method: "post" },
  929. button({ type: "submit" }, i18n.sync)
  930. );
  931. const connButtons = div({ class: "form-button-group" }, [
  932. startButton,
  933. restartButton,
  934. stopButton,
  935. syncButton,
  936. ]);
  937. const peerList = (peers || [])
  938. .filter(([, data]) => data.state === "connected")
  939. .map(([, data]) => {
  940. return li(
  941. a(
  942. { href: `/author/${encodeURIComponent(data.key)}` },
  943. data.name || data.host || data.key
  944. )
  945. );
  946. });
  947. return template(
  948. i18n.peers,
  949. section(
  950. { class: "message" },
  951. h1(i18n.peerConnections),
  952. connButtons,
  953. p(i18n.connectionsIntro),
  954. peerList.length > 0 ? ul(peerList) : i18n.noConnections,
  955. p(i18n.connectionActionIntro),
  956. )
  957. );
  958. };
  959. exports.invitesView = ({ invites }) => {
  960. return template(
  961. i18n.invites,
  962. section(
  963. { class: "message" },
  964. h1(i18n.invites),
  965. p(i18n.invitesDescription),
  966. form(
  967. { action: "/settings/invite/accept", method: "post" },
  968. input({ name: "invite", type: "text", autofocus: true, required: true }),
  969. button({ type: "submit" }, i18n.acceptInvite)
  970. ),
  971. )
  972. );
  973. };
  974. exports.settingsView = ({ theme, themeNames, version }) => {
  975. const themeElements = themeNames.map((cur) => {
  976. const isCurrentTheme = cur === theme;
  977. if (isCurrentTheme) {
  978. return option({ value: cur, selected: true }, cur);
  979. } else {
  980. return option({ value: cur }, cur);
  981. }
  982. });
  983. const base16 = [
  984. // '00', removed because this is the background
  985. "01",
  986. "02",
  987. "03",
  988. "04",
  989. "05",
  990. "06",
  991. "07",
  992. "08",
  993. "09",
  994. "0A",
  995. "0B",
  996. "0C",
  997. "0D",
  998. "0E",
  999. "0F",
  1000. ];
  1001. const base16Elements = base16.map((base) =>
  1002. div({
  1003. class: `theme-preview theme-preview-${base}`,
  1004. })
  1005. );
  1006. const languageOption = (longName, shortName) =>
  1007. shortName === selectedLanguage
  1008. ? option({ value: shortName, selected: true }, longName)
  1009. : option({ value: shortName }, longName);
  1010. const rebuildButton = form(
  1011. { action: "/settings/rebuild", method: "post" },
  1012. button({ type: "submit" }, i18n.rebuildName)
  1013. );
  1014. return template(
  1015. i18n.settings,
  1016. section(
  1017. { class: "message" },
  1018. h1(i18n.settings),
  1019. p(i18n.settingsIntro({ version })),
  1020. h2(i18n.info),
  1021. p(i18n.docsUrls({ snhUrl, projectUrl, roleUrl })),
  1022. h2(i18n.theme),
  1023. p(i18n.themeIntro),
  1024. form(
  1025. { action: "/theme.css", method: "post" },
  1026. select({ name: "theme" }, ...themeElements),
  1027. button({ type: "submit" }, i18n.setTheme)
  1028. ),
  1029. h2(i18n.language),
  1030. p(i18n.languageDescription),
  1031. form(
  1032. { action: "/language", method: "post" },
  1033. select({ name: "language" }, [
  1034. // Languages are sorted alphabetically by their 'long name'.
  1035. /* spell-checker:disable */
  1036. languageOption("English", "en"),
  1037. languageOption("Español", "es"),
  1038. /* spell-checker:enable */
  1039. ]),
  1040. button({ type: "submit" }, i18n.setLanguage)
  1041. ),
  1042. h2(i18n.indexes),
  1043. p(i18n.indexesDescription),
  1044. rebuildButton,
  1045. )
  1046. );
  1047. };
  1048. /** @param {{ viewTitle: string, viewDescription: string }} input */
  1049. const viewInfoBox = ({ viewTitle = null, viewDescription = null }) => {
  1050. if (!viewTitle && !viewDescription) {
  1051. return null;
  1052. }
  1053. return section(
  1054. { class: "viewInfo" },
  1055. viewTitle ? h1(viewTitle) : null,
  1056. viewDescription ? em(viewDescription) : null
  1057. );
  1058. };
  1059. exports.likesView = async ({ messages, feed, name }) => {
  1060. const authorLink = a(
  1061. { href: `/author/${encodeURIComponent(feed)}` },
  1062. "@" + name
  1063. );
  1064. return template(
  1065. ["@", name, i18n.likedBy],
  1066. viewInfoBox({
  1067. viewTitle: span(authorLink, i18n.likedBy),
  1068. viewDescription: span(i18n.spreadedDescription)
  1069. }),
  1070. messages.map((msg) => post({ msg }))
  1071. );
  1072. };
  1073. const messageListView = ({
  1074. messages,
  1075. viewTitle = null,
  1076. viewDescription = null,
  1077. viewElements = null,
  1078. // If `aside = true`, it will show a few comments in the thread.
  1079. aside = null,
  1080. }) => {
  1081. return template(
  1082. viewTitle,
  1083. section(h1(viewTitle), p(viewDescription), viewElements),
  1084. messages.map((msg) => post({ msg, aside }))
  1085. );
  1086. };
  1087. exports.popularView = ({ messages, prefix }) => {
  1088. return messageListView({
  1089. messages,
  1090. viewElements: prefix,
  1091. viewTitle: i18n.popular,
  1092. viewDescription: i18n.popularDescription,
  1093. });
  1094. };
  1095. exports.extendedView = ({ messages }) => {
  1096. return messageListView({
  1097. messages,
  1098. viewTitle: i18n.extended,
  1099. viewDescription: i18n.extendedDescription,
  1100. });
  1101. };
  1102. exports.latestView = ({ messages }) => {
  1103. return messageListView({
  1104. messages,
  1105. viewTitle: i18n.latest,
  1106. viewDescription: i18n.latestDescription,
  1107. });
  1108. };
  1109. exports.topicsView = ({ messages, prefix }) => {
  1110. return messageListView({
  1111. messages,
  1112. viewTitle: i18n.topics,
  1113. viewDescription: i18n.topicsDescription,
  1114. viewElements: prefix,
  1115. });
  1116. };
  1117. exports.summaryView = ({ messages }) => {
  1118. return messageListView({
  1119. messages,
  1120. viewTitle: i18n.summaries,
  1121. viewDescription: i18n.summariesDescription,
  1122. aside: true,
  1123. });
  1124. };
  1125. exports.spreadedView = ({ messages }) => {
  1126. return spreadedListView({
  1127. messages,
  1128. viewTitle: i18n.spreaded,
  1129. viewDescription: i18n.spreadedDescription,
  1130. });
  1131. };
  1132. exports.threadsView = ({ messages }) => {
  1133. return messageListView({
  1134. messages,
  1135. viewTitle: i18n.threads,
  1136. viewDescription: i18n.threadsDescription,
  1137. aside: true,
  1138. });
  1139. };
  1140. exports.previewSubtopicView = async ({
  1141. previewData,
  1142. messages,
  1143. myFeedId,
  1144. contentWarning,
  1145. }) => {
  1146. const publishAction = `/subtopic/${encodeURIComponent(messages[0].key)}`;
  1147. const preview = generatePreview({
  1148. previewData,
  1149. contentWarning,
  1150. action: publishAction,
  1151. });
  1152. return exports.subtopicView(
  1153. { messages, myFeedId },
  1154. preview,
  1155. previewData.text,
  1156. contentWarning
  1157. );
  1158. };
  1159. exports.subtopicView = async (
  1160. { messages, myFeedId },
  1161. preview,
  1162. text,
  1163. contentWarning
  1164. ) => {
  1165. const subtopicForm = `/subtopic/preview/${encodeURIComponent(
  1166. messages[messages.length - 1].key
  1167. )}`;
  1168. let markdownMention;
  1169. const messageElements = await Promise.all(
  1170. messages.reverse().map((message) => {
  1171. debug("%O", message);
  1172. const authorName = message.value.meta.author.name;
  1173. const authorFeedId = message.value.author;
  1174. if (authorFeedId !== myFeedId) {
  1175. if (message.key === messages[0].key) {
  1176. const x = `[@${authorName}](${authorFeedId})\n\n`;
  1177. markdownMention = x;
  1178. }
  1179. }
  1180. return post({ msg: message });
  1181. })
  1182. );
  1183. const authorName = messages[messages.length - 1].value.meta.author.name;
  1184. return template(
  1185. i18n.subtopicTitle({ authorName }),
  1186. div({ class: "thread-container" }, messageElements),
  1187. preview !== undefined ? preview : "",
  1188. p(i18n.subtopicLabel({ markdownUrl })),
  1189. form(
  1190. { action: subtopicForm, method: "post", enctype: "multipart/form-data" },
  1191. textarea(
  1192. {
  1193. autofocus: true,
  1194. required: true,
  1195. name: "text",
  1196. },
  1197. text ? text : markdownMention
  1198. ),
  1199. label(
  1200. i18n.contentWarningLabel,
  1201. input({
  1202. name: "contentWarning",
  1203. type: "text",
  1204. class: "contentWarning",
  1205. value: contentWarning ? contentWarning : "",
  1206. placeholder: i18n.contentWarningPlaceholder,
  1207. })
  1208. ),
  1209. button({ type: "submit" }, i18n.preview),
  1210. label({ class: "file-button", for: "blob" }, i18n.attachFiles),
  1211. input({ type: "file", id: "blob", name: "blob" })
  1212. )
  1213. );
  1214. };
  1215. exports.searchView = ({ messages, query }) => {
  1216. const searchInput = input({
  1217. name: "query",
  1218. required: false,
  1219. type: "search",
  1220. value: query,
  1221. });
  1222. // - Minimum length of 3 because otherwise SSB-Search hangs forever. :)
  1223. // https://github.com/ssbc/ssb-search/issues/8
  1224. // - Using `setAttribute()` because HyperScript (the HyperAxe dependency has
  1225. // a bug where the `minlength` property is being ignored. No idea why.
  1226. // https://github.com/hyperhype/hyperscript/issues/91
  1227. searchInput.setAttribute("minlength", 3);
  1228. return template(
  1229. i18n.search,
  1230. section(
  1231. h1(i18n.search),
  1232. form(
  1233. { action: "/search", method: "get" },
  1234. label(i18n.searchLabel, searchInput),
  1235. button(
  1236. {
  1237. type: "submit",
  1238. },
  1239. i18n.submit
  1240. )
  1241. )
  1242. ),
  1243. messages.map((msg) => post({ msg }))
  1244. );
  1245. };
  1246. const imageResult = ({ id, infos }) => {
  1247. const encodedBlobId = encodeURIComponent(id);
  1248. // only rendering the first message result so far
  1249. // todo: render links to the others as well
  1250. const info = infos[0];
  1251. const encodedMsgId = encodeURIComponent(info.msg);
  1252. return div(
  1253. {
  1254. class: "image-result",
  1255. },
  1256. [
  1257. a(
  1258. {
  1259. href: `/blob/${encodedBlobId}`,
  1260. },
  1261. img({ src: `/image/256/${encodedBlobId}` })
  1262. ),
  1263. a(
  1264. {
  1265. href: `/thread/${encodedMsgId}#${encodedMsgId}`,
  1266. class: "result-text",
  1267. },
  1268. info.name
  1269. ),
  1270. ]
  1271. );
  1272. };
  1273. exports.imageSearchView = ({ blobs, query }) => {
  1274. const searchInput = input({
  1275. name: "query",
  1276. required: false,
  1277. type: "search",
  1278. value: query,
  1279. });
  1280. // - Minimum length of 3 because otherwise SSB-Search hangs forever. :)
  1281. // https://github.com/ssbc/ssb-search/issues/8
  1282. // - Using `setAttribute()` because HyperScript (the HyperAxe dependency has
  1283. // a bug where the `minlength` property is being ignored. No idea why.
  1284. // https://github.com/hyperhype/hyperscript/issues/91
  1285. searchInput.setAttribute("minlength", 3);
  1286. return template(
  1287. i18n.imageSearch,
  1288. section(
  1289. h1(i18n.imageSearch),
  1290. form(
  1291. { action: "/imageSearch", method: "get" },
  1292. label(i18n.imageSearchLabel, searchInput),
  1293. button(
  1294. {
  1295. type: "submit",
  1296. },
  1297. i18n.submit
  1298. )
  1299. )
  1300. ),
  1301. div(
  1302. {
  1303. class: "image-search-grid",
  1304. },
  1305. Object.keys(blobs)
  1306. // todo: add pagination
  1307. .slice(0, 30)
  1308. .map((blobId) => imageResult({ id: blobId, infos: blobs[blobId] }))
  1309. )
  1310. );
  1311. };
  1312. exports.hashtagView = ({ messages, hashtag }) => {
  1313. return template(
  1314. `#${hashtag}`,
  1315. section(h1(`#${hashtag}`), p(i18n.hashtagDescription)),
  1316. messages.map((msg) => post({ msg }))
  1317. );
  1318. };
  1319. /** @param {{percent: number}} input */
  1320. exports.indexingView = ({ percent }) => {
  1321. // TODO: i18n
  1322. const message = `Oasis has only processed ${percent}% of the messages and needs to catch up. This page will refresh every 10 seconds. Thanks for your patience! ❤`;
  1323. const nodes = html(
  1324. { lang: "en" },
  1325. head(
  1326. title("Oasis"),
  1327. link({ rel: "icon", type: "image/svg+xml", href: "/assets/favicon.svg" }),
  1328. meta({ charset: "utf-8" }),
  1329. meta({
  1330. name: "description",
  1331. content: i18n.oasisDescription,
  1332. }),
  1333. meta({
  1334. name: "viewport",
  1335. content: toAttributes({ width: "device-width", "initial-scale": 1 }),
  1336. }),
  1337. meta({ "http-equiv": "refresh", content: 10 })
  1338. ),
  1339. body(
  1340. main(
  1341. { id: "content" },
  1342. p(message),
  1343. progress({ value: percent, max: 100 })
  1344. )
  1345. )
  1346. );
  1347. const result = doctypeString + nodes.outerHTML;
  1348. return result;
  1349. };