index.js 41 KB

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