index.js 43 KB

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