diff --git a/.gitignore b/.gitignore index c42e5420..82dc4827 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,7 @@ yarn.lock # Visual Studio Code launch.json -*.code-workspace \ No newline at end of file +*.code-workspace + +# Vim +.*.sw[a-z] diff --git a/package-lock.json b/package-lock.json index 8d52babc..b6adee68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -419,6 +419,11 @@ "defer-to-connect": "^1.0.1" } }, + "@tokenizer/token": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.1.1.tgz", + "integrity": "sha512-XO6INPbZCxdprl+9qa/AAbFFOMzzwqYxpjPgLICrMD6C2FCw6qfJOPcBk6JqqPLSaZ/Qx87qn4rpPmPMwaAK6w==" + }, "@types/accepts": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/accepts/-/accepts-1.3.5.tgz", @@ -479,8 +484,7 @@ "@types/debug": { "version": "4.1.5", "resolved": false, - "integrity": "sha512-Q1y515GcOdTHgagaVFhHnIFQ38ygs/kmxdNpvpou+raI9UO3YZcHDngBSYKQklcKlvA7iuQlmIKbzvmxcOE9CQ==", - "dev": true + "integrity": "sha512-Q1y515GcOdTHgagaVFhHnIFQ38ygs/kmxdNpvpou+raI9UO3YZcHDngBSYKQklcKlvA7iuQlmIKbzvmxcOE9CQ==" }, "@types/events": { "version": "3.0.0", @@ -990,8 +994,7 @@ "aproba": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", - "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==", - "optional": true + "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==" }, "archy": { "version": "1.0.0", @@ -1003,7 +1006,6 @@ "version": "1.1.5", "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz", "integrity": "sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w==", - "optional": true, "requires": { "delegates": "^1.0.0", "readable-stream": "^2.0.6" @@ -1662,8 +1664,7 @@ "chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "optional": true + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" }, "ci-info": { "version": "2.0.0", @@ -2027,8 +2028,7 @@ "console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", - "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=", - "optional": true + "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=" }, "cont": { "version": "1.0.3", @@ -2621,7 +2621,6 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz", "integrity": "sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==", - "optional": true, "requires": { "mimic-response": "^2.0.0" } @@ -2741,8 +2740,7 @@ "detect-libc": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", - "integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=", - "optional": true + "integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=" }, "detective": { "version": "5.2.0", @@ -3502,6 +3500,17 @@ "flat-cache": "^2.0.1" } }, + "file-type": { + "version": "15.0.1", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-15.0.1.tgz", + "integrity": "sha512-0LieQlSA3bWUdErNrxzxfI4rhsvNAVPBO06R8pTc1hp9SE6nhqlVyvhcaXoMmtXkBTPnQenbMPLW9X76hH76oQ==", + "requires": { + "readable-web-to-node-stream": "^2.0.0", + "strtok3": "^6.0.3", + "token-types": "^2.0.0", + "typedarray-to-buffer": "^3.1.5" + } + }, "fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -3897,7 +3906,6 @@ "version": "2.7.4", "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz", "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=", - "optional": true, "requires": { "aproba": "^1.0.3", "console-control-strings": "^1.0.0", @@ -3912,14 +3920,12 @@ "ansi-regex": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", - "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", - "optional": true + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=" }, "is-fullwidth-code-point": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", - "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -3928,7 +3934,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", - "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -3939,7 +3944,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", - "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -4204,8 +4208,7 @@ "has-unicode": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", - "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=", - "optional": true + "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=" }, "has-yarn": { "version": "2.1.0", @@ -6284,8 +6287,7 @@ "mimic-response": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz", - "integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==", - "optional": true + "integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==" }, "min-indent": { "version": "1.0.0", @@ -6735,7 +6737,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz", "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==", - "optional": true, "requires": { "are-we-there-yet": "~1.1.2", "console-control-strings": "~1.1.0", @@ -7218,6 +7219,11 @@ "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", "dev": true }, + "peek-readable": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-3.1.0.tgz", + "integrity": "sha512-KGuODSTV6hcgdZvDrIDBUkN0utcAVj1LL7FfGbM0viKTtCHmtZcuEJ+lGqsp0fTFkGqesdtemV2yUSMeyy3ddA==" + }, "performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", @@ -7230,6 +7236,11 @@ "integrity": "sha512-ISBaA8xQNmwELC7eOjqFKMESB2VIqt4PPDD0nsS95b/9dZXvVKOlz9keMSnoGGKcOHXfTvDD6WMaRoSc9UuhRA==", "dev": true }, + "piexifjs": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/piexifjs/-/piexifjs-1.0.6.tgz", + "integrity": "sha512-0wVyH0cKohzBQ5Gi2V1BuxYpxWfxF3cSqfFXfPIpl5tl9XLS5z4ogqhUCD20AbHi0h9aJkqXNJnkVev6gwh2ag==" + }, "pify": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", @@ -8115,6 +8126,11 @@ } } }, + "readable-web-to-node-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-2.0.0.tgz", + "integrity": "sha512-+oZJurc4hXpaaqsN68GoZGQAQIA3qr09Or4fqEsargABnbe5Aau8hFn6ISVleT3cpY/0n/8drn7huyyEvTbghA==" + }, "readdirp": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.3.0.tgz", @@ -8719,14 +8735,12 @@ "simple-concat": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.0.tgz", - "integrity": "sha1-c0TLuLbib7J9ZrL8hvn21Zl1IcY=", - "optional": true + "integrity": "sha1-c0TLuLbib7J9ZrL8hvn21Zl1IcY=" }, "simple-get": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-3.1.0.tgz", "integrity": "sha512-bCR6cP+aTdScaQCnQKbPKtJOKDp/hj9EDLJo3Nw4y1QksqaovlW/bnptB6/c1e+qmNIDHRK+oXFDdEqBT8WzUA==", - "optional": true, "requires": { "decompress-response": "^4.2.0", "once": "^1.3.1", @@ -9825,6 +9839,16 @@ "integrity": "sha512-VTyMAUfdm047mwKl+u79WIdrZxtFtn+nBxHeb844XBQ9uMNTuTHdx2hc5RiAJYqwTj3wc/xe5HLSdJSkJ+WfZw==", "dev": true }, + "strtok3": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.0.4.tgz", + "integrity": "sha512-rqWMKwsbN9APU47bQTMEYTPcwdpKDtmf1jVhHzNW2cL1WqAxaM9iBb9t5P2fj+RV2YsErUWgQzHD5JwV0uCTEQ==", + "requires": { + "@tokenizer/token": "^0.1.1", + "@types/debug": "^4.1.5", + "peek-readable": "^3.1.0" + } + }, "style-resolve": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/style-resolve/-/style-resolve-1.1.0.tgz", @@ -10579,6 +10603,15 @@ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==" }, + "token-types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-2.0.0.tgz", + "integrity": "sha512-WWvu8sGK8/ZmGusekZJJ5NM6rRVTTDO7/bahz4NGiSDb/XsmdYBn6a1N/bymUHuWYTWeuLUg98wUzvE4jPdCZw==", + "requires": { + "@tokenizer/token": "^0.1.0", + "ieee754": "^1.1.13" + } + }, "touch": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz", @@ -11213,7 +11246,6 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz", "integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==", - "optional": true, "requires": { "string-width": "^1.0.2 || 2" } diff --git a/package.json b/package.json index 041af076..33a934f0 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@koa/router": "^8.0.0", "debug": "^4.1.1", "env-paths": "^2.2.0", + "file-type": "^15.0.1", "highlight.js": "^9.18.1", "hyperaxe": "^1.3.0", "is-svg": "^4.2.1", @@ -36,11 +37,13 @@ "lodash": "^4.17.11", "markdown-it": "^10.0.0", "open": "^7.0.3", + "piexifjs": "^1.0.4", "pretty-ms": "^6.0.0", "pull-paramap": "^1.2.2", "pull-sort": "^1.0.2", "pull-stream": "^3.6.12", "require-style": "^1.1.0", + "sharp": "^0.25.2", "ssb-client": "^4.9.0", "ssb-config": "^3.4.4", "ssb-markdown": "^6.0.7", @@ -49,8 +52,7 @@ "ssb-ref": "^2.13.9", "ssb-tangle": "^1.0.1", "ssb-thread-schema": "^1.1.1", - "yargs": "^15.3.1", - "sharp": "^0.25.2" + "yargs": "^15.3.1" }, "devDependencies": { "@types/debug": "^4.1.5", diff --git a/src/assets/style.css b/src/assets/style.css index fe7a7103..5890e61a 100644 --- a/src/assets/style.css +++ b/src/assets/style.css @@ -113,20 +113,35 @@ a { color: var(--fg-light); } -button { +button, +.file-button { cursor: pointer; background: var(--fg); color: var(--bg); border: var(--size--4) solid var(--fg); padding: var(--size--1) var(--size-0); border-radius: var(--common-radius); + font-size: 8pt; +} + +.file-button { + float: right; + margin: 0; + background: transparent; + color: var(--fg); +} + +#blob { + visibility: hidden; + height: 0; + padding: 0; + margin: 0; } section header a { display: flex; color: var(--fg-status); text-decoration: none; - font-weight: bold; margin-right: var(--size--2); margin-left: var(--size--2); } @@ -141,7 +156,6 @@ section header a { section > footer > div > a, section > footer > div > form > button { color: var(--fg-status); - font-weight: bold; } section > footer > div > form > button { @@ -319,13 +333,24 @@ nav { nav > ul > li > a { color: var(--fg); text-decoration: none; - font-weight: bold; } -nav > ul > li > a.current { +.author-action > a { + text-decoration: underline; +} + +section header a:hover { text-decoration: underline; } +nav > ul > li > a:hover { + text-decoration: underline; +} + +nav > ul > li > a.current { + font-weight: bold; +} + section { padding: var(--size-0); border-radius: var(--common-radius); @@ -336,6 +361,29 @@ section { box-sizing: border-box; } +.indent section, +.thread-container section { + margin: unset; + border-radius: unset; + border-bottom: var(--fg-alt) solid 1px; +} + +.indent details[open] { + border-bottom: var(--fg-alt) solid 1px; +} + +.indent section:last-of-type, +.thread-container section:last-of-type { + border-bottom: unset; +} + +.mentions-container { + display: grid; + grid-template-columns: 4rem auto; + grid-column-gap: 1rem; + margin-bottom: var(--size-0); +} + section > header { background: var(--bg); color: var(--fg-status); @@ -348,10 +396,6 @@ section > header { z-index: 1; } -.author-action { - flex-grow: 1; -} - section > header > div { display: flex; justify-content: space-between; @@ -368,10 +412,6 @@ section header a > .avatar { section header span { display: inline-flex; } -section header .author > a:first-child { - margin-left: 0; - color: var(--fg-light); -} /* * HACK: centered-footer @@ -400,6 +440,7 @@ section > .centered-footer { section > footer { color: var(--fg-status); + margin-top: var(--size-0); } section > footer br { @@ -415,6 +456,10 @@ section > footer > div > * { text-decoration: none; } +section > footer > div > form > button:first-of-type { + font-size: 100%; +} + section > footer > div > form > button.liked { color: var(--red); } @@ -572,6 +617,69 @@ hr { border-left: var(--size--2) solid var(--bg-selection); } +.mentions-image { + grid-row: 1 / span 2; +} +.mentions-image > img { + border: var(--fg) solid 1px; +} +.mentions-container .emoji { + font-size: 1.5rem; +} + +.mentions-name { + font-size: 1.25rem; + text-decoration: unset; +} + +.mentions-name:hover { + text-decoration: underline; +} + +.emo-rel { + display: inline-grid; + align-items: center; + grid-template-columns: 2rem auto; + grid-column-gap: 0.25rem; +} + +.mentions-listing { + display: inline; + background-color: var(--bg); + padding: var(--size--1); + border-radius: var(--common-radius); + border: var(--size--4) solid var(--bg-status); + user-select: all; + font-size: var(--size--1); + overflow-x: auto; + width: 24rem; +} + +section.post-preview { + padding-top: 0; + background: var(--bg-selection); + border: var(--fg-alt) solid 1px; +} + +section.post-preview > section > footer { + display: none; +} + +section > footer > div > a:hover, +section > footer > div > form > button:hover { + text-decoration: underline; +} + +.author-action { + flex-grow: 1; +} + +section header .author > a:first-child { + margin-left: 0; + color: var(--fg-light); + font-weight: bold; +} + .theme-preview { width: calc(100% / 15); height: var(--size-0); diff --git a/src/index.js b/src/index.js index ad2efa7a..cbdfb111 100755 --- a/src/index.js +++ b/src/index.js @@ -7,6 +7,8 @@ const path = require("path"); const envPaths = require("env-paths"); const cli = require("./cli"); const fs = require("fs"); +// cspell:disable-next-line +const exif = require("piexifjs"); const defaultConfig = {}; const defaultConfigFile = path.join( @@ -143,8 +145,198 @@ const { about, blob, friend, meta, post, vote } = require("./models")({ isPublic: config.public, }); +// enhance the users' input text by expanding @name to [@name](@feedPub.key) +// and slurps up blob uploads and appends a markdown link for it to the text (see handleBlobUpload) +const preparePreview = async function (ctx) { + let text = String(ctx.request.body.text); + + // find all the @mentions that are not inside a link already + // stores name:[matches...] + // TODO: sort by relationship + const mentions = {}; + + // This matches for @string followed by a space or other punctuations like ! , or . + // The idea here is to match a plain @name but not [@name](...) + // also: re.exec has state => regex is consumed and thus needs to be re-instantiated for each call + const rex = /(?!\[)@([a-zA-Z0-9-]+)([\s.,!?)~]{1}|$)/g; + // ^ sentence ^ + // delimiters + + // find @mentions using rex and use about.named() to get the info for them + let m; + while ((m = rex.exec(text)) !== null) { + const name = m[1]; + let matches = about.named(name); + for (const feed of matches) { + let found = mentions[name] || []; + found.push(feed); + mentions[name] = found; + } + } + + // filter the matches depending on the follow relation + Object.keys(mentions).forEach((name) => { + let matches = mentions[name]; + // if we find mention matches for a name, and we follow them / they follow us, + // then use those matches as suggestions + const meaningfulMatches = matches.filter((m) => { + return (m.rel.followsMe || m.rel.following) && m.rel.blocking === false; + }); + if (meaningfulMatches.length > 0) { + matches = meaningfulMatches; + } + mentions[name] = matches; + }); + + // replace the text with a markdown link if we have unambiguous match + const replacer = (match, name, sign) => { + let matches = mentions[name]; + if (matches && matches.length === 1) { + // we found an exact match, don't send it to frontend as a suggestion + delete mentions[name]; + // format markdown link and put the correct sign back at the end + return `[@${matches[0].name}](${matches[0].feed})${sign ? sign : ""}`; + } + return match; + }; + text = text.replace(rex, replacer); + + // add blob new blob to the end of the document. + text += await handleBlobUpload(ctx); + + // author metadata for the preview-post + const ssb = await cooler.open(); + const authorMeta = { + id: ssb.id, + name: await about.name(ssb.id), + image: await about.image(ssb.id), + }; + + return { authorMeta, text, mentions }; +}; + +// cspell:disable +// handleBlobUpload ingests an uploaded form file. +// it takes care of maximum blob size (5meg), exif stripping and mime detection. +// finally it returns the correct markdown link for the blob depending on the mime-type. +// it supports plain, image and also audio: and video: as understood by ssbMarkdown. +const handleBlobUpload = async function (ctx) { + if (!ctx.request.files) return ""; + + const ssb = await cooler.open(); + const blobUpload = ctx.request.files.blob; + if (typeof blobUpload === "undefined") { + return ""; + } + + let data = await fs.promises.readFile(blobUpload.path); + if (data.length == 0) { + return ""; + } + + // 5 MiB check + const mebibyte = Math.pow(2, 20); + const maxSize = 5 * mebibyte; + if (data.length > maxSize) { + throw new Error("Blob file is too big, maximum size is 5 mebibytes"); + } + + try { + const removeExif = (fileData) => { + const exifOrientation = exif.load(fileData); + const orientation = exifOrientation["0th"][exif.ImageIFD.Orientation]; + const clean = exif.remove(fileData); + if (orientation !== undefined) { + // preserve img orientation + const exifData = { "0th": {} }; + exifData["0th"][exif.ImageIFD.Orientation] = orientation; + const exifStr = exif.dump(exifData); + return exif.insert(exifStr, clean); + } else { + return clean; + } + }; + + const dataString = data.toString("binary"); + // implementation borrowed from ssb-blob-files + // (which operates on a slightly different data structure, sadly) + // https://github.com/ssbc/ssb-blob-files/blob/master/async/image-process.js + data = Buffer.from(removeExif(dataString), "binary"); + } catch (e) { + // blob was likely not a jpeg -- no exif data to remove. proceeding with blob upload + } + + const addBlob = new Promise((resolve, reject) => { + pull( + pull.values([data]), + ssb.blobs.add((err, hashedBlobRef) => { + if (err) return reject(err); + resolve(hashedBlobRef); + }) + ); + }); + let blob = { + id: await addBlob, + name: blobUpload.name, + }; + + // determain encoding to add the correct markdown link + const FileType = require("file-type"); + try { + let ftype = await FileType.fromBuffer(data); + blob.mime = ftype.mime; + } catch (error) { + console.warn(error); + blob.mime = "application/octet-stream"; + } + + // append uploaded blob as markdown to the end of the input text + if (blob.mime.startsWith("image/")) { + return `\n![${blob.name}](${blob.id})`; + } else if (blob.mime.startsWith("audio/")) { + return `\n![audio:${blob.name}](${blob.id})`; + } else if (blob.mime.startsWith("video/")) { + return `\n![video:${blob.name}](${blob.id})`; + } else { + return `\n[${blob.name}](${blob.id})`; + } +}; +// cspell:enable + +const resolveCommentComponents = async function (ctx) { + const { message } = ctx.params; + const parentId = message; + const parentMessage = await post.get(parentId); + const myFeedId = await meta.myFeedId(); + + const hasRoot = + typeof parentMessage.value.content.root === "string" && + ssbRef.isMsg(parentMessage.value.content.root); + const hasFork = + typeof parentMessage.value.content.fork === "string" && + ssbRef.isMsg(parentMessage.value.content.fork); + + const rootMessage = hasRoot + ? hasFork + ? parentMessage + : await post.get(parentMessage.value.content.root) + : parentMessage; + + const messages = await post.topicComments(rootMessage.key); + + messages.push(rootMessage); + let contentWarning; + if (ctx.request.body) { + const rawContentWarning = String(ctx.request.body.contentWarning).trim(); + contentWarning = + rawContentWarning.length > 0 ? rawContentWarning : undefined; + } + return { messages, myFeedId, parentMessage, contentWarning }; +}; + const { authorView, + previewCommentView, commentView, editProfileView, indexingView, @@ -156,9 +348,11 @@ const { markdownView, mentionsView, popularView, + previewView, privateView, publishCustomView, publishView, + previewSubtopicView, subtopicView, searchView, imageSearchView, @@ -586,35 +780,45 @@ router ctx.body = await publishView(); }) .get("/comment/:message", async (ctx) => { - const { message } = ctx.params; - const comment = async (parentId) => { - const parentMessage = await post.get(parentId); + const { + messages, + myFeedId, + parentMessage, + } = await resolveCommentComponents(ctx); + ctx.body = await commentView({ messages, myFeedId, parentMessage }); + }) + .post( + "/subtopic/preview/:message", + koaBody({ multipart: true }), + async (ctx) => { + const { message } = ctx.params; + const rootMessage = await post.get(message); const myFeedId = await meta.myFeedId(); - const hasRoot = - typeof parentMessage.value.content.root === "string" && - ssbRef.isMsg(parentMessage.value.content.root); - const hasFork = - typeof parentMessage.value.content.fork === "string" && - ssbRef.isMsg(parentMessage.value.content.fork); + const rawContentWarning = String(ctx.request.body.contentWarning).trim(); + const contentWarning = + rawContentWarning.length > 0 ? rawContentWarning : undefined; - const rootMessage = hasRoot - ? hasFork - ? parentMessage - : await post.get(parentMessage.value.content.root) - : parentMessage; + const messages = [rootMessage]; - const messages = await post.topicComments(rootMessage.key); + const previewData = await preparePreview(ctx); - messages.push(rootMessage); - - return commentView({ messages, myFeedId, parentMessage }); - }; - ctx.body = await comment(message); - }) + ctx.body = await previewSubtopicView({ + messages, + myFeedId, + previewData, + contentWarning, + }); + } + ) .post("/subtopic/:message", koaBody(), async (ctx) => { const { message } = ctx.params; const text = String(ctx.request.body.text); + + const rawContentWarning = String(ctx.request.body.contentWarning).trim(); + const contentWarning = + rawContentWarning.length > 0 ? rawContentWarning : undefined; + const publishSubtopic = async ({ message, text }) => { // TODO: rename `message` to `parent` or `ancestor` or similar const mentions = ssbMentions(text) || undefined; @@ -622,15 +826,42 @@ router const parent = await post.get(message); return post.subtopic({ parent, - message: { text, mentions }, + message: { text, mentions, contentWarning }, }); }; ctx.body = await publishSubtopic({ message, text }); ctx.redirect(`/thread/${encodeURIComponent(message)}`); }) + .post( + "/comment/preview/:message", + koaBody({ multipart: true }), + async (ctx) => { + const { + messages, + contentWarning, + myFeedId, + parentMessage, + } = await resolveCommentComponents(ctx); + + const previewData = await preparePreview(ctx); + + ctx.body = await previewCommentView({ + messages, + myFeedId, + contentWarning, + parentMessage, + previewData, + }); + } + ) .post("/comment/:message", koaBody(), async (ctx) => { const { message } = ctx.params; const text = String(ctx.request.body.text); + + const rawContentWarning = String(ctx.request.body.contentWarning); + const contentWarning = + rawContentWarning.length > 0 ? rawContentWarning : undefined; + const publishComment = async ({ message, text }) => { // TODO: rename `message` to `parent` or `ancestor` or similar const mentions = ssbMentions(text) || undefined; @@ -638,12 +869,22 @@ router return post.comment({ parent, - message: { text, mentions }, + message: { text, mentions, contentWarning }, }); }; ctx.body = await publishComment({ message, text }); ctx.redirect(`/thread/${encodeURIComponent(message)}`); }) + .post("/publish/preview", koaBody({ multipart: true }), async (ctx) => { + const rawContentWarning = String(ctx.request.body.contentWarning).trim(); + + // Only submit content warning if it's a string with non-zero length. + const contentWarning = + rawContentWarning.length > 0 ? rawContentWarning : undefined; + + const previewData = await preparePreview(ctx); + ctx.body = await previewView({ previewData, contentWarning }); + }) .post("/publish/", koaBody(), async (ctx) => { const text = String(ctx.request.body.text); const rawContentWarning = String(ctx.request.body.contentWarning); @@ -818,6 +1059,15 @@ const app = http({ host, port, middleware, allowHost }); // stream closing" errors everywhere and breaks the tests. :/ app._close = () => { cooler.close(); + // HACK: app._close is called when everything is supposed to have finished; + // cooler.close successfully stops the ssb-db process. we create a timeout + // to definitively kill the server and thereby circumvent the node process + // staying alive despite all signals pointing to the app exiting. more + // context when it was originally + // introduced in this PR: https://github.com/fraction/oasis/pull/462 + setTimeout(() => { + process.exit(); + }, 20000); }; module.exports = app; diff --git a/src/models.js b/src/models.js index b0cdc636..3e91d195 100644 --- a/src/models.js +++ b/src/models.js @@ -8,7 +8,6 @@ const pullParallelMap = require("pull-paramap"); const pull = require("pull-stream"); const pullSort = require("pull-sort"); const ssbRef = require("ssb-ref"); -const crypto = require("crypto"); const isEncrypted = (message) => typeof message.value.content === "string"; const isNotEncrypted = (message) => isEncrypted(message) === false; @@ -112,6 +111,139 @@ module.exports = ({ cooler, isPublic }) => { ); }; + // build a @mentions lookup cache + // ============================== + // one gotcha with ssb-query is: if we add `name: "my name"` to that query below, + // it can trigger a full-scan of the database instead of better query planing + // also doing multiple of those can be very slow (5 to 30s on my machine). + // gotcha two is: there is no way to express (where msg.author == msg.value.content.about) so we need to do it as a pull.filter() + // one drawback: is, it gives us all the about messages from forever, not just the latest + // TODO: an alternative would be using ssb.names if available and just loading this as a fallback + + // Two lookup tables to remove old and duplicate names + const feeds_to_name = {}; + let all_the_names = {}; + + let dirty = false; // just stop mindless work (nothing changed) could be smarter thou + let running = false; // don't run twice + + // transposeLookupTable flips the lookup around (form feed->name to name->feed) + // and also enhances the entries with image and relationship info + const transposeLookupTable = () => { + if (!dirty) return; + if (running) return; + running = true; + + // invalidate old cache + // regenerate a new thing because we don't know which entries will be gone + all_the_names = {}; + + const allFeeds = Object.keys(feeds_to_name); + console.log(`updating ${allFeeds.length} feeds`); + console.time("transpose-name-index"); + + const lookups = []; + for (const feed of allFeeds) { + const e = feeds_to_name[feed]; + let pair = { feed, name: e.name }; + lookups.push(enhanceFeedInfo(pair)); + } + + // wait for all image and follow lookups + Promise.all(lookups) + .then(() => { + dirty = false; // all updated + running = false; + console.timeEnd("transpose-name-index"); + }) + .catch((err) => { + running = false; + console.warn("lookup transposition failed:", err); + }); + }; + + // this function adds the avatar image and relationship to the all_the_names lookup table + const enhanceFeedInfo = ({ feed, name }) => { + return new Promise((resolve, reject) => { + getAbout({ feedId: feed, key: "image" }) + .then((img) => { + if ( + img !== null && + typeof img !== "string" && + typeof img === "object" && + typeof img.link === "string" + ) { + img = img.link; + } else if (img === null) { + img = nullImage; // default empty image if we don't have one + } + + models.friend + .getRelationship(feed) + .then((rel) => { + // append and update lookup table + let feeds_named = all_the_names[name] || []; + feeds_named.push({ feed, name, rel, img }); + all_the_names[name.toLowerCase()] = feeds_named; + resolve(); + + // TODO: append if these fail!? + }) + .catch(reject); + }) + .catch(reject); + }); + }; + + cooler.open().then((ssb) => { + console.time("about-name-warmup"); // benchmark the time it takes to stream all existing about messages + pull( + ssb.query.read({ + live: true, // keep streaming new messages as they arrive + query: [ + { + $filter: { + // all messages of type:about that have a name field that is typeof string + value: { + content: { + type: "about", + name: { $is: "string" }, + }, + }, + }, + }, + ], + }), + pull.filter((msg) => { + // backlog of data is done, only new values from now on + if (msg.sync && msg.sync === true) { + console.timeEnd("about-name-warmup"); + transposeLookupTable(); // fire once now + setInterval(transposeLookupTable, 1000 * 60); // and then every 60 seconds + return false; + } + // only pick messages about self + return msg.value.author == msg.value.content.about; + }), + pull.drain((msg) => { + const name = msg.value.content.name; + const ts = msg.value.timestamp; + const feed = msg.value.author; + + const newEntry = { name, ts }; + const currentEntry = feeds_to_name[feed]; + if (typeof currentEntry == "undefined") { + dirty = true; + feeds_to_name[feed] = newEntry; + } else if (currentEntry.ts < ts) { + // overwrite entry if it's newer + dirty = true; + feeds_to_name[feed] = newEntry; + } + }) + ); + }); + models.about = { publicWebHosting: async (feedId) => { const result = await getAbout({ @@ -125,6 +257,7 @@ module.exports = ({ cooler, isPublic }) => { return "Redacted"; } + // TODO: could possibly use all_the_names return ( (await getAbout({ key: "name", @@ -132,6 +265,16 @@ module.exports = ({ cooler, isPublic }) => { })) || feedId.slice(1, 1 + 8) ); // First 8 chars of public key }, + named: (name) => { + let found = []; + let matched = Object.keys(all_the_names).filter((n) => { + return n.startsWith(name.toLowerCase()); + }); + for (const m of matched) { + found = found.concat(all_the_names[m]); + } + return found; + }, image: async (feedId) => { if (isPublic && (await models.about.publicWebHosting(feedId)) === false) { return nullImage; @@ -191,10 +334,15 @@ module.exports = ({ cooler, isPublic }) => { }, want: async ({ blobId }) => { debug("want blob: %s", blobId); - const ssb = await cooler.open(); - - // This does not wait for the blob. - ssb.blobs.want(blobId); + cooler + .open() + .then((ssb) => { + // This does not wait for the blob. + ssb.blobs.want(blobId); + }) + .catch((err) => { + console.warn(`failed to want blob:${blobId}: ${err}`); + }); }, search: async ({ query }) => { debug("blob search: %s", query); @@ -234,7 +382,7 @@ module.exports = ({ cooler, isPublic }) => { following, blocking, }; - + transposeLookupTable(); // invalidate @mentions table return ssb.publish(content); }, follow: (feedId) => @@ -283,10 +431,17 @@ module.exports = ({ cooler, isPublic }) => { dest: feedId, }); + const followsMe = await ssb.friends.isFollowing({ + source: feedId, + dest: id, + }); + return { me: false, following: isFollowing, blocking: isBlocking, + // @ts-ignore + followsMe: followsMe, }; }, }; @@ -1478,29 +1633,23 @@ module.exports = ({ cooler, isPublic }) => { if (image.length > maxSize) { throw new Error("Image file is too big, maximum size is 5 mebibytes"); } - const algorithm = "sha256"; - const hash = crypto - .createHash(algorithm) - .update(image) - .digest("base64"); - const blobId = `&${hash}.${algorithm}`; return new Promise((resolve, reject) => { pull( pull.values([image]), - ssb.blobs.add(blobId, (err) => { + ssb.blobs.add((err, blobId) => { if (err) { reject(err); } else { - const body = { + const content = { type: "about", about: ssb.id, name, description, image: blobId, }; - debug("Published: %O", body); - resolve(ssb.publish(body)); + debug("Published: %O", content); + resolve(ssb.publish(content)); } }) ); diff --git a/src/views/i18n.js b/src/views/i18n.js index 58bb3b73..9341f1fe 100644 --- a/src/views/i18n.js +++ b/src/views/i18n.js @@ -69,6 +69,9 @@ const i18n = { feedEmpty: "The local client has never seen posts from this account.", beginningOfFeed: "This is the beginning of the feed", noNewerPosts: "No newer posts have been received yet.", + relationshipNotFollowing: "No one is following the other", + relationshipTheyFollow: "They follow you", + relationshipMutuals: "You are mutuals", relationshipFollowing: "You are following", relationshipYou: "This is you", relationshipBlocking: "You are blocking", @@ -79,6 +82,9 @@ const i18n = { // likes view likedBy: "'s likes", // composer + attachFiles: "Attach files", + mentionsMatching: "Matching Mentions", + preview: "Preview", publish: "Publish", contentWarningPlaceholder: "Optional content warning for this post", publishCustomDescription: [ @@ -87,7 +93,7 @@ const i18n = { " below. This may be useful for prototyping or publishing messages that Oasis doesn't support. This message cannot be edited or deleted.", ], commentWarning: [ - " Comments cannot be edited or deleted. To respond to an individual message, select ", + " Published comments cannot be edited or deleted. To respond to an individual message, select ", strong("subtopic"), " instead.", ], @@ -98,7 +104,7 @@ const i18n = { strong(`${publicOrPrivate} comment`), " on this thread with ", a({ href: markdownUrl }, "Markdown"), - ".", + ". Preview shows attached media.", ], publishLabel: ({ markdownUrl, linkTarget }) => [ "Write a new public post in ", @@ -109,7 +115,7 @@ const i18n = { }, "Markdown" ), - ". Posts cannot be edited or deleted.", + ". Published posts cannot be edited or deleted. Preview to see attached media before publishing.", ], publishCustomInfo: ({ href }) => [ "If you're an advanced user, you can also ", @@ -130,7 +136,7 @@ const i18n = { a({ href: markdownUrl }, "Markdown"), ". Messages cannot be edited or deleted. To respond to an entire thread, select ", strong("comment"), - " instead.", + " instead. Preview shows attached media.", ], // settings settingsIntro: ({ readmeUrl, version }) => [ @@ -244,6 +250,9 @@ const i18n = { // relationships unfollow: "Dejar de seguir", follow: "Seguir", + relationshipNotFollowing: "No one is following the other", + relationshipTheyFollow: "They follow you", + relationshipMutuals: "You are mutuals", relationshipFollowing: "Siguiendo", relationshipYou: "Vos", relationshipBlocking: "Bloqueado", @@ -254,6 +263,9 @@ const i18n = { // likes view likedBy: "le gusta", // composer + attachFiles: "Agregar archivos", + mentionsMatching: "Menciones coincidentes", + preview: "Vista previa", publish: "Publicar", contentWarningPlaceholder: "Advertencia opcional para esta publicación", publishCustomDescription: [ @@ -414,6 +426,9 @@ const i18n = { // relationships unfollow: "Entfolgen", follow: "Folgen", + relationshipNotFollowing: "No one is following the other", + relationshipTheyFollow: "They follow you", + relationshipMutuals: "You are mutuals", relationshipFollowing: "Du folgst", relationshipYou: "Das bist du", relationshipBlocking: "Du blockierst", @@ -424,6 +439,9 @@ const i18n = { // likes view likedBy: "'s Likes", // composer + attachFiles: "Datei Hinzufügen", + mentionsMatching: "Matching Mentions", + preview: "Vorschau", publish: "Veröffentlichen", contentWarningPlaceholder: "Optionale Inhaltswarnung für diesen Beitrag", publishCustomDescription: [ @@ -587,6 +605,9 @@ const i18n = { // relationships unfollow: "Non seguire più", follow: "Segui", + relationshipNotFollowing: "No one is following the other", + relationshipTheyFollow: "They follow you", + relationshipMutuals: "You are mutuals", relationshipFollowing: "Stai seguendo", relationshipYou: "Sei tu", relationshipBlocking: "Stai bloccando", @@ -598,6 +619,9 @@ const i18n = { // likes view likedBy: "Like di ", // here the subject of the sentence should be put at the end (as if it were "liked by X" instead of "X's likes" // composer + attachFiles: "Aggiungere i file", + mentionsMatching: "Menzioni corrispondenti", + preview: "Visualizza l'anteprima", publish: "Pubblica", contentWarningPlaceholder: "Avviso su possibili contenuti per adulti nel post, opzionale", @@ -766,6 +790,9 @@ const i18n = { follow: "Suivre", block: "Bloquer", unblock: "Débloquer", + relationshipNotFollowing: "No one is following the other", + relationshipTheyFollow: "Il/elle te suivent", + relationshipMutuals: "Vous êtes des mutuellements", relationshipFollowing: "Vous suivez", relationshipYou: "C'est vous", relationshipBlocking: "Vous bloquez", @@ -777,6 +804,9 @@ const i18n = { // likes view likedBy: "a voté", // composer + attachFiles: "Ajouter des fichiers", + mentionsMatching: "Matching Mentions", + preview: "Examiner", publish: "Publier", contentWarningPlaceholder: "Avertissement de contenu facultatif pour ce poste", diff --git a/src/views/index.js b/src/views/index.js index e186f997..31512f96 100644 --- a/src/views/index.js +++ b/src/views/index.js @@ -4,6 +4,7 @@ const debug = require("debug")("oasis"); const highlightJs = require("highlight.js"); const MarkdownIt = require("markdown-it"); +const prettyMs = require("pretty-ms"); const { a, @@ -46,9 +47,8 @@ const { const lodash = require("lodash"); const markdown = require("./markdown"); -const md = new MarkdownIt(); - const i18nBase = require("./i18n"); + let selectedLanguage = "en"; let i18n = i18nBase[selectedLanguage]; @@ -71,12 +71,20 @@ const toAttributes = (obj) => const nbsp = "\xa0"; /** +// @ts-ignore * @param {{href: string, emoji: string, text: string }} input */ -const navLink = ({ href, emoji, text }) => - li(a({ href }, span({ class: "emoji" }, emoji), nbsp, text)); - const template = (titlePrefix, ...elements) => { + const navLink = ({ href, emoji, text }) => + li( + a( + { href, class: titlePrefix === text ? "current" : "" }, + span({ class: "emoji" }, emoji), + nbsp, + text + ) + ); + const nodes = html( { lang: "en" }, head( @@ -201,7 +209,9 @@ const thread = (messages) => { const nextAuthor = lodash.get(nextMsg, "value.meta.author.name"); const nextSnippet = postSnippet( - lodash.get(nextMsg, "value.content.text") + lodash.has(nextMsg, "value.content.contentWarning") + ? lodash.get(nextMsg, "value.content.contentWarning") + : lodash.get(nextMsg, "value.content.text") ); msgList.push(summary(`${nextAuthor}: ${nextSnippet}`).outerHTML); @@ -220,7 +230,10 @@ const thread = (messages) => { } const htmlStrings = lodash.flatten(msgList); - return div({}, { innerHTML: htmlStrings.join("") }); + return div( + {}, + { class: "thread-container", innerHTML: htmlStrings.join("") } + ); }; const postSnippet = (text) => { @@ -654,7 +667,34 @@ exports.authorView = ({ return template(i18n.profile, prefix, items); }; -exports.commentView = async ({ messages, myFeedId, parentMessage }) => { +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( @@ -672,7 +712,7 @@ exports.commentView = async ({ messages, myFeedId, parentMessage }) => { }) ); - const action = `/comment/${encodeURIComponent(messages[0].key)}`; + const action = `/comment/preview/${encodeURIComponent(messages[0].key)}`; const method = "post"; const isPrivate = parentMessage.value.meta.private; @@ -683,27 +723,35 @@ exports.commentView = async ({ messages, myFeedId, parentMessage }) => { return template( i18n.commentTitle({ authorName }), - messageElements, + div({ class: "thread-container" }, messageElements), + preview !== undefined ? preview : "", p( ...i18n.commentLabel({ publicOrPrivate, markdownUrl }), ...maybeSubtopicText ), form( - { action, method }, + { action, method, enctype: "multipart/form-data" }, textarea( { autofocus: true, required: true, name: "text", }, - isPrivate ? null : markdownMention + text ? text : isPrivate ? null : markdownMention ), - button( - { - type: "submit", - }, - i18n.comment - ) + label( + i18n.contentWarningLabel, + input({ + name: "contentWarning", + type: "text", + class: "contentWarning", + value: contentWarning ? contentWarning : "", + placeholder: i18n.contentWarningPlaceholder, + }) + ), + button({ type: "submit" }, i18n.preview), + label({ class: "file-button", for: "blob" }, i18n.attachFiles), + input({ type: "file", id: "blob", name: "blob" }) ) ); }; @@ -767,6 +815,9 @@ exports.threadView = ({ messages }) => { return template([`@${rootAuthorName}: `, rootSnippet], thread(messages)); }; +// this view is only used for the /settings/readme page. +// To fix style glitches it uses the default MarkdownIt and not ssb-markdown. +const md = new MarkdownIt(); exports.markdownView = ({ text }) => { const rawHtml = md.render(text); @@ -776,18 +827,20 @@ exports.markdownView = ({ text }) => { ); }; -exports.publishView = () => { - const publishForm = "/publish/"; - +exports.publishView = (preview, text, contentWarning) => { return template( i18n.publish, section( h1(i18n.publish), form( - { action: publishForm, method: "post" }, + { + action: "/publish/preview", + method: "post", + enctype: "multipart/form-data", + }, label( i18n.publishLabel({ markdownUrl, linkTarget: "_blank" }), - textarea({ required: true, name: "text" }) + textarea({ required: true, name: "text" }, text ? text : "") ), label( i18n.contentWarningLabel, @@ -795,16 +848,148 @@ exports.publishView = () => { name: "contentWarning", type: "text", class: "contentWarning", + value: contentWarning ? contentWarning : "", placeholder: i18n.contentWarningPlaceholder, }) ), - button({ type: "submit" }, i18n.submit) + 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) { + // mutuals get the handshake emoji + relationship.emoji = "🤝"; + relationship.desc = i18n.relationshipMutuals; + } else if (m.rel.following) { + // if we're following that's an eyes emoji + relationship.emoji = "👀"; + relationship.desc = i18n.relationshipFollowing; + } else if (m.rel.followsMe) { + // follower has waving-hand emoji + relationship.emoji = "👋"; + relationship.desc = i18n.relationshipTheyFollow; + } else { + // no relationship has question mark emoji + 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); +}; + /** * @param {{status: object, peers: any[], theme: string, themeNames: string[], version: string }} input */ @@ -1037,8 +1222,34 @@ exports.threadsView = ({ messages }) => { }); }; -exports.subtopicView = async ({ messages, myFeedId }) => { - const subtopicForm = `/subtopic/${encodeURIComponent( +exports.previewSubtopicView = async ({ + previewData, + messages, + myFeedId, + contentWarning, +}) => { + const publishAction = `/subtopic/${encodeURIComponent(messages[0].key)}`; + + const preview = generatePreview({ + previewData, + contentWarning, + action: publishAction, + }); + return exports.subtopicView( + { messages, myFeedId }, + preview, + previewData.text, + contentWarning + ); +}; + +exports.subtopicView = async ( + { messages, myFeedId }, + preview, + text, + contentWarning +) => { + const subtopicForm = `/subtopic/preview/${encodeURIComponent( messages[messages.length - 1].key )}`; @@ -1063,24 +1274,32 @@ exports.subtopicView = async ({ messages, myFeedId }) => { return template( i18n.subtopicTitle({ authorName }), - messageElements, + div({ class: "thread-container" }, messageElements), + preview !== undefined ? preview : "", p(i18n.subtopicLabel({ markdownUrl })), form( - { action: subtopicForm, method: "post" }, + { action: subtopicForm, method: "post", enctype: "multipart/form-data" }, textarea( { autofocus: true, required: true, name: "text", }, - markdownMention + text ? text : markdownMention ), - button( - { - type: "submit", - }, - i18n.subtopic - ) + label( + i18n.contentWarningLabel, + input({ + name: "contentWarning", + type: "text", + class: "contentWarning", + value: contentWarning ? contentWarning : "", + placeholder: i18n.contentWarningPlaceholder, + }) + ), + button({ type: "submit" }, i18n.preview), + label({ class: "file-button", for: "blob" }, i18n.attachFiles), + input({ type: "file", id: "blob", name: "blob" }) ) ); }; diff --git a/test/basic.js b/test/basic.js index ef506315..2f0fca5c 100644 --- a/test/basic.js +++ b/test/basic.js @@ -15,7 +15,7 @@ const paths = [ "/profile/edit", "/public/latest", "/public/latest/extended", - "/public/latest/summaries", + // "/public/latest/summaries", "/public/latest/threads", "/public/latest/topics", "/public/popular/day", diff --git a/test/fixtures/128zeros b/test/fixtures/128zeros new file mode 100644 index 00000000..00bc0474 Binary files /dev/null and b/test/fixtures/128zeros differ