"use strict"; const path = require("path"); const envPaths = require("env-paths"); const fs = require("fs"); const homedir = require('os').homedir(); const supportingPath = path.join(homedir, ".ssb/flume/contacts2.json"); const offsetPath = path.join(homedir, ".ssb/flume/log.offset"); const debug = require("debug")("oasis"); const highlightJs = require("highlight.js"); const MarkdownIt = require("markdown-it"); const prettyMs = require("pretty-ms"); const { a, article, br, body, button, details, div, em, footer, form, h1, h2, head, header, hr, html, img, input, label, li, link, main, meta, nav, option, p, pre, progress, section, select, span, summary, textarea, title, ul, } = require("hyperaxe"); const { about, blob, vote } = require("../models")({}); const lodash = require("lodash"); const markdown = require("./markdown"); const i18nBase = require("./i18n"); let selectedLanguage = "en"; let i18n = i18nBase[selectedLanguage]; exports.setLanguage = (language) => { selectedLanguage = language; i18n = Object.assign({}, i18nBase.en, i18nBase[language]); }; const markdownUrl = "https://commonmark.org/help/"; // SNH-docs const snhUrl = "https://solarnethub.com"; const projectUrl = "https://solarnethub.com/socialnet/start"; const roleUrl = "https://solarnethub.com/socialnet/roleplaying"; const doctypeString = ""; const THREAD_PREVIEW_LENGTH = 3; const toAttributes = (obj) => Object.entries(obj) .map(([key, val]) => `${key}=${val}`) .join(", "); // non-breaking space const nbsp = "\xa0"; const template = (titlePrefix, ...elements) => { const navLink = ({ href, emoji, text }) => li( a( { href, class: titlePrefix === text ? "current" : "" }, span({ class: "emoji" }, emoji), nbsp, text ) ); const customCSS = (filename) => { const customStyleFile = path.join( envPaths("oasis", { suffix: "" }).config, filename ); try { if (fs.existsSync(customStyleFile)) { return link({ rel: "stylesheet", href: filename }); } } catch (error) { return ""; } }; const nodes = html( { lang: "en" }, head( title(titlePrefix, " | SNH-Oasis"), link({ rel: "stylesheet", href: "/theme.css" }), link({ rel: "stylesheet", href: "/assets/style.css" }), link({ rel: "stylesheet", href: "/assets/highlight.css" }), customCSS("/custom-style.css"), link({ rel: "icon", type: "image/svg+xml", href: "/assets/favicon.svg" }), meta({ charset: "utf-8" }), meta({ name: "description", content: i18n.oasisDescription, }), meta({ name: "viewport", content: toAttributes({ width: "device-width", "initial-scale": 1 }), }) ), body( nav( ul( //navLink({ href: "/imageSearch", emoji: "✧", text: i18n.imageSearch }), navLink({ href: "/public/latest/extended", emoji: "∞", text: i18n.extended }), navLink({ href: "/public/popular/day", emoji: "⌘", text: i18n.popular }), navLink({ href: "/public/latest/threads", emoji: "♺", text: i18n.threads }), navLink({ href: "/public/latest", emoji: "☄", text: i18n.latest }), navLink({ href: "/public/latest/topics", emoji: "ϟ", text: i18n.topics }), navLink({ href: "/public/latest/summaries", emoji: "※", text: i18n.summaries }), navLink({ href: "/mentions", emoji: "✺", text: i18n.mentions }), ) ), main({ id: "content" }, elements), nav( ul( navLink({ href: "/publish", emoji: "❂",text: i18n.publish }), navLink({ href: "/search", emoji: "✦", text: i18n.search }), navLink({ href: "/inbox", emoji: "☂", text: i18n.private }), navLink({ href: "/profile", emoji: "⚉", text: i18n.profile }), navLink({ href: "/invites", emoji: "❄", text: i18n.invites }), navLink({ href: "/peers", emoji: "⧖", text: i18n.peers }), navLink({ href: "/settings", emoji: "⚙", text: i18n.settings }) ) ) ) ); const result = doctypeString + nodes.outerHTML; return result; }; const thread = (messages) => { // this first loop is preprocessing to enable auto-expansion of forks when a // message in the fork is linked to let lookingForTarget = true; let shallowest = Infinity; for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; const depth = lodash.get(msg, "value.meta.thread.depth", 0); if (lookingForTarget) { const isThreadTarget = Boolean( lodash.get(msg, "value.meta.thread.target", false) ); if (isThreadTarget) { lookingForTarget = false; } } else { if (depth < shallowest) { lodash.set(msg, "value.meta.thread.ancestorOfTarget", true); shallowest = depth; } } } const msgList = []; for (let i = 0; i < messages.length; i++) { const j = i + 1; const currentMsg = messages[i]; const nextMsg = messages[j]; const depth = (msg) => { // will be undefined when checking depth(nextMsg) when currentMsg is the // last message in the thread if (msg === undefined) return 0; return lodash.get(msg, "value.meta.thread.depth", 0); }; msgList.push(post({ msg: currentMsg }).outerHTML); if (depth(currentMsg) < depth(nextMsg)) { const isAncestor = Boolean( lodash.get(currentMsg, "value.meta.thread.ancestorOfTarget", false) ); const isBlocked = Boolean(nextMsg.value.meta.blocking); msgList.push(`
undefined
\n"; const articleElement = markdownContent === emptyContent ? article( { class: "content" }, pre({ innerHTML: highlightJs.highlight( "json", JSON.stringify(msg, null, 2) ).value, }) ) : article({ class: "content", innerHTML: markdownContent }); if (isBlocked) { messageClasses.push("blocked"); return section( { id: msg.key, class: messageClasses.join(" "), }, i18n.relationshipBlockingPost ); } const articleContent = hasContentWarning ? details(summary(msg.value.content.contentWarning), articleElement) : articleElement; const fragment = section( { id: msg.key, class: messageClasses.join(" "), }, header( div( span( { class: "author" }, a( { href: url.author }, img({ class: "avatar", src: url.avatar, alt: "" }), name ) ), span({ class: "author-action" }, postOptions[msg.value.meta.postType]), span( { class: "time", title: timeAbsolute, }, isPrivate ? "🔒" : null, isPrivate ? recps : null, a({ href: url.link }, nbsp, timeAgo) ) ) ), articleContent, // HACK: centered-footer // // Here we create an empty div with an anchor tag that can be linked to. // In our CSS we ensure that this gets centered on the screen when we // link to this anchor tag. // // This is used for redirecting users after they like a post, when we // want the like button that they just clicked to remain close-ish to // where it was before they clicked the button. div({ id: `centered-footer-${encoded.key}`, class: "centered-footer" }), footer( div( form( { action: url.likeForm, method: "post" }, button( { name: "voteValue", type: "submit", value: likeButton.value, class: likeButton.class, title: likedByMessage, }, `☉ ${likeCount}` ) ), a({ href: url.comment }, i18n.comment), isPrivate || isRoot || isFork ? null : a({ href: url.subtopic }, nbsp, i18n.subtopic), a({ href: url.json }, nbsp, i18n.json) ), br() ) ); const threadSeparator = [div({ class: "text-browser" }, hr(), br())]; if (aside) { return [fragment, postAside(msg), isRoot ? threadSeparator : null]; } else { return fragment; } }; exports.editProfileView = ({ name, description }) => template( i18n.editProfile, section( h1(i18n.editProfile), p(i18n.editProfileDescription), form( { action: "/profile/edit", method: "POST", enctype: "multipart/form-data", }, label( i18n.profileImage, input({ type: "file", name: "image", accept: "image/*" }) ), label(i18n.profileName, input({ name: "name", value: name })), label( i18n.profileDescription, textarea( { autofocus: true, name: "description", }, description ) ), button( { type: "submit", }, i18n.submit ) ) ) ); /** * @param {{avatarUrl: string, description: string, feedId: string, messages: any[], name: string, relationship: object, firstPost: object, lastPost: object}} input */ exports.authorView = ({ avatarUrl, description, feedId, messages, firstPost, lastPost, name, relationship, }) => { const mention = `[@${name}](${feedId})`; const markdownMention = highlightJs.highlight("markdown", mention).value; const contactForms = []; const addForm = ({ action }) => contactForms.push( form( { action: `/${action}/${encodeURIComponent(feedId)}`, method: "post", }, button( { type: "submit", }, i18n[action] ) ) ); if (relationship.me === false) { if (relationship.following) { addForm({ action: "unfollow" }); } else if (relationship.blocking) { addForm({ action: "unblock" }); } else { addForm({ action: "follow" }); addForm({ action: "block" }); } } const relationshipText = (() => { if (relationship.me === true) { return i18n.relationshipYou; } else if ( relationship.following === true && relationship.blocking === false ) { return i18n.relationshipFollowing; } else if ( relationship.following === false && relationship.blocking === true ) { return i18n.relationshipBlocking; } else if ( relationship.following === false && relationship.blocking === false ) { return i18n.relationshipNone; } else if ( relationship.following === true && relationship.blocking === true ) { return i18n.relationshipConflict; } else { throw new Error(`Unknown relationship ${JSON.stringify(relationship)}`); } })(); const prefix = section( { class: "message" }, div( { class: "profile" }, img({ class: "avatar", src: avatarUrl }), h1(name) ), pre({ class: "md-mention", innerHTML: markdownMention, }), description !== "" ? article({ innerHTML: markdown(description) }) : null, footer( div( a({ href: `/likes/${encodeURIComponent(feedId)}` }, i18n.viewLikes), span(nbsp, relationshipText), ...contactForms, relationship.me ? a({ href: `/profile/edit` }, nbsp, i18n.editProfile) : null ), br() ) ); const linkUrl = relationship.me ? "/profile/" : `/author/${encodeURIComponent(feedId)}/`; let items = messages.map((msg) => post({ msg })); if (items.length === 0) { if (lastPost === undefined) { items.push(section(div(span(i18n.feedEmpty)))); } else { items.push( section( div( span(i18n.feedRangeEmpty), a({ href: `${linkUrl}` }, i18n.seeFullFeed) ) ) ); } } else { const highestSeqNum = messages[0].value.sequence; const lowestSeqNum = messages[messages.length - 1].value.sequence; let newerPostsLink; if (lastPost !== undefined && highestSeqNum < lastPost.value.sequence) newerPostsLink = a( { href: `${linkUrl}?gt=${highestSeqNum}` }, i18n.newerPosts ); else newerPostsLink = span(i18n.newerPosts, { title: i18n.noNewerPosts }); let olderPostsLink; if (lowestSeqNum > firstPost.value.sequence) olderPostsLink = a( { href: `${linkUrl}?lt=${lowestSeqNum}` }, i18n.olderPosts ); else olderPostsLink = span(i18n.olderPosts, { title: i18n.beginningOfFeed }); const pagination = section( { class: "message" }, footer(div(newerPostsLink, olderPostsLink), br()) ); items.unshift(pagination); items.push(pagination); } return template(i18n.profile, prefix, items); }; exports.previewCommentView = async ({ previewData, messages, myFeedId, parentMessage, contentWarning, }) => { const publishAction = `/comment/${encodeURIComponent(messages[0].key)}`; const preview = generatePreview({ previewData, contentWarning, action: publishAction, }); return exports.commentView( { messages, myFeedId, parentMessage }, preview, previewData.text, contentWarning ); }; exports.commentView = async ( { messages, myFeedId, parentMessage }, preview, text, contentWarning ) => { let markdownMention; const messageElements = await Promise.all( messages.reverse().map((message) => { debug("%O", message); const authorName = message.value.meta.author.name; const authorFeedId = message.value.author; if (authorFeedId !== myFeedId) { if (message.key === parentMessage.key) { const x = `[@${authorName}](${authorFeedId})\n\n`; markdownMention = x; } } return post({ msg: message }); }) ); const action = `/comment/preview/${encodeURIComponent(messages[0].key)}`; const method = "post"; const isPrivate = parentMessage.value.meta.private; const authorName = parentMessage.value.meta.author.name; const publicOrPrivate = isPrivate ? i18n.commentPrivate : i18n.commentPublic; const maybeSubtopicText = isPrivate ? [null] : i18n.commentWarning; return template( i18n.commentTitle({ authorName }), div({ class: "thread-container" }, messageElements), preview !== undefined ? preview : "", p( ...i18n.commentLabel({ publicOrPrivate, markdownUrl }), ...maybeSubtopicText ), form( { action, method, enctype: "multipart/form-data" }, label( i18n.contentWarningLabel, input({ name: "contentWarning", type: "text", class: "contentWarning", value: contentWarning ? contentWarning : "", placeholder: i18n.contentWarningPlaceholder, }) ), textarea( { autofocus: true, required: true, name: "text", }, text ? text : isPrivate ? null : markdownMention ), button({ type: "submit" }, i18n.preview), label({ class: "file-button", for: "blob" }, i18n.attachFiles), input({ type: "file", id: "blob", name: "blob" }) ) ); }; exports.mentionsView = ({ messages }) => { return messageListView({ messages, viewTitle: i18n.mentions, viewDescription: i18n.mentionsDescription, }); }; exports.privateView = ({ messages }) => { return messageListView({ messages, viewTitle: i18n.private, viewDescription: i18n.privateDescription, }); }; exports.publishCustomView = async () => { const action = "/publish/custom"; const method = "post"; return template( i18n.publishCustom, section( h1(i18n.publishCustom), p(i18n.publishCustomDescription), form( { action, method }, textarea( { autofocus: true, required: true, name: "text", }, "{\n", ' "type": "test",\n', ' "hello": "world"\n', "}" ), button( { type: "submit", }, i18n.submit ) ) ), p(i18n.publishBasicInfo({ href: "/publish" })) ); }; exports.threadView = ({ messages }) => { const rootMessage = messages[0]; const rootAuthorName = rootMessage.value.meta.author.name; const rootSnippet = postSnippet( lodash.get(rootMessage, "value.content.text", i18n.mysteryDescription) ); return template([`@${rootAuthorName}: `, rootSnippet], thread(messages)); }; exports.publishView = (preview, text, contentWarning) => { return template( i18n.publish, section( h1(i18n.publish), form( { action: "/publish/preview", method: "post", enctype: "multipart/form-data", }, label( i18n.publishLabel({ markdownUrl, linkTarget: "_blank" }), label( i18n.contentWarningLabel, input({ name: "contentWarning", type: "text", class: "contentWarning", value: contentWarning ? contentWarning : "", placeholder: i18n.contentWarningPlaceholder, }) ), textarea({ required: true, name: "text", placeholder: i18n.publishWarningPlaceholder }, text ? text : "") ), button({ type: "submit" }, i18n.preview), label({ class: "file-button", for: "blob" }, i18n.attachFiles), input({ type: "file", id: "blob", name: "blob" }) ) ), preview ? preview : "", p(i18n.publishCustomInfo({ href: "/publish/custom" })) ); }; const generatePreview = ({ previewData, contentWarning, action }) => { const { authorMeta, text, mentions } = previewData; // craft message that looks like it came from the db // cb: this kinda fragile imo? this is for getting a proper post styling ya? const msg = { key: "%non-existent.preview", value: { author: authorMeta.id, // sequence: -1, content: { type: "post", text: text, }, timestamp: Date.now(), meta: { isPrivate: true, votes: [], author: { name: authorMeta.name, avatar: { url: `/image/64/${encodeURIComponent(authorMeta.image)}`, }, }, }, }, }; if (contentWarning) msg.value.content.contentWarning = contentWarning; const ts = new Date(msg.value.timestamp); lodash.set(msg, "value.meta.timestamp.received.iso8601", ts.toISOString()); const ago = Date.now() - Number(ts); const prettyAgo = prettyMs(ago, { compact: true }); lodash.set(msg, "value.meta.timestamp.received.since", prettyAgo); return div( Object.keys(mentions).length === 0 ? "" : section( { class: "mention-suggestions" }, h2(i18n.mentionsMatching), Object.keys(mentions).map((name) => { let matches = mentions[name]; return div( matches.map((m) => { let relationship = { emoji: "", desc: "" }; if (m.rel.followsMe && m.rel.following) { relationship.emoji = "☍"; relationship.desc = i18n.relationshipMutuals; } else if (m.rel.following) { relationship.emoji = "☌"; relationship.desc = i18n.relationshipFollowing; } else if (m.rel.followsMe) { relationship.emoji = "⚼"; relationship.desc = i18n.relationshipTheyFollow; } else { relationship.emoji = "❓"; relationship.desc = i18n.relationshipNotFollowing; } return div( { class: "mentions-container" }, a( { class: "mentions-image", href: `/author/${encodeURIComponent(m.feed)}`, }, img({ src: `/image/64/${encodeURIComponent(m.img)}` }) ), a( { class: "mentions-name", href: `/author/${encodeURIComponent(m.feed)}`, }, m.name ), div( { class: "emo-rel" }, span( { class: "emoji", title: relationship.desc }, relationship.emoji ), span( { class: "mentions-listing" }, `[@${m.name}](${m.feed})` ) ) ); }) ); }) ), section( { class: "post-preview" }, post({ msg }), // doesn't need blobs, preview adds them to the text form( { action, method: "post" }, input({ name: "contentWarning", type: "hidden", value: contentWarning, }), input({ name: "text", type: "hidden", value: text, }), button({ type: "submit" }, i18n.publish) ) ) ); }; exports.previewView = ({ previewData, contentWarning }) => { const publishAction = "/publish"; const preview = generatePreview({ previewData, contentWarning, action: publishAction, }); return exports.publishView(preview, previewData.text, contentWarning); }; exports.peersView = async ({ peers }) => { const startButton = form( { action: "/settings/conn/start", method: "post" }, button({ type: "submit" }, i18n.startNetworking) ); const restartButton = form( { action: "/settings/conn/restart", method: "post" }, button({ type: "submit" }, i18n.restartNetworking) ); const stopButton = form( { action: "/settings/conn/stop", method: "post" }, button({ type: "submit" }, i18n.stopNetworking) ); const syncButton = form( { action: "/settings/conn/sync", method: "post" }, button({ type: "submit" }, i18n.sync) ); const connButtons = div({ class: "form-button-group" }, [ startButton, restartButton, stopButton, syncButton, ]); const peerList = (peers || []) .filter(([, data]) => data.state === "connected") .map(([, data]) => { return li( a( { href: `/author/${encodeURIComponent(data.key)}` }, data.name || data.host || data.key ) ); }); const supportedList = (supporting) var supporting = JSON.parse(fs.readFileSync(supportingPath, "utf8")).value; var arr = []; var keys = Object.keys(supporting); var data = Object.entries(supporting[keys[0]]); Object.entries(data).forEach(([key, value]) => { if (value[1]===1){ var supported = (value[0]) if (!arr.includes(supported)) { arr.push( li( a( { href: `/author/${encodeURIComponent(supported)}` }, supported ) ) ); } } }); var supports = arr; const blockedList = (supporting) var supporting = JSON.parse(fs.readFileSync(supportingPath, "utf8")).value; var arr = []; var keys = Object.keys(supporting); var data = Object.entries(supporting[keys[0]]); Object.entries(data).forEach(([key, value]) => { if (value[1]===-1){ var blocked = (value[0]) if (!arr.includes(blocked)) { arr.push( li( a( { href: `/author/${encodeURIComponent(blocked)}` }, blocked ) ) ); } } }); var blocks = arr; const recommendedList = (supporting) var supporting = JSON.parse(fs.readFileSync(supportingPath, "utf8")).value; var arr = []; var keys = Object.keys(supporting); var data = Object.entries(supporting[keys[0]]); Object.entries(data).forEach(([key, value]) => { if (value[1]===-2){ var recommended = (value[0]) if (!arr.includes(recommended)) { arr.push( li( a( { href: `/author/${encodeURIComponent(recommended)}` }, recommended ) ) ); } } }); var recommends = arr; return template( i18n.peers, section( { class: "message" }, h1(i18n.peerConnections), connButtons, h1(i18n.online, " (", peerList.length, ")"), peerList.length > 0 ? ul(peerList) : i18n.noConnections, p(i18n.connectionActionIntro), h1(i18n.supported, " (", supports.length, ")"), supports.length > 0 ? ul(supports): i18n.noSupportedConnections, p(i18n.connectionActionIntro), h1(i18n.recommended, " (", recommends.length, ")"), recommends.length > 0 ? ul(recommends): i18n.noRecommendedConnections, p(i18n.connectionActionIntro), h1(i18n.blocked, " (", blocks.length, ")"), blocks.length > 0 ? ul(blocks): i18n.noBlockedConnections, p(i18n.connectionActionIntro), ) ); }; exports.invitesView = ({ invites }) => { const pubsList = (pub) var pubs = fs.readFileSync(offsetPath, "utf8"); var arr = pubs.split(/[{,}]/); const arr2 = []; const arr3 = []; var host = []; var id = []; for(var i in arr){ arr.push(arr[i]); } for(var i=0; i