index.js 43 KB

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