index.js 41 KB

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