Experiment with common-good module

This commit is contained in:
Christian Bundy 2020-01-21 16:22:19 -08:00
parent cb4a6ef971
commit b34b04c2c2
22 changed files with 2133 additions and 3641 deletions

16
.eslintrc.js Normal file
View File

@ -0,0 +1,16 @@
module.exports = {
env: {
commonjs: true,
es6: true,
node: true
},
extends: "eslint:recommended",
globals: {
Atomics: "readonly",
SharedArrayBuffer: "readonly"
},
parserOptions: {
ecmaVersion: 2018
},
rules: {}
};

View File

@ -1,4 +1,3 @@
**What's the problem you want to solved?**
**Is there a solution you'd like to recommend?**

View File

@ -1,4 +1,3 @@
**What's the problem you solved?**
**What solution are you recommending?**

View File

@ -1,3 +1,3 @@
{
"extends": "stylelint-config-standard"
"extends": "stylelint-config-recommended"
}

View File

@ -2,7 +2,7 @@
language: node_js
# Both latest Node.js and latest LTS.
node_js:
node_js:
- lts/*
- node

View File

@ -21,7 +21,7 @@ Options:
[boolean] [default: false]
--host Hostname for web app to listen on [string] [default: "localhost"]
--port Port for web app to listen on [number] [default: 3000]
--debug Use verbose output for debugging [boolean] [default: false]
--debug Use verbose output for debugging [boolean] [default: false]
```
## Installation

View File

@ -1,36 +1,36 @@
const fs = require('fs')
const path = require('path')
const mkdirp = require('mkdirp')
const { execSync } = require('child_process')
const open = require('open')
const fs = require("fs");
const path = require("path");
const mkdirp = require("mkdirp");
const { execSync } = require("child_process");
const open = require("open");
let xdgConfigHome = process.env.XDG_CONFIG_HOME
let systemdUserHome = process.env.SYSTEMD_USER_HOME
let xdgConfigHome = process.env.XDG_CONFIG_HOME;
let systemdUserHome = process.env.SYSTEMD_USER_HOME;
if (xdgConfigHome == null) {
// Note: path.join() throws when arguments are null-ish.
xdgConfigHome = path.join(process.env.HOME, '.config')
xdgConfigHome = path.join(process.env.HOME, ".config");
}
if (systemdUserHome == null) {
systemdUserHome = path.join(xdgConfigHome, 'systemd', 'user')
systemdUserHome = path.join(xdgConfigHome, "systemd", "user");
}
const targetPath = path.join(systemdUserHome, 'oasis.service')
const targetPath = path.join(systemdUserHome, "oasis.service");
if (fs.existsSync(targetPath)) {
console.log('Cowardly refusing to overwrite file:', targetPath)
console.log("Cowardly refusing to overwrite file:", targetPath);
} else {
mkdirp(systemdUserHome)
mkdirp(systemdUserHome);
const sourcePath = path.join(__dirname, 'oasis.service')
fs.copyFileSync(sourcePath, targetPath)
const sourcePath = path.join(__dirname, "oasis.service");
fs.copyFileSync(sourcePath, targetPath);
execSync('systemctl --user daemon-reload')
console.log('Service configuration has been installed to:', targetPath)
execSync("systemctl --user daemon-reload");
console.log("Service configuration has been installed to:", targetPath);
}
// Since this isn't in a post-install script we can enable, start, and open it.
execSync('systemctl --user enable oasis')
execSync('systemctl --user start oasis')
open('http://localhost:4515')
execSync("systemctl --user enable oasis");
execSync("systemctl --user start oasis");
open("http://localhost:4515");

View File

@ -14,21 +14,21 @@ appearance, race, religion, or sexual identity and orientation.
Examples of behavior that contributes to creating a positive environment
include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
- Using welcoming and inclusive language
- Being respectful of differing viewpoints and experiences
- Gracefully accepting constructive criticism
- Focusing on what is best for the community
- Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or
- The use of sexualized language or imagery and unwelcome sexual attention or
advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic
- Trolling, insulting/derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or electronic
address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a
- Other conduct which could reasonably be considered inappropriate in a
professional setting
## Our Responsibilities
@ -74,4 +74,3 @@ available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.ht
For answers to common questions about this code of conduct, see
https://www.contributor-covenant.org/faq

View File

@ -4,7 +4,7 @@ Hey, welcome! This project is still very experimental so I won't make any
promises about the future architecture, but today it's pretty simple:
```
├── assets: static assets like CSS
├── assets: static assets like CSS
├── cli: command-line interface (yargs)
├── http: HTTP interface (koa)
├── index: mediator that ties everything together
@ -32,27 +32,27 @@ I have a hunch that importing modules from parent directories makes it really
easy to create spaghetti code that's difficult to learn and maintain. I don't
know whether that's true, but I'm experimenting with a layer-based approach
where the only file with relative imports is `index.js` and all imports are in
the style `require('./foo')`. I don't know whether this *actually* has any
the style `require('./foo')`. I don't know whether this _actually_ has any
interesting properties, but I'm trying it out to see whether it results in
simpler software architectures.
#### Pattern
```javascript
require('./foo') // foo.js
require('./bar') // bar/index.js
require("./foo"); // foo.js
require("./bar"); // bar/index.js
```
#### Anti-pattern
```javascript
require('../ancestor') // two-way import
require('./some/descendant') // layer violation
require('./foobar/index.js') // excessive specificity
require("../ancestor"); // two-way import
require("./some/descendant"); // layer violation
require("./foobar/index.js"); // excessive specificity
```
**Note:** I want to make *very* clear that this is an experiment, not a claim
that this is Objectively Better.
**Note:** I want to make _very_ clear that this is an experiment, not a claim
that this is Objectively Better.
### Any my [hyper]axe
@ -76,7 +76,7 @@ so I'm counting this as another experiment. It looks great and my first day with
it has been really enjoyable, but if something goes horribly wrong then we can
switch to hyperscript-helpers or something.
**Note:** I wasn't aware of hyperscript-helpers until I *after* I refactored
**Note:** I wasn't aware of hyperscript-helpers until I _after_ I refactored
the templates to use hyperaxe. Oops. I think hyperaxe has a cooler name anyway.
[dep-graph]: https://en.wikipedia.org/wiki/Dependency_graph

View File

@ -1,7 +1,7 @@
# Install
This is a guide on how to download the source code for Oasis so that you can
build and install it on your computer. If you'd like an easier installation
build and install it on your computer. If you'd like an easier installation
option, try one of the options in the readme.
If you want to run Oasis in the background, see [`with-systemd.md`](./with-systemd.md).
@ -47,4 +47,3 @@ oasis --help
If you have problems, read the documentation on [downloading and installing
packages globally](https://docs.npmjs.com/downloading-and-installing-packages-globally)
or [get some help](https://github.com/fraction/oasis/issues/new/choose).

2570
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -11,9 +11,9 @@
},
"scripts": {
"dev": "nodemon --inspect src/index.js --debug --no-open",
"fix": "standard --fix && stylelint --fix src/assets/*.css",
"fix": "common-good fix",
"start": "node src/index.js",
"test": "standard && dependency-check ./package.json --unused --no-dev --ignore-module highlight.js --ignore-module @fraction/base16-css && cspell --no-summary '**/*.{js,md}' && stylelint src/assets/*.css && tsc --allowJs --resolveJsonModule --lib dom --checkJs --noEmit --skipLibCheck src/index.js",
"test": "dependency_check_suffix='-i husky -i changelog-version -i mkdirp -i nodemon -i stylelint-config-recommended' common-good",
"preversion": "npm test",
"version": "changelog-version && git add CHANGELOG.md"
},
@ -21,7 +21,7 @@
"@fraction/base16-css": "^1.1.0",
"@fraction/flotilla": "3.0.0",
"debug": "^4.1.1",
"highlight.js": "^9.16.2",
"highlight.js": "^9.18.0",
"hyperaxe": "^1.3.0",
"koa": "^2.7.0",
"koa-body": "^4.1.0",
@ -46,15 +46,11 @@
},
"devDependencies": {
"changelog-version": "^1.0.1",
"cspell": "^4.0.43",
"dependency-check": "^4.1.0",
"common-good": "^1.1.0",
"husky": "^3.0.5",
"mkdirp": "^0.5.1",
"nodemon": "^2.0.2",
"standard": "^14.3.0",
"stylelint": "^12.0.1",
"stylelint-config-standard": "^19.0.0",
"typescript": "^3.7.4"
"stylelint-config-recommended": "^3.0.0"
},
"optionalDependencies": {
"sharp": "^0.23.0"

View File

@ -59,19 +59,9 @@
html {
display: flex;
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
"Roboto",
"Oxygen",
"Ubuntu",
"Cantarell",
"Fira Sans",
"Droid Sans",
"Helvetica Neue",
sans-serif;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
"Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans",
"Helvetica Neue", sans-serif;
justify-content: center;
font-size: 12pt;
line-height: 1.5;
@ -85,12 +75,24 @@ main {
}
/* https://www.desmos.com/calculator/3elcf5cwhn */
h1 { font-size: 133%; } /* 4 / 3 */
h2 { font-size: 115%; } /* 8 / 7 */
h3 { font-size: 105%; } /* 16 / 15 */
h4 { font-size: 103%; } /* 32 / 31 */
h5 { font-size: 101%; } /* 64 / 63 */
h6 { font-size: 100%; } /* 128 / 127 */
h1 {
font-size: 133%;
} /* 4 / 3 */
h2 {
font-size: 115%;
} /* 8 / 7 */
h3 {
font-size: 105%;
} /* 16 / 15 */
h4 {
font-size: 103%;
} /* 32 / 31 */
h5 {
font-size: 101%;
} /* 64 / 63 */
h6 {
font-size: 100%;
} /* 128 / 127 */
h1,
h2,

View File

@ -1,37 +1,37 @@
'use strict'
"use strict";
const yargs = require('yargs')
const yargs = require("yargs");
module.exports = () =>
yargs
.scriptName('oasis')
.env('OASIS')
.help('h')
.alias('h', 'help')
.usage('Usage: $0 [options]')
.options('open', {
describe: 'Automatically open app in web browser. Use --no-open to disable.',
.scriptName("oasis")
.env("OASIS")
.help("h")
.alias("h", "help")
.usage("Usage: $0 [options]")
.options("open", {
describe:
"Automatically open app in web browser. Use --no-open to disable.",
default: true,
type: 'boolean'
type: "boolean"
})
.options('offline', {
.options("offline", {
describe: "Don't try to connect to scuttlebutt peers or pubs",
default: false,
type: 'boolean'
type: "boolean"
})
.options('host', {
describe: 'Hostname for web app to listen on',
default: 'localhost',
type: 'string'
.options("host", {
describe: "Hostname for web app to listen on",
default: "localhost",
type: "string"
})
.options('port', {
describe: 'Port for web app to listen on',
.options("port", {
describe: "Port for web app to listen on",
default: 3000,
type: 'number'
type: "number"
})
.options('debug', {
describe: 'Use verbose output for debugging',
.options("debug", {
describe: "Use verbose output for debugging",
default: false,
type: 'boolean'
})
.argv
type: "boolean"
}).argv;

View File

@ -1,60 +1,62 @@
const Koa = require('koa')
const koaStatic = require('koa-static')
const path = require('path')
const mount = require('koa-mount')
const Koa = require("koa");
const koaStatic = require("koa-static");
const path = require("path");
const mount = require("koa-mount");
module.exports = ({ host, port, routes }) => {
const assets = new Koa()
assets.use(koaStatic(path.join(__dirname, 'assets')))
const assets = new Koa();
assets.use(koaStatic(path.join(__dirname, "assets")));
const app = new Koa()
module.exports = app
const app = new Koa();
module.exports = app;
app.on('error', (e) => {
app.on("error", e => {
// Output full error objects
e.message = e.stack
e.expose = true
return null
})
e.message = e.stack;
e.expose = true;
return null;
});
app.use(mount('/assets', assets))
app.use(mount("/assets", assets));
// headers
app.use(async (ctx, next) => {
await next()
await next();
const csp = [
'default-src \'none\'',
'img-src \'self\'',
'form-action \'self\'',
'media-src \'self\'',
'style-src \'self\' \'unsafe-inline\''
].join('; ')
"default-src 'none'",
"img-src 'self'",
"form-action 'self'",
"media-src 'self'",
"style-src 'self' 'unsafe-inline'"
].join("; ");
// Disallow scripts.
ctx.set('Content-Security-Policy', csp)
ctx.set("Content-Security-Policy", csp);
// Disallow <iframe> embeds from other domains.
ctx.set('X-Frame-Options', 'SAMEORIGIN')
ctx.set("X-Frame-Options", "SAMEORIGIN");
// Disallow browsers overwriting declared media types.
ctx.set('X-Content-Type-Options', 'nosniff')
ctx.set("X-Content-Type-Options", "nosniff");
// Disallow sharing referrer with other domains.
ctx.set('Referrer-Policy', 'same-origin')
ctx.set("Referrer-Policy", "same-origin");
// Disallow extra browser features except audio output.
ctx.set('Feature-Policy', 'speaker \'self\'')
ctx.set("Feature-Policy", "speaker 'self'");
if (ctx.method !== 'GET') {
const referer = ctx.request.header.referer
ctx.assert(referer != null, `HTTP ${ctx.method} must include referer`)
const refererUrl = new URL(referer)
const isBlobUrl = refererUrl.pathname.startsWith('/blob/')
ctx.assert(isBlobUrl === false, `HTTP ${ctx.method} from blob URL not allowed`)
if (ctx.method !== "GET") {
const referer = ctx.request.header.referer;
ctx.assert(referer != null, `HTTP ${ctx.method} must include referer`);
const refererUrl = new URL(referer);
const isBlobUrl = refererUrl.pathname.startsWith("/blob/");
ctx.assert(
isBlobUrl === false,
`HTTP ${ctx.method} from blob URL not allowed`
);
}
})
app.use(routes)
app.listen({ host, port })
}
});
app.use(routes);
app.listen({ host, port });
};

View File

@ -1,14 +1,14 @@
#!/usr/bin/env node
'use strict'
"use strict";
// Koa application to provide HTTP interface.
const cli = require('./cli')
const config = cli()
const cli = require("./cli");
const config = cli();
if (config.debug) {
process.env.DEBUG = 'oasis,oasis:*'
process.env.DEBUG = "oasis,oasis:*";
}
// HACK: We must get the CLI config and then delete environment variables.
@ -17,37 +17,30 @@ if (config.debug) {
// Unfortunately some modules think that our CLI options are meant for them,
// and since there's no way to disable that behavior (!) we have to hide them
// manually by setting the args property to an empty array.
process.argv = []
process.argv = [];
const http = require('./http')
const http = require("./http");
const debug = require('debug')('oasis')
const fs = require('fs').promises
const koaBody = require('koa-body')
const { nav, ul, li, a } = require('hyperaxe')
const open = require('open')
const path = require('path')
const pull = require('pull-stream')
const requireStyle = require('require-style')
const router = require('koa-router')()
const ssbMentions = require('ssb-mentions')
const ssbRef = require('ssb-ref')
const { themeNames } = require('@fraction/base16-css')
const debug = require("debug")("oasis");
const fs = require("fs").promises;
const koaBody = require("koa-body");
const { nav, ul, li, a } = require("hyperaxe");
const open = require("open");
const path = require("path");
const pull = require("pull-stream");
const requireStyle = require("require-style");
const router = require("koa-router")();
const ssbMentions = require("ssb-mentions");
const ssbRef = require("ssb-ref");
const { themeNames } = require("@fraction/base16-css");
const ssb = require('./ssb')
const ssb = require("./ssb");
// Create "cooler"-style interface from SSB connection.
// This handle is passed to the models for their convenience.
const cooler = ssb({ offline: config.offline })
const cooler = ssb({ offline: config.offline });
const {
about,
blob,
friend,
meta,
post,
vote
} = require('./models')(cooler)
const { about, blob, friend, meta, post, vote } = require("./models")(cooler);
const {
authorView,
@ -58,94 +51,89 @@ const {
publicView,
replyView,
searchView
} = require('./views')
} = require("./views");
let sharp
let sharp;
try {
sharp = require('sharp')
sharp = require("sharp");
} catch (e) {
// Optional dependency
}
const defaultTheme = 'atelier-sulphurPool-light'.toLowerCase()
const defaultTheme = "atelier-sulphurPool-light".toLowerCase();
// TODO: refactor
const start = async () => {
const filePath = path.join(__dirname, '..', 'README.md')
config.readme = await fs.readFile(filePath, 'utf8')
}
start()
const filePath = path.join(__dirname, "..", "README.md");
config.readme = await fs.readFile(filePath, "utf8");
};
start();
router
.param('imageSize', (imageSize, ctx, next) => {
const size = Number(imageSize)
const isInteger = size % 1 === 0
const overMinSize = size > 2
const underMaxSize = size <= 256
ctx.assert(isInteger && overMinSize && underMaxSize, 'Invalid image size')
return next()
.param("imageSize", (imageSize, ctx, next) => {
const size = Number(imageSize);
const isInteger = size % 1 === 0;
const overMinSize = size > 2;
const underMaxSize = size <= 256;
ctx.assert(isInteger && overMinSize && underMaxSize, "Invalid image size");
return next();
})
.param('blobId', (blobId, ctx, next) => {
ctx.assert(ssbRef.isBlob(blobId), 400, 'Invalid blob link')
return next()
.param("blobId", (blobId, ctx, next) => {
ctx.assert(ssbRef.isBlob(blobId), 400, "Invalid blob link");
return next();
})
.param('message', (message, ctx, next) => {
ctx.assert(ssbRef.isMsg(message), 400, 'Invalid message link')
return next()
.param("message", (message, ctx, next) => {
ctx.assert(ssbRef.isMsg(message), 400, "Invalid message link");
return next();
})
.param('feed', (message, ctx, next) => {
ctx.assert(ssbRef.isFeedId(message), 400, 'Invalid feed link')
return next()
.param("feed", (message, ctx, next) => {
ctx.assert(ssbRef.isFeedId(message), 400, "Invalid feed link");
return next();
})
.get('/', async (ctx) => {
ctx.redirect('/public/popular/day')
.get("/", async ctx => {
ctx.redirect("/public/popular/day");
})
.get('/public/popular/:period', async (ctx) => {
const { period } = ctx.params
.get("/public/popular/:period", async ctx => {
const { period } = ctx.params;
const publicPopular = async ({ period }) => {
const messages = await post.popular({ period })
const messages = await post.popular({ period });
const option = (somePeriod) =>
const option = somePeriod =>
li(
period === somePeriod
? a({ class: 'current', href: `./${somePeriod}` }, somePeriod)
? a({ class: "current", href: `./${somePeriod}` }, somePeriod)
: a({ href: `./${somePeriod}` }, somePeriod)
)
);
const prefix = nav(
ul(
option('day'),
option('week'),
option('month'),
option('year')
)
)
ul(option("day"), option("week"), option("month"), option("year"))
);
return publicView({
messages,
prefix
})
}
ctx.body = await publicPopular({ period })
});
};
ctx.body = await publicPopular({ period });
})
.get('/public/latest', async (ctx) => {
.get("/public/latest", async ctx => {
const publicLatest = async () => {
const messages = await post.latest()
return publicView({ messages })
}
ctx.body = await publicLatest()
const messages = await post.latest();
return publicView({ messages });
};
ctx.body = await publicLatest();
})
.get('/author/:feed', async (ctx) => {
const { feed } = ctx.params
const author = async (feedId) => {
const description = await about.description(feedId)
const name = await about.name(feedId)
const image = await about.image(feedId)
const messages = await post.fromFeed(feedId)
const relationship = await friend.getRelationship(feedId)
.get("/author/:feed", async ctx => {
const { feed } = ctx.params;
const author = async feedId => {
const description = await about.description(feedId);
const name = await about.name(feedId);
const image = await about.image(feedId);
const messages = await post.fromFeed(feedId);
const relationship = await friend.getRelationship(feedId);
const avatarUrl = `/image/256/${encodeURIComponent(image)}`
const avatarUrl = `/image/256/${encodeURIComponent(image)}`;
return authorView({
feedId,
@ -154,60 +142,60 @@ router
description,
avatarUrl,
relationship
})
}
ctx.body = await author(feed)
});
};
ctx.body = await author(feed);
})
.get('/search/', async (ctx) => {
const { query } = ctx.query
.get("/search/", async ctx => {
const { query } = ctx.query;
const search = async ({ query }) => {
if (typeof query === 'string') {
if (typeof query === "string") {
// https://github.com/ssbc/ssb-search/issues/7
query = query.toLowerCase()
query = query.toLowerCase();
}
const messages = await post.search({ query })
const messages = await post.search({ query });
return searchView({ messages, query })
}
ctx.body = await search({ query })
return searchView({ messages, query });
};
ctx.body = await search({ query });
})
.get('/inbox', async (ctx) => {
.get("/inbox", async ctx => {
const inbox = async () => {
const messages = await post.inbox()
const messages = await post.inbox();
return listView({ messages })
}
ctx.body = await inbox()
return listView({ messages });
};
ctx.body = await inbox();
})
.get('/hashtag/:channel', async (ctx) => {
const { channel } = ctx.params
const hashtag = async (channel) => {
const messages = await post.fromHashtag(channel)
.get("/hashtag/:channel", async ctx => {
const { channel } = ctx.params;
const hashtag = async channel => {
const messages = await post.fromHashtag(channel);
return listView({ messages })
}
ctx.body = await hashtag(channel)
return listView({ messages });
};
ctx.body = await hashtag(channel);
})
.get('/theme.css', (ctx) => {
const theme = ctx.cookies.get('theme') || defaultTheme
.get("/theme.css", ctx => {
const theme = ctx.cookies.get("theme") || defaultTheme;
const packageName = '@fraction/base16-css'
const filePath = `${packageName}/src/base16-${theme}.css`
ctx.type = 'text/css'
ctx.body = requireStyle(filePath)
const packageName = "@fraction/base16-css";
const filePath = `${packageName}/src/base16-${theme}.css`;
ctx.type = "text/css";
ctx.body = requireStyle(filePath);
})
.get('/profile/', async (ctx) => {
.get("/profile/", async ctx => {
const profile = async () => {
const myFeedId = await meta.myFeedId()
const myFeedId = await meta.myFeedId();
const description = await about.description(myFeedId)
const name = await about.name(myFeedId)
const image = await about.image(myFeedId)
const description = await about.description(myFeedId);
const name = await about.name(myFeedId);
const image = await about.image(myFeedId);
const messages = await post.fromFeed(myFeedId)
const messages = await post.fromFeed(myFeedId);
const avatarUrl = `/image/256/${encodeURIComponent(image)}`
const avatarUrl = `/image/256/${encodeURIComponent(image)}`;
return authorView({
feedId: myFeedId,
@ -216,316 +204,319 @@ router
description,
avatarUrl,
relationship: null
})
}
ctx.body = await profile()
});
};
ctx.body = await profile();
})
.get('/json/:message', async (ctx) => {
const { message } = ctx.params
ctx.type = 'application/json'
const json = async (message) => {
const json = await meta.get(message)
return JSON.stringify(json, null, 2)
}
ctx.body = await json(message)
.get("/json/:message", async ctx => {
const { message } = ctx.params;
ctx.type = "application/json";
const json = async message => {
const json = await meta.get(message);
return JSON.stringify(json, null, 2);
};
ctx.body = await json(message);
})
.get('/blob/:blobId', async (ctx) => {
const { blobId } = ctx.params
.get("/blob/:blobId", async ctx => {
const { blobId } = ctx.params;
const getBlob = async ({ blobId }) => {
const bufferSource = await blob.get({ blobId })
const bufferSource = await blob.get({ blobId });
debug('got buffer source')
return new Promise((resolve) => {
debug("got buffer source");
return new Promise(resolve => {
pull(
bufferSource,
pull.collect(async (err, bufferArray) => {
if (err) {
await blob.want({ blobId })
resolve(Buffer.alloc(0))
await blob.want({ blobId });
resolve(Buffer.alloc(0));
} else {
const buffer = Buffer.concat(bufferArray)
resolve(buffer)
const buffer = Buffer.concat(bufferArray);
resolve(buffer);
}
})
)
})
}
ctx.body = await getBlob({ blobId })
);
});
};
ctx.body = await getBlob({ blobId });
if (ctx.body.length === 0) {
ctx.response.status = 404
ctx.response.status = 404;
} else {
ctx.set('Cache-Control', 'public,max-age=31536000,immutable')
ctx.set("Cache-Control", "public,max-age=31536000,immutable");
}
// This prevents an auto-download when visiting the URL.
ctx.attachment(blobId, { type: 'inline' })
ctx.attachment(blobId, { type: "inline" });
})
.get('/image/:imageSize/:blobId', async (ctx) => {
const { blobId, imageSize } = ctx.params
ctx.type = 'image/png'
const fakePixel =
Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64'
)
const fakeImage = (imageSize) =>
.get("/image/:imageSize/:blobId", async ctx => {
const { blobId, imageSize } = ctx.params;
ctx.type = "image/png";
const fakePixel = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64"
);
const fakeImage = imageSize =>
sharp
? sharp({
create: {
width: imageSize,
height: imageSize,
channels: 4,
background: {
r: 0, g: 0, b: 0, alpha: 0.5
create: {
width: imageSize,
height: imageSize,
channels: 4,
background: {
r: 0,
g: 0,
b: 0,
alpha: 0.5
}
}
}
})
: new Promise((resolve) => resolve(fakePixel))
})
: new Promise(resolve => resolve(fakePixel));
const image = async ({ blobId, imageSize }) => {
const bufferSource = await blob.get({ blobId })
const fakeId = '&0000000000000000000000000000000000000000000=.sha256'
const bufferSource = await blob.get({ blobId });
const fakeId = "&0000000000000000000000000000000000000000000=.sha256";
debug('got buffer source')
return new Promise((resolve) => {
debug("got buffer source");
return new Promise(resolve => {
if (blobId === fakeId) {
debug('fake image')
fakeImage(imageSize).then(result => resolve(result))
debug("fake image");
fakeImage(imageSize).then(result => resolve(result));
} else {
debug('not fake image')
debug("not fake image");
pull(
bufferSource,
pull.collect(async (err, bufferArray) => {
if (err) {
await blob.want({ blobId })
const result = fakeImage(imageSize)
debug({ result })
resolve(result)
await blob.want({ blobId });
const result = fakeImage(imageSize);
debug({ result });
resolve(result);
} else {
const buffer = Buffer.concat(bufferArray)
const buffer = Buffer.concat(bufferArray);
if (sharp) {
sharp(buffer)
.resize(imageSize, imageSize)
.png()
.toBuffer()
.then((data) => {
resolve(data)
})
.then(data => {
resolve(data);
});
} else {
resolve(buffer)
resolve(buffer);
}
}
})
)
);
}
})
}
ctx.body = await image({ blobId, imageSize: Number(imageSize) })
});
};
ctx.body = await image({ blobId, imageSize: Number(imageSize) });
})
.get('/meta/', async (ctx) => {
const theme = ctx.cookies.get('theme') || defaultTheme
.get("/meta/", async ctx => {
const theme = ctx.cookies.get("theme") || defaultTheme;
const getMeta = async ({ theme }) => {
const status = await meta.status()
const peers = await meta.peers()
const status = await meta.status();
const peers = await meta.peers();
return metaView({ status, peers, theme, themeNames })
}
ctx.body = await getMeta({ theme })
return metaView({ status, peers, theme, themeNames });
};
ctx.body = await getMeta({ theme });
})
.get('/likes/:feed', async (ctx) => {
const { feed } = ctx.params
.get("/likes/:feed", async ctx => {
const { feed } = ctx.params;
const likes = async ({ feed }) => {
const messages = await post.likes({ feed })
return listView({ messages })
}
ctx.body = await likes({ feed })
const messages = await post.likes({ feed });
return listView({ messages });
};
ctx.body = await likes({ feed });
})
.get('/meta/readme/', async (ctx) => {
const status = async (text) => {
return markdownView({ text })
}
ctx.body = await status(config.readme)
.get("/meta/readme/", async ctx => {
const status = async text => {
return markdownView({ text });
};
ctx.body = await status(config.readme);
})
.get('/mentions/', async (ctx) => {
.get("/mentions/", async ctx => {
const mentions = async () => {
const messages = await post.mentionsMe()
const messages = await post.mentionsMe();
return listView({ messages })
}
ctx.body = await mentions()
return listView({ messages });
};
ctx.body = await mentions();
})
.get('/thread/:message', async (ctx) => {
const { message } = ctx.params
const thread = async (message) => {
const messages = await post.fromThread(message)
debug('got %i messages', messages.length)
.get("/thread/:message", async ctx => {
const { message } = ctx.params;
const thread = async message => {
const messages = await post.fromThread(message);
debug("got %i messages", messages.length);
return listView({ messages })
}
return listView({ messages });
};
ctx.body = await thread(message)
ctx.body = await thread(message);
})
.get('/reply/:message', async (ctx) => {
const { message } = ctx.params
const reply = async (parentId) => {
const rootMessage = await post.get(parentId)
const myFeedId = await meta.myFeedId()
.get("/reply/:message", async ctx => {
const { message } = ctx.params;
const reply = async parentId => {
const rootMessage = await post.get(parentId);
const myFeedId = await meta.myFeedId();
debug('%O', rootMessage)
const messages = [rootMessage]
debug("%O", rootMessage);
const messages = [rootMessage];
return replyView({ messages, myFeedId })
}
ctx.body = await reply(message)
return replyView({ messages, myFeedId });
};
ctx.body = await reply(message);
})
.get('/comment/:message', async (ctx) => {
const { message } = ctx.params
const comment = async (parentId) => {
const parentMessage = await post.get(parentId)
const myFeedId = await meta.myFeedId()
.get("/comment/:message", async ctx => {
const { message } = ctx.params;
const comment = async parentId => {
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 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
: parentMessage;
const messages = await post.threadReplies(rootMessage.key)
const messages = await post.threadReplies(rootMessage.key);
messages.push(rootMessage)
messages.push(rootMessage);
return commentView({ messages, myFeedId, parentMessage })
}
ctx.body = await comment(message)
return commentView({ messages, myFeedId, parentMessage });
};
ctx.body = await comment(message);
})
.post('/reply/:message', koaBody(), async (ctx) => {
const { message } = ctx.params
const text = String(ctx.request.body.text)
.post("/reply/:message", koaBody(), async ctx => {
const { message } = ctx.params;
const text = String(ctx.request.body.text);
const publishReply = async ({ message, text }) => {
// TODO: rename `message` to `parent` or `ancestor` or similar
const mentions = ssbMentions(text).filter((mention) =>
mention != null
) || undefined
const mentions =
ssbMentions(text).filter(mention => mention != null) || undefined;
const parent = await post.get(message)
const parent = await post.get(message);
return post.reply({
parent,
message: { text, mentions }
})
}
ctx.body = await publishReply({ message, text })
ctx.redirect(`/thread/${encodeURIComponent(message)}`)
});
};
ctx.body = await publishReply({ message, text });
ctx.redirect(`/thread/${encodeURIComponent(message)}`);
})
.post('/comment/:message', koaBody(), async (ctx) => {
const { message } = ctx.params
const text = String(ctx.request.body.text)
.post("/comment/:message", koaBody(), async ctx => {
const { message } = ctx.params;
const text = String(ctx.request.body.text);
const publishComment = async ({ message, text }) => {
// TODO: rename `message` to `parent` or `ancestor` or similar
const mentions = ssbMentions(text).filter((mention) =>
mention != null
) || undefined
const parent = await meta.get(message)
const mentions =
ssbMentions(text).filter(mention => mention != null) || undefined;
const parent = await meta.get(message);
return post.comment({
parent,
message: { text, mentions }
})
}
ctx.body = await publishComment({ message, text })
ctx.redirect(`/thread/${encodeURIComponent(message)}`)
});
};
ctx.body = await publishComment({ message, text });
ctx.redirect(`/thread/${encodeURIComponent(message)}`);
})
.post('/publish/', koaBody(), async (ctx) => {
const text = String(ctx.request.body.text)
.post("/publish/", koaBody(), async ctx => {
const text = String(ctx.request.body.text);
const publish = async ({ text }) => {
const mentions = ssbMentions(text).filter((mention) =>
mention != null
) || undefined
const mentions =
ssbMentions(text).filter(mention => mention != null) || undefined;
return post.root({
text,
mentions
})
}
ctx.body = await publish({ text })
ctx.redirect('/')
});
};
ctx.body = await publish({ text });
ctx.redirect("/");
})
.post('/follow/:feed', koaBody(), async (ctx) => {
const { feed } = ctx.params
const referer = new URL(ctx.request.header.referer)
ctx.body = await friend.follow(feed)
ctx.redirect(referer)
.post("/follow/:feed", koaBody(), async ctx => {
const { feed } = ctx.params;
const referer = new URL(ctx.request.header.referer);
ctx.body = await friend.follow(feed);
ctx.redirect(referer);
})
.post('/unfollow/:feed', koaBody(), async (ctx) => {
const { feed } = ctx.params
const referer = new URL(ctx.request.header.referer)
ctx.body = await friend.unfollow(feed)
ctx.redirect(referer)
.post("/unfollow/:feed", koaBody(), async ctx => {
const { feed } = ctx.params;
const referer = new URL(ctx.request.header.referer);
ctx.body = await friend.unfollow(feed);
ctx.redirect(referer);
})
.post('/like/:message', koaBody(), async (ctx) => {
const { message } = ctx.params
.post("/like/:message", koaBody(), async ctx => {
const { message } = ctx.params;
// TODO: convert all so `message` is full message and `messageKey` is key
const messageKey = message
const messageKey = message;
const voteValue = Number(ctx.request.body.voteValue)
const voteValue = Number(ctx.request.body.voteValue);
const encoded = {
message: encodeURIComponent(message)
}
};
const referer = new URL(ctx.request.header.referer)
referer.hash = `centered-footer-${encoded.message}`
const referer = new URL(ctx.request.header.referer);
referer.hash = `centered-footer-${encoded.message}`;
const like = async ({ messageKey, voteValue }) => {
const value = Number(voteValue)
const message = await post.get(messageKey)
const value = Number(voteValue);
const message = await post.get(messageKey);
const isPrivate = message.value.meta.private === true
const messageRecipients = isPrivate ? message.value.content.recps : []
const isPrivate = message.value.meta.private === true;
const messageRecipients = isPrivate ? message.value.content.recps : [];
const normalized = messageRecipients.map((recipient) => {
if (typeof recipient === 'string') {
return recipient
const normalized = messageRecipients.map(recipient => {
if (typeof recipient === "string") {
return recipient;
}
if (typeof recipient.link === 'string') {
return recipient.link
if (typeof recipient.link === "string") {
return recipient.link;
}
return null
})
return null;
});
const recipients = normalized.length > 0 ? normalized : undefined
const recipients = normalized.length > 0 ? normalized : undefined;
return vote.publish({ messageKey, value, recps: recipients })
}
ctx.body = await like({ messageKey, voteValue })
ctx.redirect(referer)
})
.post('/theme.css', koaBody(), async (ctx) => {
const theme = String(ctx.request.body.theme)
ctx.cookies.set('theme', theme)
const referer = new URL(ctx.request.header.referer)
ctx.redirect(referer)
return vote.publish({ messageKey, value, recps: recipients });
};
ctx.body = await like({ messageKey, voteValue });
ctx.redirect(referer);
})
.post("/theme.css", koaBody(), async ctx => {
const theme = String(ctx.request.body.theme);
ctx.cookies.set("theme", theme);
const referer = new URL(ctx.request.header.referer);
ctx.redirect(referer);
});
const { host } = config
const { port } = config
const routes = router.routes()
const { host } = config;
const { port } = config;
const routes = router.routes();
http({ host, port, routes })
http({ host, port, routes });
const uri = `http://${host}:${port}/`
const uri = `http://${host}:${port}/`;
const isDebugEnabled = debug.enabled
debug.enabled = true
debug(`Listening on ${uri}`)
debug.enabled = isDebugEnabled
const isDebugEnabled = debug.enabled;
debug.enabled = true;
debug(`Listening on ${uri}`);
debug.enabled = isDebugEnabled;
if (config.open === true) {
open(uri)
open(uri);
}

File diff suppressed because it is too large Load Diff

View File

@ -1,38 +1,42 @@
'use strict'
"use strict";
const md = require('ssb-markdown')
const ssbMessages = require('ssb-msgs')
const ssbRef = require('ssb-ref')
const md = require("ssb-markdown");
const ssbMessages = require("ssb-msgs");
const ssbRef = require("ssb-ref");
const toUrl = (mentions = []) => {
const mentionNames = {}
const mentionNames = {};
ssbMessages.links(mentions, 'feed').forEach((link) => {
if (link.name && typeof link.name === 'string') {
const name = (link.name.charAt(0) === '@') ? link.name : `@${link.name}`
mentionNames[name] = link.link
ssbMessages.links(mentions, "feed").forEach(link => {
if (link.name && typeof link.name === "string") {
const name = link.name.charAt(0) === "@" ? link.name : `@${link.name}`;
mentionNames[name] = link.link;
}
})
});
return (ref) => {
return ref => {
// @mentions
if (ref in mentionNames) {
return `/author/${encodeURIComponent(mentionNames[ref])}`
return `/author/${encodeURIComponent(mentionNames[ref])}`;
}
if (ssbRef.isFeedId(ref)) {
return `/author/${encodeURIComponent(ref)}`
} if (ssbRef.isMsgId(ref)) {
return `/thread/${encodeURIComponent(ref)}`
} if (ssbRef.isBlobId(ref)) {
return `/blob/${encodeURIComponent(ref)}`
} if (ref && ref[0] === '#') {
return `/hashtag/${encodeURIComponent(ref.substr(1))}`
return `/author/${encodeURIComponent(ref)}`;
}
return ''
}
}
if (ssbRef.isMsgId(ref)) {
return `/thread/${encodeURIComponent(ref)}`;
}
if (ssbRef.isBlobId(ref)) {
return `/blob/${encodeURIComponent(ref)}`;
}
if (ref && ref[0] === "#") {
return `/hashtag/${encodeURIComponent(ref.substr(1))}`;
}
return "";
};
};
module.exports = (input, mentions) => md.block(input, {
toUrl: toUrl(mentions)
})
module.exports = (input, mentions) =>
md.block(input, {
toUrl: toUrl(mentions)
});

View File

@ -1,97 +1,107 @@
'use strict'
"use strict";
// This module exports a function that connects to SSB and returns a "cooler"
// interface. This interface is poorly defined and should be replaced with
// native support for Promises in the MuxRPC module and auto-generated manifest
// files in the SSB-Client module.
const ssbClient = require('ssb-client')
const ssbConfig = require('ssb-config')
const flotilla = require('@fraction/flotilla')
const debug = require('debug')('oasis')
const ssbClient = require("ssb-client");
const ssbConfig = require("ssb-config");
const flotilla = require("@fraction/flotilla");
const debug = require("debug")("oasis");
const server = flotilla(ssbConfig)
const server = flotilla(ssbConfig);
const log = (...args) => {
const isDebugEnabled = debug.enabled
debug.enabled = true
debug(...args)
debug.enabled = isDebugEnabled
}
const isDebugEnabled = debug.enabled;
debug.enabled = true;
debug(...args);
debug.enabled = isDebugEnabled;
};
const rawConnect = () => new Promise((resolve, reject) => {
ssbClient({
manifest: {
about: {
socialValue: 'async',
read: 'source'
const rawConnect = () =>
new Promise((resolve, reject) => {
ssbClient(
{
manifest: {
about: {
socialValue: "async",
read: "source"
},
backlinks: { read: "source" },
blobs: {
get: "source",
ls: "source",
want: "async"
},
conn: {
peers: "source"
},
createUserStream: "source",
createHistoryStream: "source",
get: "sync",
messagesByType: "source",
publish: "async",
status: "async",
tangle: { branch: "async" },
query: { read: "source" },
friends: {
isFollowing: "async",
isBlocking: "async"
},
search: {
query: "source"
}
}
},
backlinks: { read: 'source' },
blobs: {
get: 'source',
ls: 'source',
want: 'async'
},
conn: {
peers: 'source'
},
createUserStream: 'source',
createHistoryStream: 'source',
get: 'sync',
messagesByType: 'source',
publish: 'async',
status: 'async',
tangle: { branch: 'async' },
query: { read: 'source' },
friends: {
isFollowing: 'async',
isBlocking: 'async'
},
search: {
query: 'source'
(err, api) => {
if (err) {
reject(err);
} else {
resolve(api);
}
}
}
}, (err, api) => {
if (err) {
reject(err)
} else {
resolve(api)
}
})
})
);
});
let handle
let handle;
const createConnection = (config) => {
handle = new Promise((resolve) => {
rawConnect().then((ssb) => {
log('Using pre-existing Scuttlebutt server instead of starting one')
resolve(ssb)
}).catch(() => {
log('Initial connection attempt failed')
log('Starting Scuttlebutt server')
server(config)
const connectOrRetry = () => {
rawConnect().then((ssb) => {
log('Retrying connection to own server')
resolve(ssb)
}).catch((e) => {
log(e)
connectOrRetry()
})
}
const createConnection = config => {
handle = new Promise(resolve => {
rawConnect()
.then(ssb => {
log("Using pre-existing Scuttlebutt server instead of starting one");
resolve(ssb);
})
.catch(() => {
log("Initial connection attempt failed");
log("Starting Scuttlebutt server");
server(config);
const connectOrRetry = () => {
rawConnect()
.then(ssb => {
log("Retrying connection to own server");
resolve(ssb);
})
.catch(e => {
log(e);
connectOrRetry();
});
};
connectOrRetry()
})
})
connectOrRetry();
});
});
return handle
}
return handle;
};
module.exports = ({ offline }) => {
if (offline) {
log('Offline mode activated - not connecting to scuttlebutt peers or pubs')
log('WARNING: offline mode cannot control the behavior of pre-existing servers')
log("Offline mode activated - not connecting to scuttlebutt peers or pubs");
log(
"WARNING: offline mode cannot control the behavior of pre-existing servers"
);
}
const config = {
@ -101,11 +111,11 @@ module.exports = ({ offline }) => {
ws: {
http: false
}
}
};
createConnection(config)
createConnection(config);
return {
connect () {
connect() {
// This has interesting behavior that may be unexpected.
//
// If `handle` is already an active [non-closed] connection, return that.
@ -113,33 +123,33 @@ module.exports = ({ offline }) => {
// If the connection is closed, we need to restart it. It's important to
// note that if we're depending on an external service (like Patchwork) and
// that app is closed, then Oasis will seamlessly start its own SSB service.
return new Promise((resolve, reject) => {
handle.then((ssb) => {
return new Promise(resolve => {
handle.then(ssb => {
if (ssb.closed) {
createConnection()
createConnection();
}
resolve(handle)
})
})
resolve(handle);
});
});
},
/**
* @param {function} method
*/
get (method, ...opts) {
get(method, ...opts) {
return new Promise((resolve, reject) => {
method(...opts, (err, val) => {
if (err) {
reject(err)
reject(err);
} else {
resolve(val)
resolve(val);
}
})
})
});
});
},
read (method, ...args) {
return new Promise((resolve) => {
resolve(method(...args))
})
read(method, ...args) {
return new Promise(resolve => {
resolve(method(...args));
});
}
}
}
};
};

View File

@ -1,8 +1,8 @@
'use strict'
"use strict";
const debug = require('debug')('oasis')
const ssbMarkdown = require('ssb-markdown')
const highlightJs = require('highlight.js')
const debug = require("debug")("oasis");
const ssbMarkdown = require("ssb-markdown");
const highlightJs = require("highlight.js");
const {
a,
@ -30,10 +30,10 @@ const {
strong,
textarea,
ul
} = require('hyperaxe')
} = require("hyperaxe");
const template = require('./template')
const post = require('./post')
const template = require("./template");
const post = require("./post");
exports.authorView = ({
avatarUrl,
@ -43,273 +43,308 @@ exports.authorView = ({
name,
relationship
}) => {
const mention = `[@${name}](${feedId})`
const markdownMention = highlightJs.highlight('markdown', mention).value
const mention = `[@${name}](${feedId})`;
const markdownMention = highlightJs.highlight("markdown", mention).value;
const areFollowing = relationship === 'you are following'
const areFollowing = relationship === "you are following";
const contactFormType = areFollowing
? 'unfollow'
: 'follow'
const contactFormType = areFollowing ? "unfollow" : "follow";
// We're on our own profile!
const contactForm = relationship !== null
? form({ action: `/${contactFormType}/${encodeURIComponent(feedId)}`, method: 'post' },
button({
type: 'submit'
},
contactFormType))
: null
const contactForm =
relationship !== null
? form(
{
action: `/${contactFormType}/${encodeURIComponent(feedId)}`,
method: "post"
},
button(
{
type: "submit"
},
contactFormType
)
)
: null;
const prefix = section({ class: 'message' },
header({ class: 'profile' },
img({ class: 'avatar', src: avatarUrl }),
h1(name)),
const prefix = section(
{ class: "message" },
header(
{ class: "profile" },
img({ class: "avatar", src: avatarUrl }),
h1(name)
),
pre({
class: 'md-mention',
class: "md-mention",
innerHTML: markdownMention
}),
description !== '<p>null</p>\n'
description !== "<p>null</p>\n"
? article({ innerHTML: description })
: null,
footer(
a({ href: `/likes/${encodeURIComponent(feedId)}` }, 'view likes'),
a({ href: `/likes/${encodeURIComponent(feedId)}` }, "view likes"),
span(relationship),
contactForm
)
)
);
return template(
prefix,
messages.map((msg) => post({ msg }))
)
}
messages.map(msg => post({ msg }))
);
};
exports.commentView = async ({ messages, myFeedId, parentMessage }) => {
let markdownMention
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
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
const x = `[@${authorName}](${authorFeedId})\n\n`;
markdownMention = x;
}
}
return post({ msg: message })
return post({ msg: message });
})
)
);
const action = `/comment/${encodeURIComponent(messages[0].key)}`
const method = 'post'
const action = `/comment/${encodeURIComponent(messages[0].key)}`;
const method = "post";
const isPrivate = parentMessage.value.meta.private
const isPrivate = parentMessage.value.meta.private;
const publicOrPrivate = isPrivate ? 'private' : 'public'
const maybeReplyText = isPrivate ? null : [
' Messages cannot be edited or deleted. To respond to an individual message, select ',
strong('reply'),
' instead.'
]
const publicOrPrivate = isPrivate ? "private" : "public";
const maybeReplyText = isPrivate
? null
: [
" Messages cannot be edited or deleted. To respond to an individual message, select ",
strong("reply"),
" instead."
];
return template(
messageElements,
p('Write a ',
p(
"Write a ",
strong(`${publicOrPrivate} comment`),
' on this thread with ',
a({ href: 'https://commonmark.org/help/' }, 'Markdown'),
'.',
" on this thread with ",
a({ href: "https://commonmark.org/help/" }, "Markdown"),
".",
maybeReplyText
),
form({ action, method },
textarea({
autofocus: true,
required: true,
name: 'text'
}, markdownMention),
button({
type: 'submit'
}, 'comment'))
)
}
exports.listView = ({ messages }) => template(
messages.map((msg) => post({ msg }))
)
exports.markdownView = ({ text }) => {
const rawHtml = ssbMarkdown.block(text)
return template(
section({ class: 'message' }, { innerHTML: rawHtml })
)
}
exports.metaView = ({ status, peers, theme, themeNames }) => {
const max = status.sync.since
const progressElements = Object.entries(status.sync.plugins).map((e) => {
const [key, val] = e
const id = `progress-${key}`
return div(
label({ for: id }, key),
progress({ id, value: val, max }, val)
)
})
const peerList = (peers || [])
.map(([, data]) =>
li(
a(
{ href: `/author/${encodeURIComponent(data.key)}` },
code(data.key)
)
form(
{ action, method },
textarea(
{
autofocus: true,
required: true,
name: "text"
},
markdownMention
),
button(
{
type: "submit"
},
"comment"
)
)
);
};
const themeElements = themeNames.map((cur) => {
const isCurrentTheme = cur === theme
exports.listView = ({ messages }) =>
template(messages.map(msg => post({ msg })));
exports.markdownView = ({ text }) => {
const rawHtml = ssbMarkdown.block(text);
return template(section({ class: "message" }, { innerHTML: rawHtml }));
};
exports.metaView = ({ status, peers, theme, themeNames }) => {
const max = status.sync.since;
const progressElements = Object.entries(status.sync.plugins).map(e => {
const [key, val] = e;
const id = `progress-${key}`;
return div(label({ for: id }, key), progress({ id, value: val, max }, val));
});
const peerList = (peers || []).map(([, data]) =>
li(a({ href: `/author/${encodeURIComponent(data.key)}` }, code(data.key)))
);
const themeElements = themeNames.map(cur => {
const isCurrentTheme = cur === theme;
if (isCurrentTheme) {
return option({ value: cur, selected: true }, cur)
return option({ value: cur, selected: true }, cur);
} else {
return option({ value: cur }, cur)
return option({ value: cur }, cur);
}
})
});
const base16 = [
// '00', removed because this is the background
'01',
'02',
'03',
'04',
'05',
'06',
'07',
'08',
'09',
'0A',
'0B',
'0C',
'0D',
'0E',
'0F'
]
"01",
"02",
"03",
"04",
"05",
"06",
"07",
"08",
"09",
"0A",
"0B",
"0C",
"0D",
"0E",
"0F"
];
const base16Elements = base16.map((base) =>
const base16Elements = base16.map(base =>
div({
style: {
'background-color': `var(--base${base})`,
width: `${1 / base16.length * 100}%`,
height: '1em',
'margin-top': '1em',
display: 'inline-block'
"background-color": `var(--base${base})`,
width: `${(1 / base16.length) * 100}%`,
height: "1em",
"margin-top": "1em",
display: "inline-block"
}
})
)
);
return template(
section({ class: 'message' },
h1('Meta'),
section(
{ class: "message" },
h1("Meta"),
p(
'Check out ',
a({ href: '/meta/readme' }, 'the readme'),
', configure your theme, or view debugging information below.'
"Check out ",
a({ href: "/meta/readme" }, "the readme"),
", configure your theme, or view debugging information below."
),
h2("Theme"),
p(
"Choose from any theme you'd like. The default theme is Atelier-SulphurPool-Light."
),
form(
{ action: "/theme.css", method: "post" },
select({ name: "theme" }, ...themeElements),
button({ type: "submit" }, "set theme")
),
h2('Theme'),
p('Choose from any theme you\'d like. The default theme is Atelier-SulphurPool-Light.'),
form({ action: '/theme.css', method: 'post' },
select({ name: 'theme' }, ...themeElements),
button({ type: 'submit' }, 'set theme')),
base16Elements,
h2('Status'),
h3('Indexes'),
h2("Status"),
h3("Indexes"),
progressElements,
peerList.length > 0
? [h3('Peers'), ul(peerList)]
: null
peerList.length > 0 ? [h3("Peers"), ul(peerList)] : null
)
)
}
);
};
exports.publicView = ({ messages, prefix = null }) => {
const publishForm = '/publish/'
const publishForm = "/publish/";
return template(
prefix,
section(
header(strong('🌐 Publish')),
form({ action: publishForm, method: 'post' },
header(strong("🌐 Publish")),
form(
{ action: publishForm, method: "post" },
label(
{ for: 'text' },
'Write a new message in ',
a({
href: 'https://commonmark.org/help/',
target: '_blank'
}, 'Markdown'),
'. Messages cannot be edited or deleted.'
{ for: "text" },
"Write a new message in ",
a(
{
href: "https://commonmark.org/help/",
target: "_blank"
},
"Markdown"
),
". Messages cannot be edited or deleted."
),
textarea({ required: true, name: 'text' }),
button({ type: 'submit' }, 'submit')
textarea({ required: true, name: "text" }),
button({ type: "submit" }, "submit")
)
),
messages.map((msg) => post({ msg }))
)
}
messages.map(msg => post({ msg }))
);
};
exports.replyView = async ({ messages, myFeedId }) => {
const replyForm = `/reply/${encodeURIComponent(messages[messages.length - 1].key)}`
const replyForm = `/reply/${encodeURIComponent(
messages[messages.length - 1].key
)}`;
let markdownMention
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
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 === messages[0].key) {
const x = `[@${authorName}](${authorFeedId})\n\n`
markdownMention = x
const x = `[@${authorName}](${authorFeedId})\n\n`;
markdownMention = x;
}
}
return post({ msg: message })
return post({ msg: message });
})
)
);
return template(
messageElements,
p('Write a ',
strong('public reply'),
' to this message with ',
a({ href: 'https://commonmark.org/help/' }, 'Markdown'),
'. Messages cannot be edited or deleted. To respond to an entire thread, select ',
strong('comment'),
' instead.'
p(
"Write a ",
strong("public reply"),
" to this message with ",
a({ href: "https://commonmark.org/help/" }, "Markdown"),
". Messages cannot be edited or deleted. To respond to an entire thread, select ",
strong("comment"),
" instead."
),
form({ action: replyForm, method: 'post' },
textarea({
autofocus: true,
required: true,
name: 'text'
}, markdownMention),
button({
type: 'submit'
}, 'reply'))
)
}
form(
{ action: replyForm, method: "post" },
textarea(
{
autofocus: true,
required: true,
name: "text"
},
markdownMention
),
button(
{
type: "submit"
},
"reply"
)
)
);
};
exports.searchView = ({ messages, query }) => template(
section(
form({ action: '/search', method: 'get' },
header(strong('Search')),
label({ for: 'query' }, 'Add word(s) to look for in downloaded messages.'),
input({ required: true, type: 'search', name: 'query', value: query }),
button({
type: 'submit'
}, 'submit'))
),
messages.map((msg) => post({ msg }))
)
exports.searchView = ({ messages, query }) =>
template(
section(
form(
{ action: "/search", method: "get" },
header(strong("Search")),
label(
{ for: "query" },
"Add word(s) to look for in downloaded messages."
),
input({ required: true, type: "search", name: "query", value: query }),
button(
{
type: "submit"
},
"submit"
)
)
),
messages.map(msg => post({ msg }))
);

View File

@ -1,4 +1,4 @@
'use strict'
"use strict";
const {
a,
@ -14,17 +14,17 @@ const {
span,
summary,
pre
} = require('hyperaxe')
} = require("hyperaxe");
const highlightJs = require('highlight.js')
const lodash = require('lodash')
const highlightJs = require("highlight.js");
const lodash = require("lodash");
module.exports = ({ msg }) => {
const encoded = {
key: encodeURIComponent(msg.key),
author: encodeURIComponent(msg.value.author),
parent: encodeURIComponent(msg.value.content.root)
}
};
const url = {
author: `/author/${encoded.author}`,
@ -35,101 +35,94 @@ module.exports = ({ msg }) => {
json: `/json/${encoded.key}`,
reply: `/reply/${encoded.key}`,
comment: `/comment/${encoded.key}`
}
};
const isPrivate = Boolean(msg.value.meta.private)
const isRoot = msg.value.content.root == null
const isThreadTarget = Boolean(lodash.get(
msg,
'value.meta.thread.target',
false
))
const isPrivate = Boolean(msg.value.meta.private);
const isRoot = msg.value.content.root == null;
const isThreadTarget = Boolean(
lodash.get(msg, "value.meta.thread.target", false)
);
// TODO: I think this is actually true for both replies and comments.
const isReply = Boolean(lodash.get(
msg,
'value.meta.thread.reply',
false
))
const isReply = Boolean(lodash.get(msg, "value.meta.thread.reply", false));
const { name } = msg.value.meta.author
const timeAgo = msg.value.meta.timestamp.received.since.replace('~', '')
const { name } = msg.value.meta.author;
const timeAgo = msg.value.meta.timestamp.received.since.replace("~", "");
const depth = lodash.get(msg, 'value.meta.thread.depth', 0)
const depth = lodash.get(msg, "value.meta.thread.depth", 0);
const markdownContent = msg.value.meta.md.block()
const markdownContent = msg.value.meta.md.block();
const hasContentWarning = typeof msg.value.content.contentWarning === 'string'
const hasContentWarning =
typeof msg.value.content.contentWarning === "string";
const likeButton = msg.value.meta.voted
? { value: 0, class: 'liked' }
: { value: 1, class: null }
? { value: 0, class: "liked" }
: { value: 1, class: null };
const likeCount = msg.value.meta.votes.length
const likeCount = msg.value.meta.votes.length;
const messageClasses = []
const messageClasses = [];
if (isPrivate) {
messageClasses.push('private')
messageClasses.push("private");
}
if (isThreadTarget) {
messageClasses.push('thread-target')
messageClasses.push("thread-target");
}
if (isReply) {
// True for comments too, I think
messageClasses.push('reply')
messageClasses.push("reply");
}
const isFork = msg.value.meta.postType === 'reply'
const isFork = msg.value.meta.postType === "reply";
const postOptions = {
post: null,
comment: [
'commented on ',
a({ href: url.parent }, ' thread')
],
reply: [
'replied to ',
a({ href: url.parent }, ' message')
],
mystery: 'posted a mysterious message'
}
comment: ["commented on ", a({ href: url.parent }, " thread")],
reply: ["replied to ", a({ href: url.parent }, " message")],
mystery: "posted a mysterious message"
};
const emptyContent = '<p>undefined</p>\n'
const articleElement = markdownContent === emptyContent
? article({ class: 'content' }, pre({
innerHTML: highlightJs.highlight(
'json',
JSON.stringify(msg, null, 2)
).value
}))
: article({ class: 'content', innerHTML: markdownContent })
const emptyContent = "<p>undefined</p>\n";
const articleElement =
markdownContent === emptyContent
? article(
{ class: "content" },
pre({
innerHTML: highlightJs.highlight(
"json",
JSON.stringify(msg, null, 2)
).value
})
)
: article({ class: "content", innerHTML: markdownContent });
const articleContent = hasContentWarning
? details(
summary(msg.value.content.contentWarning),
articleElement
)
: articleElement
? details(summary(msg.value.content.contentWarning), articleElement)
: articleElement;
const fragment =
section({
const fragment = section(
{
id: msg.key,
class: messageClasses.join(' '),
class: messageClasses.join(" "),
style: `margin-left: ${depth}rem;`
},
header(
span({ class: 'author' },
a({ href: url.author },
img({ class: 'avatar', src: url.avatar, alt: '' }),
span(
{ class: "author" },
a(
{ href: url.author },
img({ class: "avatar", src: url.avatar, alt: "" }),
name
),
postOptions[msg.value.meta.postType]
),
span({ class: 'time' },
isPrivate ? '🔒' : null,
span(
{ class: "time" },
isPrivate ? "🔒" : null,
a({ href: url.link }, timeAgo)
)
),
@ -144,21 +137,26 @@ module.exports = ({ msg }) => {
// 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' }),
div({ id: `centered-footer-${encoded.key}`, class: "centered-footer" }),
footer(
form({ action: url.likeForm, method: 'post' },
button({
name: 'voteValue',
type: 'submit',
value: likeButton.value,
class: likeButton.class
},
`${likeCount}`)),
a({ href: url.comment }, 'comment'),
(isPrivate || isRoot || isFork) ? null : a({ href: url.reply }, 'reply'),
a({ href: url.json }, 'json')
))
form(
{ action: url.likeForm, method: "post" },
button(
{
name: "voteValue",
type: "submit",
value: likeButton.value,
class: likeButton.class
},
`${likeCount}`
)
),
a({ href: url.comment }, "comment"),
isPrivate || isRoot || isFork ? null : a({ href: url.reply }, "reply"),
a({ href: url.json }, "json")
)
);
return fragment
}
return fragment;
};

View File

@ -1,4 +1,4 @@
'use strict'
"use strict";
const {
a,
@ -12,47 +12,50 @@ const {
nav,
title,
ul
} = require('hyperaxe')
} = require("hyperaxe");
const doctypeString = '<!DOCTYPE html>'
const doctypeString = "<!DOCTYPE html>";
const toAttributes = (obj) =>
Object.entries(obj).map(([key, val]) => `${key}=${val}`).join(', ')
const toAttributes = obj =>
Object.entries(obj)
.map(([key, val]) => `${key}=${val}`)
.join(", ");
module.exports = (...elements) => {
const nodes =
html({ lang: 'en' },
head(
title('🏝️ Oasis'),
link({ rel: 'stylesheet', href: '/theme.css' }),
link({ rel: 'stylesheet', href: '/assets/style.css' }),
link({ rel: 'stylesheet', href: '/assets/highlight.css' }),
meta({ charset: 'utf-8' }),
meta({
name: 'description',
content: 'friendly neighborhood scuttlebutt interface'
}),
meta({
name: 'viewport',
content: toAttributes({ width: 'device-width', 'initial-scale': 1 })
})
const nodes = html(
{ lang: "en" },
head(
title("🏝️ Oasis"),
link({ rel: "stylesheet", href: "/theme.css" }),
link({ rel: "stylesheet", href: "/assets/style.css" }),
link({ rel: "stylesheet", href: "/assets/highlight.css" }),
meta({ charset: "utf-8" }),
meta({
name: "description",
content: "friendly neighborhood scuttlebutt interface"
}),
meta({
name: "viewport",
content: toAttributes({ width: "device-width", "initial-scale": 1 })
})
),
body(
nav(
ul(
li(a({ href: "/" }, "popular")),
li(a({ href: "/public/latest" }, "latest")),
li(a({ href: "/inbox" }, "inbox")),
li(a({ href: "/mentions" }, "mentions")),
li(a({ href: "/profile" }, "profile")),
li(a({ href: "/search" }, "search")),
li(a({ href: "/meta" }, "meta"))
)
),
body(
nav(
ul(
li(a({ href: '/' }, 'popular')),
li(a({ href: '/public/latest' }, 'latest')),
li(a({ href: '/inbox' }, 'inbox')),
li(a({ href: '/mentions' }, 'mentions')),
li(a({ href: '/profile' }, 'profile')),
li(a({ href: '/search' }, 'search')),
li(a({ href: '/meta' }, 'meta'))
)
),
main({ id: 'content' }, elements)
))
main({ id: "content" }, elements)
)
);
const result = doctypeString + nodes.outerHTML
const result = doctypeString + nodes.outerHTML;
return result
}
return result;
};