aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md27
-rw-r--r--lib/about.js6
-rw-r--r--lib/app.js314
-rw-r--r--lib/git.js667
-rw-r--r--lib/render-msg.js443
-rw-r--r--lib/render.js211
-rw-r--r--lib/serve.js1254
-rw-r--r--lib/util.js109
-rw-r--r--package.json14
-rw-r--r--static/styles.css41
10 files changed, 2859 insertions, 227 deletions
diff --git a/README.md b/README.md
index 4351681..92450bb 100644
--- a/README.md
+++ b/README.md
@@ -16,15 +16,12 @@ Plain SSB web UI. Uses HTML forms instead of client-side JS. Designed for use on
- View public log, private log, user feeds, channels, and search.
- Paginate views bidirectionally.
- Compose, preview and publish public and private messages.
+- *and more*
-## TODO
+## Joining SSB with Patchfoo
-- Add a way to assist picking feed ids for `@mentions` in composer.
-- Add more sophisticated private messages view.
-- Show contents of git repos (cross-develop with [patchbay])
-- Count digs
-- Show network status
-- Add UI for using pub invites
+Find [this guide](%VaSj08AbdhIa4itK4z8Z91G80o2h5OhRLCEEO6MhAcU=.sha256) [on github](https://github.com/noffle/sailing-patchfoo) or [on
+SSB](http://git.scuttlebot.io/%25VaSj08AbdhIa4itK4z8Z91G80o2h5OhRLCEEO6MhAcU%3D.sha256).
## Install & Run
@@ -48,12 +45,12 @@ sbot plugins.enable patchfoo
## Install extras
-To be able to render channels, patchfoo needs the `ssb-query` scuttlebot
+To most effectively render things, patchfoo needs the `ssb-backlinks` scuttlebot
plugin:
```sh
-sbot plugins.install ssb-query
-sbot plugins.enable ssb-query
+sbot plugins.install ssb-backlinks
+sbot plugins.enable ssb-backlinks
# restart sbot
```
@@ -80,6 +77,16 @@ To make config options persistent, set them in `~/.ssb/config`, e.g.:
- `blob_base`: base url for links to ssb blobs. default: same as `base`
- `img_base`: base url for blobs embedded as images. default: same as `base`
- `emoji_base`: base url for emoji images. default: same as `base`
+- `encode_msgids`: whether to URL-encode message ids in local links. default: `true`
+
+## TODO
+
+- Add a way to assist picking feed ids for `@mentions` in composer.
+- Add more sophisticated private messages view.
+- Show contents of git repos (cross-develop with [patchbay])
+- Count digs
+- Show network status
+- Add UI for using pub invites
[patchbay]: %s9mSFATE4RGyJx9wgH22lBrvD4CgUQW4yeguSWWjtqc=.sha256
diff --git a/lib/about.js b/lib/about.js
index e94b7fc..6d04355 100644
--- a/lib/about.js
+++ b/lib/about.js
@@ -20,15 +20,14 @@ About.prototype.createAboutOpStream = function (id) {
&& key !== 'type'
&& key !== 'recps'
}).map(function (key) {
- var value = u.linkDest(c[key])
- // if (u.isRef(value)) value = {link: value}
+ var value = c[key]
return {
id: msg.key,
author: msg.value.author,
timestamp: msg.value.timestamp,
prop: key,
value: value,
- remove: value && typeof value === 'object' && value.remove,
+ remove: value && value.remove,
}
})
}),
@@ -96,6 +95,7 @@ About.prototype.get = function (dest, cb) {
if (!c) return
var about = aboutByFeed[author] || (aboutByFeed[author] = {})
if (c.name) about.name = c.name
+ if (c.title) about.title = c.title
if (c.image) about.image = u.linkDest(c.image)
if (c.description) about.description = c.description
}, function (err) {
diff --git a/lib/app.js b/lib/app.js
index 3a41086..5cc4e62 100644
--- a/lib/app.js
+++ b/lib/app.js
@@ -4,13 +4,16 @@ var lru = require('hashlru')
var pkg = require('../package')
var u = require('./util')
var pull = require('pull-stream')
-var hasher = require('pull-hash/ext/ssb')
var multicb = require('multicb')
var paramap = require('pull-paramap')
var Contacts = require('ssb-contact')
var About = require('./about')
var Serve = require('./serve')
var Render = require('./render')
+var Git = require('./git')
+var cat = require('pull-cat')
+var proc = require('child_process')
+var toPull = require('stream-to-pull-stream')
var BoxStream = require('pull-box-stream')
var crypto = require('crypto')
@@ -24,7 +27,7 @@ function App(sbot, config) {
var conf = config.patchfoo || {}
this.port = conf.port || 8027
- this.host = conf.host || '::1'
+ this.host = conf.host || 'localhost'
var base = conf.base || '/'
this.opts = {
@@ -32,6 +35,7 @@ function App(sbot, config) {
blob_base: conf.blob_base || conf.img_base || base,
img_base: conf.img_base || base,
emoji_base: conf.emoji_base || (base + 'emoji/'),
+ encode_msgids: conf.encode_msgids == null ? true : Boolean(conf.encode_msgids),
}
sbot.get = memo({cache: lru(100)}, sbot.get)
@@ -41,20 +45,29 @@ function App(sbot, config) {
this._getAbout.bind(this))
this.unboxContent = memo({cache: lru(100)}, sbot.private.unbox)
this.reverseNameCache = lru(500)
+ this.reverseEmojiNameCache = lru(500)
+ this.getBlobSize = memo({cache: this.blobSizeCache = lru(100)},
+ sbot.blobs.size.bind(sbot.blobs))
this.unboxMsg = this.unboxMsg.bind(this)
this.render = new Render(this, this.opts)
+ this.git = new Git(this)
+
+ this.monitorBlobWants()
}
App.prototype.go = function () {
var self = this
- http.createServer(function (req, res) {
+ var server = http.createServer(function (req, res) {
new Serve(self, req, res).go()
- }).listen(self.port, self.host, function () {
+ })
+ if (self.host === 'localhost') server.listen(self.port, onListening)
+ else server.listen(self.port, self.host, onListening)
+ function onListening() {
var host = /:/.test(self.host) ? '[' + self.host + ']' : self.host
self.log('Listening on http://' + host + ':' + self.port)
- })
+ }
// invalidate cached About info when new About messages come in
pull(
@@ -62,9 +75,12 @@ App.prototype.go = function () {
pull.drain(function (link) {
self.aboutCache.remove(link.dest)
}, function (err) {
- if (err) self.error('about:', err)
+ if (err) throw err
})
)
+
+ // keep alive ssb client connection
+ setInterval(self.sbot.whoami, 10e3)
}
var logPrefix = '[' + pkg.name + ']'
@@ -172,14 +188,27 @@ App.prototype.publish = function (content, cb) {
tryPublish(2)
}
+App.prototype.wantSizeBlob = function (id, cb) {
+ // only want() the blob if we don't already have it
+ var self = this
+ var blobs = this.sbot.blobs
+ blobs.size(id, function (err, size) {
+ if (size != null) return cb(null, size)
+ self.blobWants[id] = true
+ blobs.want(id, function (err) {
+ if (err) return cb(err)
+ blobs.size(id, cb)
+ })
+ })
+}
+
App.prototype.addBlobRaw = function (cb) {
var done = multicb({pluck: 1, spread: true})
var sink = pull(
- hasher(done()),
u.pullLength(done()),
this.sbot.blobs.add(done())
)
- done(function (err, hash, size, _) {
+ done(function (err, size, hash) {
if (err) return cb(err)
cb(null, {link: hash, size: size})
})
@@ -228,11 +257,55 @@ App.prototype.pushBlob = function (id, cb) {
this.sbot.blobs.push(id, cb)
}
+App.prototype.readBlob = function (link) {
+ link = u.toLink(link)
+ return this.sbot.blobs.get({
+ hash: link.link,
+ size: link.size,
+ })
+}
+
+App.prototype.readBlobSlice = function (link, opts) {
+ if (this.sbot.blobs.getSlice) return this.sbot.blobs.getSlice({
+ hash: link.link,
+ size: link.size,
+ start: opts.start,
+ end: opts.end,
+ })
+ return pull(
+ this.readBlob(link),
+ u.pullSlice(opts.start, opts.end)
+ )
+}
+
+App.prototype.ensureHasBlobs = function (links, cb) {
+ var self = this
+ var done = multicb({pluck: 1})
+ links.forEach(function (link) {
+ var cb = done()
+ self.sbot.blobs.size(link.link, function (err, size) {
+ if (err) cb(err)
+ else if (size == null) cb(null, link)
+ else cb()
+ })
+ })
+ done(function (err, missingLinks) {
+ if (err) console.trace(err)
+ missingLinks = missingLinks.filter(Boolean)
+ if (missingLinks.length == 0) return cb()
+ return cb({name: 'BlobNotFoundError', links: missingLinks})
+ })
+}
+
App.prototype.getReverseNameSync = function (name) {
var id = this.reverseNameCache.get(name)
return id
}
+App.prototype.getReverseEmojiNameSync = function (name) {
+ return this.reverseEmojiNameCache.get(name)
+}
+
App.prototype.getNameSync = function (name) {
var about = this.aboutCache.get(name)
return about && about.name
@@ -267,10 +340,22 @@ App.prototype.pullGetMsg = function (id) {
App.prototype.createLogStream = function (opts) {
opts = opts || {}
return opts.sortByTimestamp
- ? this.sbot.createFeedStream(opts)
+ ? this.createFeedStream(opts)
: this.sbot.createLogStream(opts)
}
+App.prototype.createFeedStream = function (opts) {
+ // work around opts.gt being treated as opts.gte sometimes
+ if (opts.gt && opts.limit && !opts.reverse) return pull(
+ this.sbot.createFeedStream(u.mergeOpts(opts, {limit: opts.limit + 1})),
+ pull.filter(function (msg) {
+ return msg && msg.value.timestamp !== opts.gt
+ }),
+ opts.limit && pull.take(opts.limit)
+ )
+ return this.sbot.createFeedStream(opts)
+}
+
var stateVals = {
connected: 3,
connecting: 2,
@@ -372,6 +457,217 @@ App.prototype.createContactStreams = function (id) {
return new Contacts(this.sbot).createContactStreams(id)
}
+function compareVoted(a, b) {
+ return b.value - a.value
+}
+
+App.prototype.getVoted = function (_opts, cb) {
+ if (isNaN(_opts.limit)) return pull.error(new Error('missing limit'))
+ var self = this
+ var opts = {
+ type: 'vote',
+ limit: _opts.limit * 100,
+ reverse: !!_opts.reverse,
+ gt: _opts.gt || undefined,
+ lt: _opts.lt || undefined,
+ }
+
+ var votedObj = {}
+ var votedArray = []
+ var numItems = 0
+ var firstTimestamp, lastTimestamp
+ pull(
+ self.sbot.messagesByType(opts),
+ self.unboxMessages(),
+ pull.take(function () {
+ return numItems < _opts.limit
+ }),
+ pull.drain(function (msg) {
+ if (!firstTimestamp) firstTimestamp = msg.timestamp
+ lastTimestamp = msg.timestamp
+ var vote = msg.value.content.vote
+ if (!vote) return
+ var target = u.linkDest(vote)
+ var votes = votedObj[target]
+ if (!votes) {
+ numItems++
+ votes = {id: target, value: 0, feedsObj: {}, feeds: []}
+ votedObj[target] = votes
+ votedArray.push(votes)
+ }
+ if (msg.value.author in votes.feedsObj) {
+ if (!opts.reverse) return // leave latest vote value as-is
+ // remove old vote value
+ votes.value -= votes.feedsObj[msg.value.author]
+ } else {
+ votes.feeds.push(msg.value.author)
+ }
+ var value = vote.value > 0 ? 1 : vote.value < 0 ? -1 : 0
+ votes.feedsObj[msg.value.author] = value
+ votes.value += value
+ }, function (err) {
+ if (err && err !== true) return cb(err)
+ var items = votedArray
+ if (opts.reverse) items.reverse()
+ items.sort(compareVoted)
+ cb(null, {items: items,
+ firstTimestamp: firstTimestamp,
+ lastTimestamp: lastTimestamp})
+ })
+ )
+}
+
App.prototype.createAboutStreams = function (id) {
return this.about.createAboutStreams(id)
}
+
+App.prototype.streamEmojis = function () {
+ return pull(
+ cat([
+ this.sbot.links({
+ rel: 'mentions',
+ source: this.sbot.id,
+ dest: '&',
+ values: true
+ }),
+ this.sbot.links({rel: 'mentions', dest: '&', values: true})
+ ]),
+ this.unboxMessages(),
+ pull.map(function (msg) { return msg.value.content.mentions }),
+ pull.flatten(),
+ pull.filter('emoji'),
+ pull.unique('link')
+ )
+}
+
+App.prototype.filter = function (plugin, opts, filter) {
+ // work around flumeview-query not picking the best index.
+ // %b+QdyLFQ21UGYwvV3AiD8FEr7mKlB8w9xx3h8WzSUb0=.sha256
+ var index
+ if (plugin === this.sbot.backlinks) {
+ var c = filter && filter.value && filter.value.content
+ var filteringByType = c && c.type
+ if (!filteringByType) index = 'DTS'
+ }
+ // work around flumeview-query not supporting $lt/$gt.
+ // %FCIv0D7JQyERznC18p8Dc1KtN6SLeJAl1sR5DAIr/Ek=.sha256
+ return pull(
+ plugin.read({
+ index: index,
+ reverse: opts.reverse,
+ limit: opts.limit && (opts.limit + 1),
+ query: [{$filter: u.mergeOpts(filter, {
+ timestamp: {
+ $gte: opts.gt,
+ $lte: opts.lt,
+ }
+ })}]
+ }),
+ pull.filter(function (msg) {
+ return msg && msg.timestamp !== opts.lt && msg.timestamp !== opts.gt
+ }),
+ opts.limit && pull.take(opts.limit)
+ )
+}
+
+App.prototype.streamChannel = function (opts) {
+ // prefer ssb-backlinks to ssb-query because it also handles hashtag mentions
+ if (this.sbot.backlinks) return this.filter(this.sbot.backlinks, opts, {
+ dest: '#' + opts.channel,
+ })
+
+ if (this.sbot.query) return this.filter(this.sbot.query, opts, {
+ value: {content: {channel: opts.channel}},
+ })
+
+ return pull.error(new Error(
+ 'Viewing channels/tags requires the ssb-backlinks or ssb-query plugin'))
+}
+
+App.prototype.streamMentions = function (opts) {
+ if (!this.sbot.backlinks) return pull.error(new Error(
+ 'Viewing mentions requires the ssb-backlinks plugin'))
+
+ if (this.sbot.backlinks) return this.filter(this.sbot.backlinks, opts, {
+ dest: this.sbot.id,
+ })
+}
+
+App.prototype.streamPrivate = function (opts) {
+ if (this.sbot.private.read) return this.filter(this.sbot.private, opts, {})
+
+ return pull(
+ this.createLogStream(u.mergeOpts(opts, {limit: null})),
+ pull.filter(u.isMsgEncrypted),
+ this.unboxMessages(),
+ pull.filter(u.isMsgReadable),
+ pull.take(opts.limit)
+ )
+}
+
+App.prototype.blobMentions = function (opts) {
+ if (!this.sbot.links2) return pull.error(new Error(
+ 'missing ssb-links plugin'))
+ var filter = {rel: ['mentions', opts.name]}
+ if (opts.author) filter.source = opts.author
+ return this.sbot.links2.read({
+ query: [
+ {$filter: filter},
+ {$filter: {dest: {$prefix: '&'}}},
+ {$map: {
+ name: ['rel', 1],
+ size: ['rel', 2],
+ link: 'dest',
+ author: 'source',
+ time: 'ts'
+ }}
+ ]
+ })
+}
+
+App.prototype.monitorBlobWants = function () {
+ var self = this
+ self.blobWants = {}
+ pull(
+ this.sbot.blobs.createWants(),
+ pull.drain(function (wants) {
+ for (var id in wants) {
+ if (wants[id] < 0) self.blobWants[id] = true
+ else delete self.blobWants[id]
+ self.blobSizeCache.remove(id)
+ }
+ }, function (err) {
+ if (err) console.trace(err)
+ })
+ )
+}
+
+App.prototype.getBlobState = function (id, cb) {
+ var self = this
+ if (self.blobWants[id]) return cb(null, 'wanted')
+ self.getBlobSize(id, function (err, size) {
+ if (err) return cb(err)
+ cb(null, size != null)
+ })
+}
+
+App.prototype.getNpmReadme = function (tarballId, cb) {
+ var self = this
+ // TODO: make this portable, and handle plaintext readmes
+ var tar = proc.spawn('tar', ['--ignore-case', '-Oxz',
+ 'package/README.md', 'package/readme.markdown', 'package/readme.mkd'])
+ var done = multicb({pluck: 1, spread: true})
+ pull(
+ self.sbot.blobs.get(tarballId),
+ toPull.sink(tar.stdin, done())
+ )
+ pull(
+ toPull.source(tar.stdout),
+ pull.collect(done())
+ )
+ done(function (err, _, bufs) {
+ if (err) return cb(err)
+ var text = Buffer.concat(bufs).toString('utf8')
+ cb(null, text, true)
+ })
+}
diff --git a/lib/git.js b/lib/git.js
new file mode 100644
index 0000000..cfcea9e
--- /dev/null
+++ b/lib/git.js
@@ -0,0 +1,667 @@
+var pull = require('pull-stream')
+var paramap = require('pull-paramap')
+var lru = require('hashlru')
+var memo = require('asyncmemo')
+var u = require('./util')
+var packidx = require('pull-git-packidx-parser')
+var Reader = require('pull-reader')
+var toPull = require('stream-to-pull-stream')
+var zlib = require('zlib')
+var looper = require('looper')
+var multicb = require('multicb')
+var kvdiff = require('pull-kvdiff')
+
+var ObjectNotFoundError = u.customError('ObjectNotFoundError')
+
+var types = {
+ blob: true,
+ commit: true,
+ tree: true,
+}
+var emptyBlobHash = 'e69de29bb2d1d6434b8b29ae775ad8c2e48c5391'
+
+module.exports = Git
+
+function Git(app) {
+ this.app = app
+
+ this.findObject = memo({
+ cache: lru(5),
+ asString: function (opts) {
+ return opts.obj + opts.headMsgId
+ }
+ }, this._findObject.bind(this))
+
+ this.findObjectInMsg = memo({
+ cache: lru(5),
+ asString: function (opts) {
+ return opts.obj + opts.msg
+ }
+ }, this._findObjectInMsg.bind(this))
+
+ this.getPackIndex = memo({
+ cache: lru(4),
+ asString: JSON.stringify
+ }, this._getPackIndex.bind(this))
+}
+
+// open, read, buffer and callback an object
+Git.prototype.getObject = function (opts, cb) {
+ var self = this
+ self.openObject(opts, function (err, obj) {
+ if (err) return cb(err)
+ pull(
+ self.readObject(obj),
+ u.pullConcat(cb)
+ )
+ })
+}
+
+// get a message that pushed an object
+Git.prototype.getObjectMsg = function (opts, cb) {
+ this.findObject(opts, function (err, loc) {
+ if (err) return cb(err)
+ cb(null, loc.msg)
+ })
+}
+
+Git.prototype.openObject = function (opts, cb) {
+ var self = this
+ self.findObjectInMsg(opts, function (err, loc) {
+ if (err) return cb(err)
+ self.app.ensureHasBlobs([loc.packLink], function (err) {
+ if (err) return cb(err)
+ cb(null, {
+ type: opts.type,
+ length: opts.length,
+ offset: loc.offset,
+ next: loc.next,
+ packLink: loc.packLink,
+ idx: loc.idx,
+ msg: loc.msg,
+ })
+ })
+ })
+}
+
+Git.prototype.readObject = function (obj) {
+ if (obj.offset === obj.next) return pull.empty()
+ return pull(
+ this.app.readBlobSlice(obj.packLink, {start: obj.offset, end: obj.next}),
+ this.decodeObject({
+ type: obj.type,
+ length: obj.length,
+ packLink: obj.packLink,
+ idx: obj.idx,
+ })
+ )
+}
+
+// find which packfile contains a git object, and where in the packfile it is
+// located
+Git.prototype._findObject = function (opts, cb) {
+ if (!opts.headMsgId) return cb(new TypeError('missing head message id'))
+ if (!opts.obj) return cb(new TypeError('missing object id'))
+ var self = this
+ var objId = opts.obj
+ if (objId === emptyBlobHash) {
+ // special case: the empty blob may be found anywhere
+ self.app.getMsgDecrypted(opts.headMsgId, function (err, msg) {
+ if (err) return cb(err)
+ return cb(null, {
+ offset: 0,
+ next: 0,
+ packLink: null,
+ idx: null,
+ msg: msg,
+ })
+ })
+ }
+ self.findObjectMsgs(opts, function (err, msgs) {
+ if (err) return cb(err)
+ if (msgs.length === 0)
+ return cb(new ObjectNotFoundError('unable to find git object ' + objId))
+ self.findObjectInMsgs(objId, msgs, cb)
+ })
+}
+
+Git.prototype._findObjectInMsg = function (opts, cb) {
+ if (!opts.msg) return cb(new TypeError('missing message id'))
+ if (!opts.obj) return cb(new TypeError('missing object id'))
+ var self = this
+ self.app.getMsgDecrypted(opts.msg, function (err, msg) {
+ if (err) return cb(err)
+ self.findObjectInMsgs(opts.obj, [msg], cb)
+ })
+}
+
+Git.prototype.findObjectInMsgs = function (objId, msgs, cb) {
+ var self = this
+ var objIdBuf = new Buffer(objId, 'hex')
+ // if blobs may need to be fetched, try to ask the user about as many of them
+ // at one time as possible
+ var packidxs = [].concat.apply([], msgs.map(function (msg) {
+ var c = msg.value.content
+ var idxs = u.toArray(c.indexes).map(u.toLink)
+ return u.toArray(c.packs).map(u.toLink).map(function (pack, i) {
+ var idx = idxs[i]
+ if (pack && idx) return {
+ msg: msg,
+ packLink: pack,
+ idxLink: idx,
+ }
+ })
+ })).filter(Boolean)
+ var blobLinks = packidxs.length === 1
+ ? [packidxs[0].idxLink, packidxs[0].packLink]
+ : packidxs.map(function (packidx) {
+ return packidx.idxLink
+ })
+ self.app.ensureHasBlobs(blobLinks, function (err) {
+ if (err) return cb(err)
+ pull(
+ pull.values(packidxs),
+ paramap(function (pack, cb) {
+ self.getPackIndex(pack.idxLink, function (err, idx) {
+ if (err) return cb(err)
+ var offset = idx.find(objIdBuf)
+ if (!offset) return cb()
+ cb(null, {
+ offset: offset.offset,
+ next: offset.next,
+ packLink: pack.packLink,
+ idx: idx,
+ msg: pack.msg,
+ })
+ })
+ }, 4),
+ pull.filter(),
+ pull.take(1),
+ pull.collect(function (err, offsets) {
+ if (err) return cb(err)
+ if (offsets.length === 0)
+ return cb(new ObjectNotFoundError('unable to find git object '
+ + objId + ' in ' + msgs.length + ' messages'))
+ cb(null, offsets[0])
+ })
+ )
+ })
+}
+
+// given an object id and ssb msg id, get a set of messages of which at least one pushed the object.
+Git.prototype.findObjectMsgs = function (opts, cb) {
+ var self = this
+ var id = opts.obj
+ var headMsgId = opts.headMsgId
+ var ended = false
+ var waiting = 0
+ var maybeMsgs = []
+
+ function cbOnce(err, msgs) {
+ if (ended) return
+ ended = true
+ cb(err, msgs)
+ }
+
+ function objectMatches(commit) {
+ return commit && (commit === id || commit.sha1 === id)
+ }
+
+ if (!headMsgId) return cb(new TypeError('missing head message id'))
+ if (!u.isRef(headMsgId))
+ return cb(new TypeError('bad head message id \'' + headMsgId + '\''))
+
+ ;(function getMsg(id) {
+ waiting++
+ self.app.getMsgDecrypted(id, function (err, msg) {
+ waiting--
+ if (ended) return
+ if (err && err.name == 'NotFoundError')
+ return cbOnce(new Error('missing message ' + headMsgId))
+ if (err) return cbOnce(err)
+ var c = msg.value.content
+ if (typeof c === 'string')
+ return cbOnce(new Error('unable to decrypt message ' + msg.key))
+ if ((u.toArray(c.object_ids).some(objectMatches))
+ || (u.toArray(c.tags).some(objectMatches))
+ || (u.toArray(c.commits).some(objectMatches))) {
+ // found the object
+ return cbOnce(null, [msg])
+ } else if (!c.object_ids) {
+ // the object might be here
+ maybeMsgs.push(msg)
+ }
+ // traverse the DAG to keep looking for the object
+ u.toArray(c.repoBranch).filter(u.isRef).forEach(getMsg)
+ if (waiting === 0) {
+ cbOnce(null, maybeMsgs)
+ }
+ })
+ })(headMsgId)
+}
+
+Git.prototype._getPackIndex = function (idxBlobLink, cb) {
+ pull(this.app.readBlob(idxBlobLink), packidx(cb))
+}
+
+var objectTypes = [
+ 'none', 'commit', 'tree', 'blob',
+ 'tag', 'unused', 'ofs-delta', 'ref-delta'
+]
+
+function readTypedVarInt(reader, cb) {
+ var type, value, shift
+ reader.read(1, function (end, buf) {
+ if (ended = end) return cb(end)
+ var firstByte = buf[0]
+ type = objectTypes[(firstByte >> 4) & 7]
+ value = firstByte & 15
+ shift = 4
+ checkByte(firstByte)
+ })
+
+ function checkByte(byte) {
+ if (byte & 0x80)
+ reader.read(1, gotByte)
+ else
+ cb(null, type, value)
+ }
+
+ function gotByte(end, buf) {
+ if (ended = end) return cb(end)
+ var byte = buf[0]
+ value += (byte & 0x7f) << shift
+ shift += 7
+ checkByte(byte)
+ }
+}
+
+function readVarInt(reader, cb) {
+ var value = 0, shift = 0
+ reader.read(1, function gotByte(end, buf) {
+ if (ended = end) return cb(end)
+ var byte = buf[0]
+ value += (byte & 0x7f) << shift
+ shift += 7
+ if (byte & 0x80)
+ reader.read(1, gotByte)
+ else
+ cb(null, value)
+ })
+}
+
+function inflate(read) {
+ return toPull(zlib.createInflate())(read)
+}
+
+Git.prototype.decodeObject = function (opts) {
+ var self = this
+ var packLink = opts.packLink
+ return function (read) {
+ var reader = Reader()
+ reader(read)
+ return u.readNext(function (cb) {
+ readTypedVarInt(reader, function (end, type, length) {
+ if (end === true) cb(new Error('Missing object type'))
+ else if (end) cb(end)
+ else if (type === 'ref-delta') getObjectFromRefDelta(length, cb)
+ else if (opts.type && type !== opts.type)
+ cb(new Error('expected type \'' + opts.type + '\' ' +
+ 'but found \'' + type + '\''))
+ else if (opts.length && length !== opts.length)
+ cb(new Error('expected length ' + opts.length + ' ' +
+ 'but found ' + length))
+ else cb(null, inflate(reader.read()))
+ })
+ })
+
+ function getObjectFromRefDelta(length, cb) {
+ reader.read(20, function (end, sourceHash) {
+ if (end) return cb(end)
+ var inflatedReader = Reader()
+ pull(reader.read(), inflate, inflatedReader)
+ readVarInt(inflatedReader, function (err, expectedSourceLength) {
+ if (err) return cb(err)
+ readVarInt(inflatedReader, function (err, expectedTargetLength) {
+ if (err) return cb(err)
+ var offset = opts.idx.find(sourceHash)
+ if (!offset) return cb(null, 'missing source object ' +
+ sourcehash.toString('hex'))
+ var readSource = pull(
+ self.app.readBlobSlice(opts.packLink, {
+ start: offset.offset,
+ end: offset.next
+ }),
+ self.decodeObject({
+ type: opts.type,
+ length: expectedSourceLength,
+ packLink: opts.packLink,
+ idx: opts.idx
+ })
+ )
+ cb(null, patchObject(inflatedReader, length, readSource, expectedTargetLength))
+ })
+ })
+ })
+ }
+ }
+}
+
+function readOffsetSize(cmd, reader, readCb) {
+ var offset = 0, size = 0
+
+ function addByte(bit, outPos, cb) {
+ if (cmd & (1 << bit))
+ reader.read(1, function (err, buf) {
+ if (err) readCb(err)
+ else cb(buf[0] << (outPos << 3))
+ })
+ else
+ cb(0)
+ }
+
+ addByte(0, 0, function (val) {
+ offset = val
+ addByte(1, 1, function (val) {
+ offset |= val
+ addByte(2, 2, function (val) {
+ offset |= val
+ addByte(3, 3, function (val) {
+ offset |= val
+ addSize()
+ })
+ })
+ })
+ })
+ function addSize() {
+ addByte(4, 0, function (val) {
+ size = val
+ addByte(5, 1, function (val) {
+ size |= val
+ addByte(6, 2, function (val) {
+ size |= val
+ readCb(null, offset, size || 0x10000)
+ })
+ })
+ })
+ }
+}
+
+function patchObject(deltaReader, deltaLength, readSource, targetLength) {
+ var srcBuf
+ var ended
+
+ return u.readNext(function (cb) {
+ pull(readSource, u.pullConcat(function (err, buf) {
+ if (err) return cb(err)
+ srcBuf = buf
+ cb(null, read)
+ }))
+ })
+
+ function read(abort, cb) {
+ if (ended) return cb(ended)
+ deltaReader.read(1, function (end, dBuf) {
+ if (ended = end) return cb(end)
+ var cmd = dBuf[0]
+ if (cmd & 0x80)
+ // skip a variable amount and then pass through a variable amount
+ readOffsetSize(cmd, deltaReader, function (err, offset, size) {
+ if (err) return earlyEnd(err)
+ var buf = srcBuf.slice(offset, offset + size)
+ cb(end, buf)
+ })
+ else if (cmd)
+ // insert `cmd` bytes from delta
+ deltaReader.read(cmd, cb)
+ else
+ cb(new Error("unexpected delta opcode 0"))
+ })
+
+ function earlyEnd(err) {
+ cb(err === true ? new Error('stream ended early') : err)
+ }
+ }
+}
+
+var gitNameRegex = /^(.*) <(([^>@]*)(@[^>]*)?)> (.*) (.*)$/
+function parseName(line) {
+ var m = gitNameRegex.exec(line)
+ if (!m) return null
+ return {
+ name: m[1],
+ email: m[2],
+ localpart: m[3],
+ feed: u.isRef(m[4]) && m[4] || undefined,
+ date: new Date(m[5] * 1000),
+ tz: m[6],
+ }
+}
+
+Git.prototype.getCommit = function (obj, cb) {
+ pull(this.readObject(obj), u.pullConcat(function (err, buf) {
+ if (err) return cb(err)
+ var commit = {
+ msg: obj.msg,
+ parents: [],
+ }
+ var authorLine, committerLine
+ var lines = buf.toString('utf8').split('\n')
+ for (var line; (line = lines.shift()); ) {
+ var parts = line.split(' ')
+ var prop = parts.shift()
+ var value = parts.join(' ')
+ switch (prop) {
+ case 'tree':
+ commit.tree = value
+ break
+ case 'parent':
+ commit.parents.push(value)
+ break
+ case 'author':
+ authorLine = value
+ break
+ case 'committer':
+ committerLine = value
+ break
+ case 'gpgsig':
+ var sigLines = [value]
+ while (lines[0] && lines[0][0] == ' ')
+ sigLines.push(lines.shift().slice(1))
+ commit.gpgsig = sigLines.join('\n')
+ break
+ default:
+ return cb(new TypeError('unknown git object property ' + prop))
+ }
+ }
+ commit.committer = parseName(committerLine)
+ if (authorLine !== committerLine) commit.author = parseName(authorLine)
+ commit.body = lines.join('\n')
+ cb(null, commit)
+ }))
+}
+
+Git.prototype.getTag = function (obj, cb) {
+ pull(this.readObject(obj), u.pullConcat(function (err, buf) {
+ if (err) return cb(err)
+ var tag = {
+ msg: obj.msg,
+ }
+ var authorLine, tagterLine
+ var lines = buf.toString('utf8').split('\n')
+ for (var line; (line = lines.shift()); ) {
+ var parts = line.split(' ')
+ var prop = parts.shift()
+ var value = parts.join(' ')
+ switch (prop) {
+ case 'object':
+ tag.object = value
+ break
+ case 'type':
+ if (!types[value])
+ return cb(new TypeError('unknown git object type ' + type))
+ tag.type = value
+ break
+ case 'tag':
+ tag.tag = value
+ break
+ case 'tagger':
+ tag.tagger = parseName(value)
+ break
+ default:
+ return cb(new TypeError('unknown git object property ' + prop))
+ }
+ }
+ tag.body = lines.join('\n')
+ cb(null, tag)
+ }))
+}
+
+function readCString(reader, cb) {
+ var chars = []
+ var loop = looper(function () {
+ reader.read(1, next)
+ })
+ function next(err, ch) {
+ if (err) return cb(err)
+ if (ch[0] === 0) return cb(null, Buffer.concat(chars).toString('utf8'))
+ chars.push(ch)
+ loop()
+ }
+ loop()
+}
+
+Git.prototype.readTree = function (obj) {
+ var self = this
+ var reader = Reader()
+ reader(this.readObject(obj))
+ return function (abort, cb) {
+ if (abort) return reader.abort(abort, cb)
+ readCString(reader, function (err, str) {
+ if (err) return cb(err)
+ var parts = str.split(' ')
+ var mode = parseInt(parts[0], 8)
+ var name = parts.slice(1).join(' ')
+ reader.read(20, function (err, hash) {
+ if (err) return cb(err)
+ cb(null, {
+ name: name,
+ mode: mode,
+ hash: hash.toString('hex'),
+ type: mode === 0040000 ? 'tree' :
+ mode === 0160000 ? 'commit' : 'blob',
+ })
+ })
+ })
+ }
+}
+
+Git.prototype.readCommitChanges = function (commit) {
+ var self = this
+ return u.readNext(function (cb) {
+ var done = multicb({pluck: 1})
+ commit.parents.forEach(function (rev) {
+ var cb = done()
+ self.getObjectMsg({
+ obj: rev,
+ headMsgId: commit.msg.key,
+ type: 'commit',
+ }, function (err, msg) {
+ if (err) return cb(err)
+ self.openObject({
+ obj: rev,
+ msg: msg.key,
+ }, function (err, obj) {
+ if (err) return cb(err)
+ self.getCommit(obj, cb)
+ })
+ })
+ })
+ done()(null, commit)
+ done(function (err, commits) {
+ if (err) return cb(err)
+ var done = multicb({pluck: 1})
+ commits.forEach(function (commit) {
+ var cb = done()
+ if (!commit.tree) return cb(null, pull.empty())
+ self.getObjectMsg({
+ obj: commit.tree,
+ headMsgId: commit.msg.key,
+ type: 'tree',
+ }, function (err, msg) {
+ if (err) return cb(err)
+ self.openObject({
+ obj: commit.tree,
+ msg: commit.msg.key,
+ }, cb)
+ })
+ })
+ done(function (err, trees) {
+ if (err) return cb(err)
+ cb(null, self.diffTreesRecursive(trees))
+ })
+ })
+ })
+}
+
+Git.prototype.diffTrees = function (objs) {
+ var self = this
+ return pull(
+ kvdiff(objs.map(function (obj) {
+ return self.readTree(obj)
+ }), 'name'),
+ pull.map(function (item) {
+ var diff = item.diff || {}
+ var head = item.values[item.values.length-1]
+ var created = true
+ for (var k = 0; k < item.values.length-1; k++)
+ if (item.values[k]) created = false
+ return {
+ name: item.key,
+ hash: item.values.map(function (val) { return val.hash }),
+ mode: diff.mode,
+ type: item.values.map(function (val) { return val.type }),
+ deleted: !head,
+ created: created
+ }
+ })
+ )
+}
+
+Git.prototype.diffTreesRecursive = function (objs) {
+ var self = this
+ return pull(
+ self.diffTrees(objs),
+ paramap(function (item, cb) {
+ if (!item.type.some(function (t) { return t === 'tree' }))
+ return cb(null, [item])
+ var done = multicb({pluck: 1})
+ item.type.forEach(function (type, i) {
+ var cb = done()
+ if (type !== 'tree') return cb(null, pull.once(item))
+ var hash = item.hash[i]
+ self.getObjectMsg({
+ obj: hash,
+ headMsgId: objs[i].msg.key,
+ }, function (err, msg) {
+ if (err) return cb(err)
+ self.openObject({
+ obj: hash,
+ msg: msg.key,
+ }, cb)
+ })
+ })
+ done(function (err, objs) {
+ if (err) return cb(err)
+ cb(null, pull(
+ self.diffTreesRecursive(objs),
+ pull.map(function (f) {
+ f.name = item.name + '/' + f.name
+ return f
+ })
+ ))
+ })
+ }, 4),
+ pull.flatten()
+ )
+}
diff --git a/lib/render-msg.js b/lib/render-msg.js
index c0001fc..9b69603 100644
--- a/lib/render-msg.js
+++ b/lib/render-msg.js
@@ -25,11 +25,7 @@ RenderMsg.prototype.toUrl = function (href) {
}
RenderMsg.prototype.linkify = function (text) {
- var arr = text.split(u.ssbRefRegex)
- for (var i = 1; i < arr.length; i += 2) {
- arr[i] = h('a', {href: this.toUrl(arr[i])}, arr[i])
- }
- return arr
+ return this.render.linkify(text)
}
function token() {
@@ -151,8 +147,14 @@ RenderMsg.prototype.actions = function () {
h('a', {href: '?gt=' + this.msg.timestamp}, '↓'), ' '] : '',
this.c.type === 'gathering' ? [
h('a', {href: this.render.toUrl('/about/' + encodeURIComponent(this.msg.key))}, 'about'), ' '] : '',
- h('a', {href: this.toUrl(this.msg.key) + '?raw'}, 'raw'), ' ',
- this.voteFormInner('dig')
+ typeof this.c.text === 'string' ? [
+ h('a', {href: this.toUrl(this.msg.key) + '?raw=md',
+ title: 'view markdown source'}, 'md'), ' '] : '',
+ h('a', {href: this.toUrl(this.msg.key) + '?raw',
+ title: 'view raw message'}, 'raw'), ' ',
+ this.buttonsCommon(),
+ this.c.type === 'gathering' ? [this.attendButton(), ' '] : '',
+ this.voteButton('dig')
) : [
this.msg.rel ? [this.msg.rel, ' '] : ''
]
@@ -179,16 +181,29 @@ RenderMsg.prototype.recpsIds = function () {
: []
}
-RenderMsg.prototype.voteFormInner = function (expression) {
+RenderMsg.prototype.buttonsCommon = function () {
var chan = this.msg.value.content.channel
+ var recps = this.recpsIds()
return [
- h('input', {type: 'hidden', name: 'action', value: 'vote'}),
- h('input', {type: 'hidden', name: 'recps',
- value: this.recpsIds().join(',')}),
chan ? h('input', {type: 'hidden', name: 'channel', value: chan}) : '',
h('input', {type: 'hidden', name: 'link', value: this.msg.key}),
- h('input', {type: 'hidden', name: 'value', value: 1}),
- h('input', {type: 'submit', name: 'expression', value: expression})]
+ h('input', {type: 'hidden', name: 'recps', value: recps.join(',')})
+ ]
+}
+
+RenderMsg.prototype.voteButton = function (expression) {
+ var chan = this.msg.value.content.channel
+ return [
+ h('input', {type: 'hidden', name: 'vote_value', value: 1}),
+ h('input', {type: 'hidden', name: 'vote_expression', value: expression}),
+ h('input', {type: 'submit', name: 'action_vote', value: expression})]
+}
+
+RenderMsg.prototype.attendButton = function () {
+ var chan = this.msg.value.content.channel
+ return [
+ h('input', {type: 'submit', name: 'action_attend', value: 'attend'})
+ ]
}
RenderMsg.prototype.message = function (cb) {
@@ -223,9 +238,23 @@ RenderMsg.prototype.message = function (cb) {
case 'ferment/update':
case 'robeson/update':
return this.update(cb)
+ case 'chess_invite':
+ case 'ssb_chess_invite':
+ return this.chessInvite(cb)
+ case 'chess_invite_accept':
+ case 'ssb_chess_invite_accept':
+ return this.chessInviteAccept(cb)
+ case 'chess_move':
+ case 'ssb_chess_move':
+ return this.chessMove(cb)
+ case 'chess_game_end':
+ case 'ssb_chess_game_end':
+ return this.chessGameEnd(cb)
case 'wifi-network': return this.wifiNetwork(cb)
case 'mutual/credit': return this.mutualCredit(cb)
case 'mutual/account': return this.mutualAccount(cb)
+ case 'npm-publish': return this.npmPublish(cb)
+ case 'npm-packages': return this.npmPackages(cb)
default: return this.object(cb)
}
}
@@ -235,25 +264,33 @@ RenderMsg.prototype.encrypted = function (cb) {
}
RenderMsg.prototype.markdown = function (cb) {
+ if (this.opts.markdownSource)
+ return this.markdownSource(this.c.text, this.c.mentions)
return this.render.markdown(this.c.text, this.c.mentions)
}
+RenderMsg.prototype.markdownSource = function (text, mentions) {
+ return h('div',
+ h('pre', String(text)),
+ mentions ? [
+ h('div', h('em', 'mentions:')),
+ this.valueTable(mentions, function () {})
+ ] : ''
+ ).innerHTML
+}
+
RenderMsg.prototype.post = function (cb) {
var self = this
var done = multicb({pluck: 1, spread: true})
- var branchDone = multicb({pluck: 1})
- u.toArray(self.c.branch).forEach(function (branch) {
- self.link(branch, branchDone())
- })
if (self.c.root === self.c.branch) done()()
else self.link(self.c.root, done())
- branchDone(done())
+ self.links(self.c.branch, done())
done(function (err, rootLink, branchLinks) {
if (err) return self.wrap(u.renderError(err), cb)
self.wrap(h('div.ssb-post',
- rootLink ? h('div', h('small', '>> ', rootLink)) : '',
+ rootLink ? h('div', h('small', h('span.symbol', '→'), ' ', rootLink)) : '',
branchLinks.map(function (a, i) {
- return h('div', h('small', '> ', a))
+ return h('div', h('small', h('span.symbol', '  ↳'), ' ', a))
}),
h('div.ssb-post-text', {innerHTML: self.markdown()})
), cb)
@@ -320,6 +357,8 @@ RenderMsg.prototype.title = function (cb) {
} else {
if (self.c.type === 'ssb-dns')
cb(null, self.c.record && JSON.stringify(self.c.record.data) || self.msg.key)
+ else if (self.c.type === 'npm-publish')
+ self.npmPublishTitle(cb)
else
self.app.getAbout(self.msg.key, function (err, about) {
if (err) return cb(err)
@@ -362,17 +401,38 @@ RenderMsg.prototype.link1 = function (link, cb) {
return a
}
+RenderMsg.prototype.links = function (links, cb) {
+ var self = this
+ var done = multicb({pluck: 1})
+ u.toArray(links).forEach(function (link) {
+ self.link(link, done())
+ })
+ done(cb)
+}
+
function dateTime(d) {
var date = new Date(d.epoch)
- return date.toUTCString()
+ return date.toString()
// d.bias
// d.epoch
}
RenderMsg.prototype.about = function (cb) {
- var img = u.linkDest(this.c.image)
var done = multicb({pluck: 1, spread: true})
var elCb = done()
+
+ var isAttendingMsg = u.linkDest(this.c.attendee) === this.msg.value.author
+ && Object.keys(this.c).sort().join() === 'about,attendee,type'
+ if (isAttendingMsg) {
+ var attending = !this.c.attendee.remove
+ this.wrapMini([
+ attending ? ' is attending' : ' is not attending', ' ',
+ this.link1(this.c.about, done())
+ ], elCb)
+ return done(cb)
+ }
+
+ var img = u.linkDest(this.c.image)
// if there is a description, it is likely to be multi-line
var hasDescription = this.c.description != null
var wrap = hasDescription ? this.wrap : this.wrapMini
@@ -430,6 +490,7 @@ RenderMsg.prototype.contact = function (cb) {
' from ',
h('code', self.c.note)
] : '',
+ self.c.reason ? [' because ', h('q', self.c.reason)] : ''
], cb)
})
}
@@ -475,50 +536,58 @@ RenderMsg.prototype.gitUpdate = function (cb) {
var size = [].concat(self.c.packs, self.c.indexes)
.map(function (o) { return o && o.size })
.reduce(function (total, s) { return total + s })
- self.link(self.c.repo, function (err, a) {
+
+ var done = multicb({pluck: 1, spread: true})
+ self.link(self.c.repo, done())
+ self.render.npmPackageMentions(self.c.mentions, done())
+ done(function (err, a, pkgMentionsEl) {
if (err) return cb(err)
self.wrap(h('div.ssb-git-update',
'git push ', a, ' ',
!isNaN(size) ? [self.render.formatSize(size), ' '] : '',
self.c.refs ? h('ul', Object.keys(self.c.refs).map(function (ref) {
var id = self.c.refs[ref]
- return h('li',
- ref.replace(/^refs\/(heads|tags)\//, ''), ': ',
- id ? h('code', id) : h('em', 'deleted'))
+ var type = /^refs\/tags/.test(ref) ? 'tag' : 'commit'
+ var path = id && ('/git/' + type + '/' + encodeURIComponent(id)
+ + '?msg=' + encodeURIComponent(self.msg.key))
+ return h('li',
+ ref.replace(/^refs\/(heads|tags)\//, ''), ': ',
+ id ? h('a', {href: self.render.toUrl(path)}, h('code', id))
+ : h('em', 'deleted'))
})) : '',
Array.isArray(self.c.commits) ?
h('ul', self.c.commits.map(function (commit) {
- return h('li',
- h('code', String(commit.sha1).substr(0, 8)), ' ',
+ var path = '/git/commit/' + encodeURIComponent(commit.sha1)
+ + '?msg=' + encodeURIComponent(self.msg.key)
+ return h('li', h('a', {href: self.render.toUrl(path)},
+ h('code', String(commit.sha1).substr(0, 8))), ' ',
self.linkify(String(commit.title)),
- self.gitCommitBody(commit.body)
+ self.render.gitCommitBody(commit.body)
)
})) : '',
Array.isArray(self.c.tags) ?
h('ul', self.c.tags.map(function (tag) {
+ var path = '/git/tag/' + encodeURIComponent(tag.sha1)
+ + '?msg=' + encodeURIComponent(self.msg.key)
return h('li',
- h('code', String(tag.sha1).substr(0, 8)), ' ',
+ h('a', {href: self.render.toUrl(path)},
+ h('code', String(tag.sha1).substr(0, 8))), ' ',
'tagged ', String(tag.type), ' ',
h('code', String(tag.object).substr(0, 8)), ' ',
- String(tag.tag)
+ String(tag.tag),
+ tag.title ? [': ', self.linkify(String(tag.title).trim()), ' '] : '',
+ tag.body ? self.render.gitCommitBody(tag.body) : ''
)
})) : '',
self.c.commits_more ? h('div',
'+ ' + self.c.commits_more + ' more commits') : '',
self.c.tags_more ? h('div',
- '+ ' + self.c.tags_more + ' more tags') : ''
+ '+ ' + self.c.tags_more + ' more tags') : '',
+ pkgMentionsEl
), cb)
})
}
-RenderMsg.prototype.gitCommitBody = function (body) {
- if (!body) return ''
- var isMarkdown = !/^# Conflicts:$/m.test(body)
- return isMarkdown
- ? h('div', {innerHTML: this.render.markdown('\n' + body)})
- : h('pre', this.linkify('\n' + body))
-}
-
RenderMsg.prototype.gitPullRequest = function (cb) {
var self = this
var done = multicb({pluck: 1, spread: true})
@@ -800,3 +869,297 @@ RenderMsg.prototype.micro = function (cb) {
var el = h('span', {innerHTML: unwrapP(this.markdown())})
this.wrapMini(el, cb)
}
+
+function hJoin(els, seperator) {
+ return els.map(function (el, i) {
+ return [i === 0 ? '' : separator, el]
+ })
+}
+
+function asNpmReadme(readme) {
+ if (!readme || readme === 'ERROR: No README data found!') return
+ return u.ifString(readme)
+}
+
+function singleValue(obj) {
+ if (!obj || typeof obj !== 'object') return obj
+ var keys = Object.keys(obj)
+ if (keys.length === 1) return obj[keys[0]]
+}
+
+function ifDifferent(obj, value) {
+ if (singleValue(obj) !== value) return obj
+}
+
+RenderMsg.prototype.npmPublish = function (cb) {
+ var self = this
+ var render = self.render
+ var pkg = self.c.meta || {}
+ var pkgReadme = asNpmReadme(pkg.readme)
+ var pkgDescription = u.ifString(pkg.description)
+
+ var versions = Object.keys(pkg.versions || {})
+ var singleVersion = versions.length === 1 ? versions[0] : null
+ var singleRelease = singleVersion && pkg.versions[singleVersion]
+ var singleReadme = singleRelease && asNpmReadme(singleRelease.readme)
+
+ var distTags = pkg['dist-tags'] || {}
+ var distTagged = {}
+ for (var distTag in distTags)
+ if (distTag !== 'latest')
+ distTagged[distTags[distTag]] = distTag
+
+ self.links(self.c.previousPublish, function (err, prevLinks) {
+ if (err) return cb(err)
+ self.wrap([
+ h('div',
+ 'published ',
+ h('u', pkg.name), ' ',
+ hJoin(versions.map(function (version) {
+ var distTag = distTagged[version]
+ return [h('b', version), distTag ? [' (', h('i', distTag), ')'] : '']
+ }), ', ')
+ ),
+ pkgDescription ? h('div',
+ // TODO: make mdInline use custom emojis
+ h('q', {innerHTML: unwrapP(render.markdown(pkgDescription))})) : '',
+ prevLinks.length ? h('div', 'previous: ', prevLinks) : '',
+ pkgReadme && pkgReadme !== singleReadme ?
+ h('blockquote', {innerHTML: render.markdown(pkgReadme)}) : '',
+ versions.map(function (version, i) {
+ var release = pkg.versions[version] || {}
+ var license = u.ifString(release.license)
+ var author = ifDifferent(release.author, self.msg.value.author)
+ var description = u.ifString(release.description)
+ var readme = asNpmReadme(release.readme)
+ var keywords = u.toArray(release.keywords).map(u.ifString)
+ var dist = release.dist || {}
+ var size = u.ifNumber(dist.size)
+ return [
+ h > 0 ? h('br') : '',
+ version !== singleVersion ? h('div', 'version: ', version) : '',
+ author ? h('div', 'author: ', render.npmAuthorLink(author)) : '',
+ license ? h('div', 'license: ', h('code', license)) : '',
+ keywords.length ? h('div', 'keywords: ', keywords.join(', ')) : '',
+ size ? h('div', 'size: ', render.formatSize(size)) : '',
+ description && description !== pkgDescription ?
+ h('div', h('q', {innerHTML: render.markdown(description)})) : '',
+ readme ? h('blockquote', {innerHTML: render.markdown(readme)}) : ''
+ ]
+ })
+ ], cb)
+ })
+}
+
+RenderMsg.prototype.npmPackages = function (cb) {
+ var self = this
+ self.render.npmPackageMentions(self.c.mentions, function (err, el) {
+ if (err) return cb(err)
+ self.wrap(el, cb)
+ })
+}
+
+RenderMsg.prototype.npmPublishTitle = function (cb) {
+ var pkg = this.c.meta || {}
+ var name = pkg.name || pkg._id || '?'
+
+ var taggedVersions = {}
+ for (var version in pkg.versions || {})
+ taggedVersions[version] = []
+
+ var distTags = pkg['dist-tags'] || {}
+ for (var distTag in distTags) {
+ if (distTag === 'latest') continue
+ var version = distTags[distTag] || '?'
+ var tags = taggedVersions[version] || (taggedVersions[version] = [])
+ tags.push(distTag)
+ }
+
+ cb(null, name + '@' + Object.keys(taggedVersions).map(function (version) {
+ var tags = taggedVersions[version]
+ return (tags.length ? tags.join(',') + ':' : '') + version
+ }).join(','))
+}
+
+function expandDigitToSpaces(n) {
+ return ' '.substr(-n)
+}
+
+function parseFenRank (line) {
+ return line.replace(/\d/g, expandDigitToSpaces).split('')
+}
+
+function parseChess(fen) {
+ var fields = String(fen).split(/\s+/)
+ var ranks = fields[0].split('/')
+ var f2 = fields[2] || ''
+ return {
+ board: ranks.map(parseFenRank),
+ /*
+ nextMove: fields[1] === 'b' ? 'black'
+ : fields[1] === 'w' ? 'white' : 'unknown',
+ castling: f2 === '-' ? {} : {
+ w: {
+ k: 0 < f2.indexOf('K'),
+ q: 0 < f2.indexOf('Q'),
+ },
+ b: {
+ k: 0 < f2.indexOf('k'),
+ q: 0 < f2.indexOf('q'),
+ }
+ },
+ enpassantTarget: fields[3] === '-' ? null : fields[3],
+ halfmoves: Number(fields[4]),
+ fullmoves: Number(fields[5]),
+ */
+ }
+}
+
+var chessSymbols = {
+ ' ': [' ', ''],
+ P: ['♙', 'white pawn'],
+ N: ['♘', 'white knight'],
+ B: ['♗', 'white bishop'],
+ R: ['♖', 'white rook'],
+ Q: ['♕', 'white queen'],
+ K: ['♔', 'white king'],
+ p: ['♟', 'black pawn'],
+ n: ['♞', 'black knight'],
+ b: ['♝', 'black bishop'],
+ r: ['♜', 'black rook'],
+ q: ['♛', 'black queen'],
+ k: ['♚', 'black king'],
+}
+
+function renderChessSymbol(c, loc) {
+ var info = chessSymbols[c] || ['?', 'unknown']
+ return h('span.symbol', {
+ title: info[1] + (loc ? ' at ' + loc : '')
+ }, info[0])
+}
+
+function chessLocToIdxs(loc) {
+ var m = /^([a-h])([1-8])$/.exec(loc)
+ if (m) return [8 - m[2], m[1].charCodeAt(0) - 97]
+}
+
+function lookupPiece(board, loc) {
+ var idxs = chessLocToIdxs(loc)
+ return idxs && board[idxs[0]] && board[idxs[0]][idxs[1]]
+}
+
+function chessIdxsToLoc(i, j) {
+ return 'abcdefgh'[j] + (8-i)
+}
+
+RenderMsg.prototype.chessBoard = function (board) {
+ if (!board) return ''
+ return h('table.chess-board',
+ board.map(function (rank, i) {
+ return h('tr', rank.map(function (piece, j) {
+ var dark = (i ^ j) & 1
+ return h('td', {
+ class: 'chess-square chess-square-' + (dark ? 'dark' : 'light'),
+ }, renderChessSymbol(piece, chessIdxsToLoc(i, j)))
+ }))
+ })
+ )
+}
+
+RenderMsg.prototype.chessMove = function (cb) {
+ var self = this
+ var c = self.c
+ var fen = c.fen && c.fen.length === 2 ? c.pgnMove : c.fen
+ var game = parseChess(fen)
+ var piece = game && lookupPiece(game.board, c.dest)
+ self.link(self.c.root, function (err, rootLink) {
+ if (err) return cb(err)
+ self.wrap([
+ h('div', h('small', '> ', rootLink)),
+ h('p',
+ // 'player ', (c.ply || ''), ' ',
+ 'moved ', (piece ? renderChessSymbol(piece) : ''), ' ',
+ 'from ', c.orig, ' ',
+ 'to ', c.dest
+ ),
+ self.chessBoard(game.board)
+ ], cb)
+ })
+}
+
+RenderMsg.prototype.chessInvite = function (cb) {
+ var self = this
+ var myColor = self.c.myColor
+ self.link(self.c.inviting, function (err, link) {
+ if (err) return cb(err)
+ self.wrap([
+ 'invites ', link, ' to play chess',
+ // myColor ? h('p', 'my color is ' + myColor) : ''
+ ], cb)
+ })
+}
+
+RenderMsg.prototype.chessInviteAccept = function (cb) {
+ var self = this
+ self.link(self.c.root, function (err, rootLink) {
+ if (err) return cb(err)
+ self.wrap([
+ h('div', h('small', '> ', rootLink)),
+ h('p', 'accepts invitation to play chess')
+ ], cb)
+ })
+}
+
+RenderMsg.prototype.chessGameEnd = function (cb) {
+ var self = this
+ var c = self.c
+ if (c.status === 'resigned') return self.link(self.c.root, function (err, rootLink) {
+ if (err) return cb(err)
+ self.wrap([
+ h('div', h('small', '> ', rootLink)),
+ h('p', h('strong', 'resigned'))
+ ], cb)
+ })
+
+ var fen = c.fen && c.fen.length === 2 ? c.pgnMove : c.fen
+ var game = parseChess(fen)
+ var piece = game && lookupPiece(game.board, c.dest)
+ var done = multicb({pluck: 1, spread: true})
+ self.link(self.c.root, done())
+ self.link(self.c.winner, done())
+ done(function (err, rootLink, winnerLink) {
+ if (err) return cb(err)
+ self.wrap([
+ h('div', h('small', '> ', rootLink)),
+ h('p',
+ 'moved ', (piece ? renderChessSymbol(piece) : ''), ' ',
+ 'from ', c.orig, ' ',
+ 'to ', c.dest
+ ),
+ h('p',
+ h('strong', self.c.status), '. winner: ', h('strong', winnerLink)),
+ self.chessBoard(game.board)
+ ], cb)
+ })
+}
+
+RenderMsg.prototype.chessMove = function (cb) {
+ var self = this
+ var c = self.c
+ var fen = c.fen && c.fen.length === 2 ? c.pgnMove : c.fen
+ var game = parseChess(fen)
+ var piece = game && lookupPiece(game.board, c.dest)
+ self.link(self.c.root, function (err, rootLink) {
+ if (err) return cb(err)
+ self.wrap([
+ h('div', h('small', '> ', rootLink)),
+ h('p',
+ // 'player ', (c.ply || ''), ' ',
+ 'moved ', (piece ? renderChessSymbol(piece) : ''), ' ',
+ 'from ', c.orig, ' ',
+ 'to ', c.dest
+ ),
+ self.chessBoard(game.board)
+ ], cb)
+ })
+}
diff --git a/lib/render.js b/lib/render.js
index 3b4a142..cc08259 100644
--- a/lib/render.js
+++ b/lib/render.js
@@ -10,6 +10,7 @@ var qs = require('querystring')
var u = require('./util')
var multicb = require('multicb')
var RenderMsg = require('./render-msg')
+var Highlight = require('highlight.js')
module.exports = Render
@@ -83,18 +84,33 @@ function Render(app, opts) {
smartypants: false,
emoji: lexerRenderEmoji,
renderer: new MdRenderer(this),
+ highlight: this.highlight.bind(this),
}
}
Render.prototype.emoji = function (emoji) {
var name = ':' + emoji + ':'
- return emoji in emojis ?
- h('img.ssb-emoji', {
+ var link = this._mentions && this._mentions[name]
+ if (link && link.link) {
+ this.app.reverseEmojiNameCache.set(emoji, link.link)
+ return h('img.ssb-emoji', {
+ src: this.opts.img_base + link.link,
+ alt: name
+ + (link.size != null ? ' (' + this.formatSize(link.size) + ')' : ''),
+ height: 17,
+ title: name,
+ })
+ }
+ if (emoji in emojis) {
+ return h('img.ssb-emoji', {
src: this.opts.emoji_base + emoji + '.png',
alt: name,
height: 17,
+ align: 'absmiddle',
title: name,
- }) : name
+ })
+ }
+ return name
}
/* disabled until it can be done safely without breaking html
@@ -113,12 +129,14 @@ Render.prototype.markdown = function (text, mentions) {
var mentionsByLink = this._mentionsByLink = {}
if (Array.isArray(mentions)) mentions.forEach(function (link) {
if (!link) return
+ else if (link.emoji)
+ mentionsObj[':' + link.name + ':'] = link
else if (link.name)
mentionsObj['@' + link.name] = link.link
else if (link.host === 'http://localhost:7777')
mentionsObj[link.href] = link.link
if (link.link)
- mentionsByLink[link.link] = link
+ mentionsByLink[link.link + (link.key ? '#' + link.key : '')] = link
})
var out = marked(String(text), this.markedOpts)
delete this._mentions
@@ -130,7 +148,7 @@ Render.prototype.imageUrl = function (ref) {
var m = /^blobstore:(.*)/.exec(ref)
if (m) ref = m[1]
ref = ref.replace(/#/, '%23')
- return this.opts.img_base + ref
+ return this.opts.img_base + 'image/' + ref
}
Render.prototype.getImageAlt = function (id, fallback) {
@@ -149,6 +167,22 @@ Render.prototype.formatSize = function (size) {
return size.toFixed(2) + ' MB'
}
+Render.prototype.linkify = function (text) {
+ var arr = text.split(u.ssbRefEncRegex)
+ for (var i = 1; i < arr.length; i += 2) {
+ arr[i] = h('a', {href: this.toUrlEnc(arr[i])}, arr[i])
+ }
+ return arr
+}
+
+Render.prototype.toUrlEnc = function (href) {
+ var url = this.toUrl(href)
+ if (url) return url
+ try { href = decodeURIComponent(href) }
+ catch (e) { return false }
+ return this.toUrl(href)
+}
+
Render.prototype.toUrl = function (href) {
if (!href) return href
var mentions = this._mentions
@@ -157,7 +191,8 @@ Render.prototype.toUrl = function (href) {
switch (href[0]) {
case '%':
if (!u.isRef(href)) return false
- return this.opts.base + encodeURIComponent(href)
+ return this.opts.base +
+ (this.opts.encode_msgids ? encodeURIComponent(href) : href)
case '@':
if (!u.isRef(href)) return false
return this.opts.base + href
@@ -280,3 +315,167 @@ Render.prototype.renderFeeds = function (opts) {
self.renderMsg(msg, opts, cb)
}, 4)
}
+
+Render.prototype.gitCommitBody = function (body) {
+ if (!body) return ''
+ var isMarkdown = !/^# Conflicts:$/m.test(body)
+ return isMarkdown
+ ? h('div', {innerHTML: this.markdown('\n' + body)})
+ : h('pre', this.linkify('\n' + body))
+}
+
+Render.prototype.getName = function (id, cb) {
+ // TODO: consolidate the get name/link functions
+ var self = this
+ switch (id && id[0]) {
+ case '%':
+ return self.app.getMsgDecrypted(id, function (err, msg) {
+ if (err && err.name == 'NotFoundError')
+ return cb(null, String(id).substring(0, 8) + '…(missing)')
+ if (err) return fallback()
+ new RenderMsg(self, self.app, msg, {wrap: false}).title(cb)
+ })
+ case '@': // fallthrough
+ case '&':
+ return self.app.getAbout(id, function (err, about) {
+ if (err || !about || !about.name) return fallback()
+ cb(null, about.name)
+ })
+ default:
+ return cb(null, String(id))
+ }
+ function fallback() {
+ cb(null, String(id).substr(0, 8) + '…')
+ }
+}
+
+Render.prototype.getNameLink = function (id, cb) {
+ var self = this
+ self.getName(id, function (err, name) {
+ if (err) return cb(err)
+ cb(null, h('a', {href: self.toUrl(id)}, name))
+ })
+}
+
+Render.prototype.npmAuthorLink = function (author) {
+ if (!author) return
+ var url = u.ifString(author.url)
+ var email = u.ifString(author.email)
+ var name = u.ifString(author.name)
+ var title
+ if (!url && u.isRef(name)) url = name, name = null
+ if (!url && !email) return name || JSON.stringify(author)
+ if (!url && email) url = 'mailto:' + email, email = null
+ if (!name && email) name = email, email = null
+ var feed = u.isRef(url) && url[0] === '@' && url
+ if (feed && name) title = this.app.getNameSync(feed)
+ if (feed && name && name[0] != '@') name = '@' + name
+ if (feed && !name) name = this.app.getNameSync(feed) // TODO: async
+ if (url && !name) name = url
+ var secondaryLink = email && h('a', {href: this.toUrl('mailto:' + email)}, email)
+ return [
+ h('a', {href: this.toUrl(url), title: title}, name),
+ secondaryLink ? [' (', secondaryLink, ')'] : ''
+ ]
+}
+
+// auto-highlight is slow
+var useAutoHighlight = false
+
+Render.prototype.highlight = function (code, lang) {
+ if (code.length > 100000) return u.escapeHTML(code)
+ if (!lang && /^#!\/bin\/[^\/]*sh$/m.test(code)) lang = 'sh'
+ try {
+ return lang
+ ? Highlight.highlight(lang, code).value
+ : useAutoHighlight
+ ? Highlight.highlightAuto(code).value
+ : u.escapeHTML(code)
+ } catch(e) {
+ if (!/^Unknown language/.test(e.message)) console.trace(e)
+ return u.escapeHTML(code)
+ }
+}
+
+Render.prototype.npmPackageMentions = function (links, cb) {
+ var self = this
+ var pkgLinks = u.toArray(links).filter(function (link) {
+ return /^npm:/.test(link.name)
+ })
+ if (pkgLinks.length === 0) return cb(null, '')
+ var done = multicb({pluck: 1})
+ pkgLinks.forEach(function (link) {
+ self.npmPackageMention(link, {}, done())
+ })
+ done(function (err, mentionEls) {
+ cb(null, h('table',
+ h('thead', h('tr',
+ h('td', 'package'),
+ h('td', 'version'),
+ h('td', 'tag'),
+ h('td', 'size'),
+ h('td', 'tarball'),
+ h('td', 'readme')
+ )),
+ h('tbody', mentionEls)
+ ))
+ })
+}
+
+Render.prototype.npmPackageMention = function (link, opts, cb) {
+ var parts = String(link.name).replace(/\.tgz$/, '').split(':')
+ var name = parts[1]
+ var version = parts[2]
+ var distTag = parts[3]
+ var self = this
+ var done = multicb({pluck: 1, spread: true})
+ var base = '/npm/' + (opts.author ? u.escapeId(link.author) + '/' : '')
+ var pathWithAuthor = opts.withAuthor ? '/npm/' +
+ u.escapeId(link.author) +
+ (opts.name ? '/' + opts.name +
+ (opts.version ? '/' + opts.version +
+ (opts.distTag ? '/' + opts.distTag + '/' : '') : '') : '') : ''
+ self.app.getAbout(link.author, done())
+ self.app.getBlobState(link.link, done())
+ done(function (err, about, blobState) {
+ if (err) return cb(err)
+ cb(null, h('tr', [
+ opts.withAuthor ? h('td', h('a', {
+ href: self.toUrl(pathWithAuthor),
+ title: 'publisher'
+ }, about.name), ' ') : '',
+ h('td', h('a', {
+ href: self.toUrl(base + name),
+ title: 'package name'
+ }, name), ' '),
+ h('td', version ? [h('a', {
+ href: self.toUrl(base + name + '/' + version),
+ title: 'package version'
+ }, version), ' '] : ''),
+ h('td', distTag ? [h('a', {
+ href: self.toUrl(base + name + '//' + distTag),
+ title: 'dist-tag'
+ }, distTag), ' '] : ''),
+ h('td', {align: 'right'}, link.size != null ? [h('span', {
+ title: 'tarball size'
+ }, self.formatSize(link.size)), ' '] : ''),
+ h('td', typeof link.link === 'string' ? h('code', h('a', {
+ href: self.toUrl('/links/' + link.link),
+ title: 'package tarball'
+ }, link.link.substr(0, 8) + '…')) : ''),
+ h('td',
+ blobState === 'wanted' ?
+ 'fetching...'
+ : blobState ? h('a', {
+ href: self.toUrl('/npm-readme/' + encodeURIComponent(link.link)),
+ title: 'package contents'
+ }, 'readme')
+ : h('form', {action: '', method: 'post'},
+ h('input', {type: 'hidden', name: 'action', value: 'want-blobs'}),
+ h('input', {type: 'hidden', name: 'async_want', value: '1'}),
+ h('input', {type: 'hidden', name: 'blob_ids', value: link.link}),
+ h('input', {type: 'submit', value: 'fetch'})
+ ))
+ ]))
+ })
+}
diff --git a/lib/serve.js b/lib/serve.js
index 1ce18c6..839725a 100644
--- a/lib/serve.js
+++ b/lib/serve.js
@@ -19,10 +19,13 @@ var mime = require('mime-types')
var ident = require('pull-identify-filetype')
var htime = require('human-time')
var ph = require('pull-hyperscript')
+var emojis = require('emoji-named-characters')
+var jpeg = require('jpeg-autorotate')
module.exports = Serve
var emojiDir = path.join(require.resolve('emoji-named-characters'), '../pngs')
+var hlCssDir = path.join(require.resolve('highlight.js'), '../../styles')
var urlIdRegex = /^(?:\/+(([%&@]|%25)(?:[A-Za-z0-9\/+]|%2[Ff]|%2[Bb]){43}(?:=|%3D)\.(?:sha256|ed25519))([^?]*)?|(\/.*?))(?:\?(.*))?$/
@@ -74,6 +77,11 @@ Serve.prototype.go = function () {
filesCb(function (err) {
gotData(err, data)
})
+ function addField(name, value) {
+ if (!(name in data)) data[name] = value
+ else if (Array.isArray(data[name])) data[name].push(value)
+ else data[name] = [data[name], value]
+ }
busboy.on('file', function (fieldname, file, filename, encoding, mimetype) {
var cb = filesCb()
pull(
@@ -83,15 +91,13 @@ Serve.prototype.go = function () {
if (link.size === 0 && !filename) return cb()
link.name = filename
link.type = mimetype
- data[fieldname] = link
+ addField(fieldname, link)
cb()
})
)
})
busboy.on('field', function (fieldname, val, fieldnameTruncated, valTruncated, encoding, mimetype) {
- if (!(fieldname in data)) data[fieldname] = val
- else if (Array.isArray(data[fieldname])) data[fieldname].push(val)
- else data[fieldname] = [data[fieldname], val]
+ addField(fieldname, val)
})
this.req.pipe(busboy)
} else {
@@ -116,8 +122,10 @@ Serve.prototype.go = function () {
self.data = data
if (err) next(err)
else if (data.action === 'publish') self.publishJSON(next)
- else if (data.action === 'vote') self.publishVote(next)
else if (data.action === 'contact') self.publishContact(next)
+ else if (data.action === 'want-blobs') self.wantBlobs(next)
+ else if (data.action_vote) self.publishVote(next)
+ else if (data.action_attend) self.publishAttend(next)
else next()
}
@@ -154,8 +162,8 @@ Serve.prototype.publishVote = function (cb) {
channel: this.data.channel || undefined,
vote: {
link: this.data.link,
- value: Number(this.data.value),
- expression: this.data.expression,
+ value: Number(this.data.vote_value),
+ expression: this.data.vote_expression,
}
}
if (this.data.recps) content.recps = this.data.recps.split(',')
@@ -171,6 +179,36 @@ Serve.prototype.publishContact = function (cb) {
this.publish(content, cb)
}
+Serve.prototype.publishAttend = function (cb) {
+ var content = {
+ type: 'about',
+ channel: this.data.channel || undefined,
+ about: this.data.link,
+ attendee: {
+ link: this.app.sbot.id
+ }
+ }
+ if (this.data.recps) content.recps = this.data.recps.split(',')
+ this.publish(content, cb)
+}
+
+Serve.prototype.wantBlobs = function (cb) {
+ var self = this
+ if (!self.data.blob_ids) return cb()
+ var ids = self.data.blob_ids.split(',')
+ if (!ids.every(u.isRef)) return cb(new Error('bad blob ids ' + ids.join(',')))
+ var done = multicb({pluck: 1})
+ ids.forEach(function (id) {
+ self.app.wantSizeBlob(id, done())
+ })
+ if (self.data.async_want) return cb()
+ done(function (err) {
+ if (err) return cb(err)
+ // self.note = h('div', 'wanted blobs: ' + ids.join(', ') + '.')
+ cb()
+ })
+}
+
Serve.prototype.publish = function (content, cb) {
var self = this
var done = multicb({pluck: 1, spread: true})
@@ -238,14 +276,18 @@ Serve.prototype.path = function (url) {
case '/new': return this.new(m[2])
case '/public': return this.public(m[2])
case '/private': return this.private(m[2])
+ case '/mentions': return this.mentions(m[2])
case '/search': return this.search(m[2])
case '/advsearch': return this.advsearch(m[2])
case '/vote': return this.vote(m[2])
case '/peers': return this.peers(m[2])
+ case '/status': return this.status(m[2])
case '/channels': return this.channels(m[2])
case '/friends': return this.friends(m[2])
case '/live': return this.live(m[2])
case '/compose': return this.compose(m[2])
+ case '/emojis': return this.emojis(m[2])
+ case '/votes': return this.votes(m[2])
}
m = /^(\/?[^\/]*)(\/.*)?$/.exec(url)
switch (m[1]) {
@@ -254,8 +296,13 @@ Serve.prototype.path = function (url) {
case '/links': return this.links(m[2])
case '/static': return this.static(m[2])
case '/emoji': return this.emoji(m[2])
+ case '/highlight': return this.highlight(m[2])
case '/contacts': return this.contacts(m[2])
case '/about': return this.about(m[2])
+ case '/git': return this.git(m[2])
+ case '/image': return this.image(m[2])
+ case '/npm': return this.npm(m[2])
+ case '/npm-readme': return this.npmReadme(m[2])
}
return this.respond(404, 'Not found')
}
@@ -347,18 +394,13 @@ Serve.prototype.private = function (ext) {
var q = this.query
var opts = {
reverse: !q.forwards,
- sortByTimestamp: q.sort === 'claimed',
lt: Number(q.lt) || Date.now(),
gt: Number(q.gt) || -Infinity,
+ limit: Number(q.limit) || 12,
}
- var limit = Number(q.limit) || 12
pull(
- this.app.createLogStream(opts),
- pull.filter(u.isMsgEncrypted),
- this.app.unboxMessages(),
- pull.filter(u.isMsgReadable),
- pull.take(limit),
+ this.app.streamPrivate(opts),
this.renderThreadPaginated(opts, null, q),
this.wrapMessages(),
this.wrapPrivate(opts),
@@ -369,6 +411,32 @@ Serve.prototype.private = function (ext) {
)
}
+Serve.prototype.mentions = function (ext) {
+ var self = this
+ var q = self.query
+ var opts = {
+ reverse: !q.forwards,
+ sortByTimestamp: q.sort === 'claimed',
+ lt: Number(q.lt) || Date.now(),
+ gt: Number(q.gt) || -Infinity,
+ limit: Number(q.limit) || 12,
+ }
+
+ return pull(
+ ph('section', {}, [
+ ph('h3', 'Mentions'),
+ pull(
+ self.app.streamMentions(opts),
+ self.app.unboxMessages(),
+ self.renderThreadPaginated(opts, null, q),
+ self.wrapMessages()
+ )
+ ]),
+ self.wrapPage('mentions'),
+ self.respondSink(200)
+ )
+}
+
Serve.prototype.search = function (ext) {
var searchQ = (this.query.q || '').trim()
var self = this
@@ -487,6 +555,90 @@ Serve.prototype.compose = function (ext) {
})
}
+Serve.prototype.votes = function (path) {
+ if (path) return pull(
+ pull.once(u.renderError(new Error('Not implemented')).outerHTML),
+ this.wrapPage('#' + channel),
+ this.respondSink(404, {'Content-Type': ctype('html')})
+ )
+
+ var self = this
+ var q = self.query
+ var opts = {
+ reverse: !q.forwards,
+ limit: Number(q.limit) || 50,
+ }
+ var gt = Number(q.gt)
+ if (gt) opts.gt = gt
+ var lt = Number(q.lt)
+ if (lt) opts.lt = lt
+
+ self.app.getVoted(opts, function (err, voted) {
+ if (err) return pull(
+ pull.once(u.renderError(err).outerHTML),
+ self.wrapPage('#' + channel),
+ self.respondSink(500, {'Content-Type': ctype('html')})
+ )
+
+ pull(
+ ph('table', [
+ ph('thead', [
+ ph('tr', [
+ ph('td', {colspan: 2}, self.syncPager({
+ first: voted.firstTimestamp,
+ last: voted.lastTimestamp,
+ }))
+ ])
+ ]),
+ ph('tbody', pull(
+ pull.values(voted.items),
+ paramap(function (item, cb) {
+ cb(null, ph('tr', [
+ ph('td', [String(item.value)]),
+ ph('td', [
+ self.phIdLink(item.id),
+ pull.once(' dug by '),
+ self.renderIdsList()(pull.values(item.feeds))
+ ])
+ ]))
+ }, 8)
+ )),
+ ph('tfoot', {}, []),
+ ]),
+ self.wrapPage('votes'),
+ self.respondSink(200, {
+ 'Content-Type': ctype('html')
+ })
+ )
+ })
+}
+
+Serve.prototype.syncPager = function (opts) {
+ var q = this.query
+ var reverse = !q.forwards
+ var min = (reverse ? opts.last : opts.first) || Number(q.gt)
+ var max = (reverse ? opts.first : opts.last) || Number(q.lt)
+ var minDate = new Date(min)
+ var maxDate = new Date(max)
+ var qOlder = u.mergeOpts(q, {lt: min, gt: undefined, forwards: undefined})
+ var qNewer = u.mergeOpts(q, {gt: max, lt: undefined, forwards: 1})
+ var atNewest = reverse ? !q.lt : !max
+ var atOldest = reverse ? !min : !q.gt
+ if (atNewest && !reverse) qOlder.lt++
+ if (atOldest && reverse) qNewer.gt--
+ return h('div',
+ atOldest ? 'oldest' : [
+ h('a', {href: '?' + qs.stringify(qOlder)}, '<<'), ' ',
+ h('span', {title: minDate.toString()}, htime(minDate)), ' ',
+ ],
+ ' - ',
+ atNewest ? 'now' : [
+ h('span', {title: maxDate.toString()}, htime(maxDate)), ' ',
+ h('a', {href: '?' + qs.stringify(qNewer)}, '>>')
+ ]
+ ).outerHTML
+}
+
Serve.prototype.peers = function (ext) {
var self = this
if (self.data.action === 'connect') {
@@ -532,6 +684,34 @@ Serve.prototype.peers = function (ext) {
)
}
+Serve.prototype.status = function (ext) {
+ var self = this
+
+ if (!self.app.sbot.status) return pull(
+ pull.once('missing sbot status method'),
+ this.wrapPage('status'),
+ self.respondSink(400)
+ )
+
+ pull(
+ ph('section', [
+ ph('h3', 'Status'),
+ pull(
+ u.readNext(function (cb) {
+ self.app.sbot.status(function (err, status) {
+ cb(err, status && pull.once(status))
+ })
+ }),
+ pull.map(function (status) {
+ return h('pre', self.app.render.linkify(JSON.stringify(status, 0, 2))).outerHTML
+ })
+ )
+ ]),
+ this.wrapPage('status'),
+ this.respondSink(200)
+ )
+}
+
Serve.prototype.channels = function (ext) {
var self = this
var id = self.app.sbot.id
@@ -581,14 +761,6 @@ Serve.prototype.channels = function (ext) {
)
}
-Serve.prototype.phIdLink = function (id) {
- return pull(
- pull.once(id),
- pull.asyncMap(this.renderIdLink.bind(this)),
- pull.map(u.toHTML)
- )
-}
-
Serve.prototype.contacts = function (path) {
var self = this
var id = String(path).substr(1)
@@ -655,7 +827,7 @@ Serve.prototype.about = function (path) {
function renderAboutOpContent(op) {
if (op.prop === 'image')
- return renderAboutOpImage(op.value)
+ return renderAboutOpImage(u.toLink(op.value))
if (op.prop === 'description')
return h('div', {innerHTML: render.markdown(op.value)}).outerHTML
if (op.prop === 'title')
@@ -772,23 +944,11 @@ Serve.prototype.channel = function (path) {
lt: lt,
gt: gt,
limit: Number(q.limit) || 12,
- query: [{$filter: {
- value: {content: {channel: channel}},
- timestamp: {
- $gt: gt,
- $lt: lt,
- }
- }}]
+ channel: channel,
}
- if (!this.app.sbot.query) return pull(
- pull.once(u.renderError(new Error('Missing ssb-query plugin')).outerHTML),
- this.wrapPage('#' + channel),
- this.respondSink(400, {'Content-Type': ctype('html')})
- )
-
pull(
- this.app.sbot.query.read(opts),
+ this.app.streamChannel(opts),
this.renderThreadPaginated(opts, null, q),
this.wrapMessages(),
this.wrapChannel(channel),
@@ -900,9 +1060,12 @@ Serve.prototype.emoji = function (emoji) {
serveEmoji(this.req, this.res, emoji)
}
+Serve.prototype.highlight = function (dirs) {
+ this.file(path.join(hlCssDir, dirs))
+}
+
Serve.prototype.blob = function (id, path) {
var self = this
- var blobs = self.app.sbot.blobs
var etag = id + (path || '')
if (self.req.headers['if-none-match'] === etag) return self.respond(304)
var key
@@ -921,22 +1084,18 @@ Serve.prototype.blob = function (id, path) {
return self.respond(400, 'Bad blob request')
}
}
- var done = multicb({pluck: 1, spread: true})
- blobs.want(id, function (err, has) {
+ self.app.wantSizeBlob(id, function (err, size) {
if (err) {
if (/^invalid/.test(err.message)) return self.respond(400, err.message)
else return self.respond(500, err.message || err)
}
- if (!has) return self.respond(404, 'Not found')
- blobs.size(id, done())
pull(
self.app.getBlob(id, key),
pull.map(Buffer),
- ident(done().bind(self, null)),
+ ident(gotType),
self.respondSink()
)
- done(function (err, size, type) {
- if (err) console.trace(err)
+ function gotType(type) {
type = type && mime.lookup(type)
if (type) self.res.setHeader('Content-Type', type)
// don't serve size for encrypted blob, because it refers to the size of
@@ -948,6 +1107,77 @@ Serve.prototype.blob = function (id, path) {
self.res.setHeader('Cache-Control', 'public, max-age=315360000')
self.res.setHeader('etag', etag)
self.res.writeHead(200)
+ }
+ })
+}
+
+Serve.prototype.image = function (path) {
+ var self = this
+ var id, key
+ var m = urlIdRegex.exec(path)
+ if (m && m[2] === '&') id = m[1], path = m[3]
+ var etag = 'image-' + id + (path || '')
+ if (self.req.headers['if-none-match'] === etag) return self.respond(304)
+ if (path) {
+ path = decodeURIComponent(path)
+ if (path[0] === '#') {
+ try {
+ key = new Buffer(path.substr(1), 'base64')
+ } catch(err) {
+ return self.respond(400, err.message)
+ }
+ if (key.length !== 32) {
+ return self.respond(400, 'Bad blob key')
+ }
+ } else {
+ return self.respond(400, 'Bad blob request')
+ }
+ }
+ self.app.wantSizeBlob(id, function (err, size) {
+ if (err) {
+ if (/^invalid/.test(err.message)) return self.respond(400, err.message)
+ else return self.respond(500, err.message || err)
+ }
+
+ var done = multicb({pluck: 1, spread: true})
+ var heresTheData = done()
+ var heresTheType = done().bind(self, null)
+
+ pull(
+ self.app.getBlob(id, key),
+ pull.map(Buffer),
+ ident(heresTheType),
+ pull.collect(onFullBuffer)
+ )
+
+ function onFullBuffer (err, buffer) {
+ if (err) return heresTheData(err)
+ buffer = Buffer.concat(buffer)
+
+ jpeg.rotate(buffer, {}, function (err, rotatedBuffer, orientation) {
+ if (!err) buffer = rotatedBuffer
+
+ heresTheData(null, buffer)
+ pull(
+ pull.once(buffer),
+ self.respondSink()
+ )
+ })
+ }
+
+ done(function (err, data, type) {
+ if (err) {
+ console.trace(err)
+ self.respond(500, err.message || err)
+ }
+ type = type && mime.lookup(type)
+ if (type) self.res.setHeader('Content-Type', type)
+ self.res.setHeader('Content-Length', data.length)
+ if (self.query.name) self.res.setHeader('Content-Disposition',
+ 'inline; filename='+encodeDispositionFilename(self.query.name))
+ self.res.setHeader('Cache-Control', 'public, max-age=315360000')
+ self.res.setHeader('etag', etag)
+ self.res.writeHead(200)
})
})
}
@@ -972,38 +1202,28 @@ Serve.prototype.renderThread = function () {
)
}
-function mergeOpts(a, b) {
- var obj = {}, k
- for (k in a) {
- obj[k] = a[k]
- }
- for (k in b) {
- if (b[k] != null) obj[k] = b[k]
- else delete obj[k]
- }
- return obj
-}
-
Serve.prototype.renderThreadPaginated = function (opts, feedId, q) {
var self = this
function linkA(opts, name) {
- var q1 = mergeOpts(q, opts)
+ var q1 = u.mergeOpts(q, opts)
return h('a', {href: '?' + qs.stringify(q1)}, name || q1.limit)
}
function links(opts) {
var limit = opts.limit || q.limit || 10
return h('tr', h('td.paginate', {colspan: 3},
opts.forwards ? '↑ newer ' : '↓ older ',
- linkA(mergeOpts(opts, {limit: 1})), ' ',
- linkA(mergeOpts(opts, {limit: 10})), ' ',
- linkA(mergeOpts(opts, {limit: 100}))
+ linkA(u.mergeOpts(opts, {limit: 1})), ' ',
+ linkA(u.mergeOpts(opts, {limit: 10})), ' ',
+ linkA(u.mergeOpts(opts, {limit: 100}))
))
}
return pull(
paginate(
function onFirst(msg, cb) {
- var num = feedId ? msg.value.sequence : msg.timestamp || msg.ts
+ var num = feedId ? msg.value.sequence :
+ opts.sortByTimestamp ? msg.value.timestamp :
+ msg.timestamp || msg.ts
if (q.forwards) {
cb(null, links({
lt: num,
@@ -1020,7 +1240,9 @@ Serve.prototype.renderThreadPaginated = function (opts, feedId, q) {
},
this.app.render.renderFeeds(),
function onLast(msg, cb) {
- var num = feedId ? msg.value.sequence : msg.timestamp || msg.ts
+ var num = feedId ? msg.value.sequence :
+ opts.sortByTimestamp ? msg.value.timestamp :
+ msg.timestamp || msg.ts
if (q.forwards) {
cb(null, links({
lt: null,
@@ -1056,8 +1278,13 @@ Serve.prototype.renderThreadPaginated = function (opts, feedId, q) {
}
Serve.prototype.renderRawMsgPage = function (id) {
+ var showMarkdownSource = (this.query.raw === 'md')
+ var raw = !showMarkdownSource
return pull(
- this.app.render.renderFeeds(true),
+ this.app.render.renderFeeds({
+ raw: raw,
+ markdownSource: showMarkdownSource
+ }),
pull.map(u.toHTML),
this.wrapMessages(),
this.wrapPage(id)
@@ -1078,6 +1305,20 @@ function catchHTMLError() {
}
}
+function catchTextError() {
+ return function (read) {
+ var ended
+ return function (abort, cb) {
+ if (ended) return cb(ended)
+ read(abort, function (end, data) {
+ if (!end || end === true) return cb(end, data)
+ ended = true
+ cb(null, end.stack + '\n')
+ })
+ }
+ }
+}
+
function styles() {
return fs.readFileSync(path.join(__dirname, '../static/styles.css'), 'utf8')
}
@@ -1108,19 +1349,25 @@ Serve.prototype.wrapPage = function (title, searchQ) {
h('title', title),
h('meta', {name: 'viewport', content: 'width=device-width,initial-scale=1'}),
h('link', {rel: 'icon', href: render.toUrl('/static/hermie.ico'), type: 'image/x-icon'}),
- h('style', styles())
+ h('style', styles()),
+ h('link', {rel: 'stylesheet', href: render.toUrl('/highlight/foundation.css')})
),
h('body',
h('nav.nav-bar', h('form', {action: render.toUrl('/search'), method: 'get'},
h('a', {href: render.toUrl('/new')}, 'new') , ' ',
h('a', {href: render.toUrl('/public')}, 'public'), ' ',
h('a', {href: render.toUrl('/private')}, 'private') , ' ',
+ h('a', {href: render.toUrl('/mentions')}, 'mentions') , ' ',
h('a', {href: render.toUrl('/peers')}, 'peers') , ' ',
+ self.app.sbot.status ?
+ [h('a', {href: render.toUrl('/status')}, 'status'), ' '] : '',
h('a', {href: render.toUrl('/channels')}, 'channels') , ' ',
h('a', {href: render.toUrl('/friends')}, 'friends'), ' ',
h('a', {href: render.toUrl('/advsearch')}, 'search'), ' ',
h('a', {href: render.toUrl('/live')}, 'live'), ' ',
h('a', {href: render.toUrl('/compose')}, 'compose'), ' ',
+ h('a', {href: render.toUrl('/votes')}, 'votes'), ' ',
+ h('a', {href: render.toUrl('/emojis')}, 'emojis'), ' ',
render.idLink(self.app.sbot.id, done()), ' ',
h('input.search-input', {name: 'q', value: searchQ,
placeholder: 'search'})
@@ -1132,6 +1379,7 @@ Serve.prototype.wrapPage = function (title, searchQ) {
'published ',
self.app.render.msgLink(self.publishedMsg, done())
) : '',
+ // self.note,
content
)))
done(cb)
@@ -1139,25 +1387,18 @@ Serve.prototype.wrapPage = function (title, searchQ) {
)
}
-Serve.prototype.renderIdLink = function (id, cb) {
- var render = this.app.render
- var el = render.idLink(id, function (err) {
- if (err || !el) {
- el = h('a', {href: render.toUrl(id)}, id)
- }
- cb(null, el)
- })
+Serve.prototype.phIdLink = function (id) {
+ return pull(
+ pull.once(id),
+ this.renderIdsList()
+ )
}
Serve.prototype.friends = function (path) {
var self = this
pull(
self.app.sbot.friends.createFriendStream({hops: 1}),
- self.renderFriends(),
- pull.map(function (el) {
- return [el, ' ']
- }),
- pull.map(u.toHTML),
+ self.renderIdsList(),
u.hyperwrap(function (items, cb) {
cb(null, [
h('section',
@@ -1173,14 +1414,17 @@ Serve.prototype.friends = function (path) {
)
}
-Serve.prototype.renderFriends = function () {
+Serve.prototype.renderIdsList = function () {
var self = this
- return paramap(function (id, cb) {
- self.renderIdLink(id, function (err, el) {
- if (err) el = u.renderError(err, ext)
- cb(null, el)
- })
- }, 8)
+ return pull(
+ paramap(function (id, cb) {
+ self.app.render.getNameLink(id, cb)
+ }, 8),
+ pull.map(function (el) {
+ return [el, ' ']
+ }),
+ pull.map(u.toHTML)
+ )
}
var relationships = [
@@ -1260,6 +1504,532 @@ Serve.prototype.wrapUserFeed = function (isScrolled, id) {
})
}
+Serve.prototype.git = function (url) {
+ var m = /^\/?([^\/]*)\/?(.*)?$/.exec(url)
+ switch (m[1]) {
+ case 'commit': return this.gitCommit(m[2])
+ case 'tag': return this.gitTag(m[2])
+ case 'tree': return this.gitTree(m[2])
+ case 'blob': return this.gitBlob(m[2])
+ case 'raw': return this.gitRaw(m[2])
+ default: return this.respond(404, 'Not found')
+ }
+}
+
+Serve.prototype.gitRaw = function (rev) {
+ var self = this
+ if (!/[0-9a-f]{24}/.test(rev)) {
+ return pull(
+ pull.once('\'' + rev + '\' is not a git object id'),
+ self.respondSink(400, {'Content-Type': 'text/plain'})
+ )
+ }
+ if (!u.isRef(self.query.msg)) return pull(
+ ph('div.error', 'missing message id'),
+ self.wrapPage('git tree ' + rev),
+ self.respondSink(400)
+ )
+
+ self.app.git.openObject({
+ obj: rev,
+ msg: self.query.msg,
+ }, function (err, obj) {
+ if (err && err.name === 'BlobNotFoundError')
+ return self.askWantBlobs(err.links)
+ if (err) return pull(
+ pull.once(err.stack),
+ self.respondSink(400, {'Content-Type': 'text/plain'})
+ )
+ pull(
+ self.app.git.readObject(obj),
+ catchTextError(),
+ ident(function (type) {
+ type = type && mime.lookup(type)
+ if (type) self.res.setHeader('Content-Type', type)
+ self.res.setHeader('Cache-Control', 'public, max-age=315360000')
+ self.res.setHeader('etag', rev)
+ self.res.writeHead(200)
+ }),
+ self.respondSink()
+ )
+ })
+}
+
+Serve.prototype.gitAuthorLink = function (author) {
+ if (author.feed) {
+ var myName = this.app.getNameSync(author.feed)
+ var sigil = author.name === author.localpart ? '@' : ''
+ return ph('a', {
+ href: this.app.render.toUrl(author.feed),
+ title: author.localpart + (myName ? ' (' + myName + ')' : '')
+ }, u.escapeHTML(sigil + author.name))
+ } else {
+ return ph('a', {href: this.app.render.toUrl('mailto:' + author.email)},
+ u.escapeHTML(author.name))
+ }
+}
+
+Serve.prototype.gitCommit = function (rev) {
+ var self = this
+ if (!/[0-9a-f]{24}/.test(rev)) {
+ return pull(
+ ph('div.error', 'rev is not a git object id'),
+ self.wrapPage('git'),
+ self.respondSink(400)
+ )
+ }
+ if (!u.isRef(self.query.msg)) return pull(
+ ph('div.error', 'missing message id'),
+ self.wrapPage('git commit ' + rev),
+ self.respondSink(400)
+ )
+
+ self.app.git.openObject({
+ obj: rev,
+ msg: self.query.msg,
+ }, function (err, obj) {
+ if (err && err.name === 'BlobNotFoundError')
+ return self.askWantBlobs(err.links)
+ if (err) return pull(
+ pull.once(u.renderError(err).outerHTML),
+ self.wrapPage('git commit ' + rev),
+ self.respondSink(400)
+ )
+ var msgDate = new Date(obj.msg.value.timestamp)
+ self.app.git.getCommit(obj, function (err, commit) {
+ var missingBlobs
+ if (err && err.name === 'BlobNotFoundError')
+ missingBlobs = err.links, err = null
+ if (err) return pull(
+ pull.once(u.renderError(err).outerHTML),
+ self.wrapPage('git commit ' + rev),
+ self.respondSink(400)
+ )
+ pull(
+ ph('section', [
+ ph('h3', ph('a', {href: ''}, rev)),
+ ph('div', [
+ self.phIdLink(obj.msg.value.author), ' pushed ',
+ ph('a', {
+ href: self.app.render.toUrl(obj.msg.key),
+ title: msgDate.toLocaleString(),
+ }, htime(msgDate))
+ ]),
+ missingBlobs ? self.askWantBlobsForm(missingBlobs) : [
+ ph('div', [
+ self.gitAuthorLink(commit.committer),
+ ' committed ',
+ ph('span', {title: commit.committer.date.toLocaleString()},
+ htime(commit.committer.date)),
+ ' in ', commit.committer.tz
+ ]),
+ commit.author ? ph('div', [
+ self.gitAuthorLink(commit.author),
+ ' authored ',
+ ph('span', {title: commit.author.date.toLocaleString()},
+ htime(commit.author.date)),
+ ' in ', commit.author.tz
+ ]) : '',
+ commit.parents.length ? ph('div', ['parents: ', pull(
+ pull.values(commit.parents),
+ self.gitObjectLinks(obj.msg.key, 'commit')
+ )]) : '',
+ commit.tree ? ph('div', ['tree: ', pull(
+ pull.once(commit.tree),
+ self.gitObjectLinks(obj.msg.key, 'tree')
+ )]) : '',
+ h('blockquote',
+ self.app.render.gitCommitBody(commit.body)).outerHTML,
+ ph('h4', 'files'),
+ ph('table', pull(
+ self.app.git.readCommitChanges(commit),
+ pull.map(function (file) {
+ return ph('tr', [
+ ph('td', ph('code', u.escapeHTML(file.name))),
+ // ph('td', ph('code', u.escapeHTML(JSON.stringify(file.msg)))),
+ ph('td', file.deleted ? 'deleted'
+ : file.created ? 'created'
+ : file.hash ? 'changed'
+ : file.mode ? 'mode changed'
+ : JSON.stringify(file))
+ ])
+ })
+ ))
+ ]
+ ]),
+ self.wrapPage('git commit ' + rev),
+ self.respondSink(missingBlobs ? 409 : 200)
+ )
+ })
+ })
+}
+
+Serve.prototype.gitTag = function (rev) {
+ var self = this
+ if (!/[0-9a-f]{24}/.test(rev)) {
+ return pull(
+ ph('div.error', 'rev is not a git object id'),
+ self.wrapPage('git'),
+ self.respondSink(400)
+ )
+ }
+ if (!u.isRef(self.query.msg)) return pull(
+ ph('div.error', 'missing message id'),
+ self.wrapPage('git tag ' + rev),
+ self.respondSink(400)
+ )
+
+ self.app.git.openObject({
+ obj: rev,
+ msg: self.query.msg,
+ }, function (err, obj) {
+ if (err && err.name === 'BlobNotFoundError')
+ return self.askWantBlobs(err.links)
+ if (err) return pull(
+ pull.once(u.renderError(err).outerHTML),
+ self.wrapPage('git tag ' + rev),
+ self.respondSink(400)
+ )
+ var msgDate = new Date(obj.msg.value.timestamp)
+ self.app.git.getTag(obj, function (err, tag) {
+ var missingBlobs
+ if (err && err.name === 'BlobNotFoundError')
+ missingBlobs = err.links, err = null
+ if (err) return pull(
+ pull.once(u.renderError(err).outerHTML),
+ self.wrapPage('git tag ' + rev),
+ self.respondSink(400)
+ )
+ pull(
+ ph('section', [
+ ph('h3', ph('a', {href: ''}, rev)),
+ ph('div', [
+ self.phIdLink(obj.msg.value.author), ' pushed ',
+ ph('a', {
+ href: self.app.render.toUrl(obj.msg.key),
+ title: msgDate.toLocaleString(),
+ }, htime(msgDate))
+ ]),
+ missingBlobs ? self.askWantBlobsForm(missingBlobs) : [
+ ph('div', [
+ self.gitAuthorLink(tag.tagger),
+ ' tagged ',
+ ph('span', {title: tag.tagger.date.toLocaleString()},
+ htime(tag.tagger.date)),
+ ' in ', tag.tagger.tz
+ ]),
+ tag.type, ' ',
+ pull(
+ pull.once(tag.object),
+ self.gitObjectLinks(obj.msg.key, tag.type)
+ ), ' ',
+ ph('code', u.escapeHTML(tag.tag)),
+ h('pre', self.app.render.linkify(tag.body)).outerHTML,
+ ]
+ ]),
+ self.wrapPage('git tag ' + rev),
+ self.respondSink(missingBlobs ? 409 : 200)
+ )
+ })
+ })
+}
+
+Serve.prototype.gitTree = function (rev) {
+ var self = this
+ if (!/[0-9a-f]{24}/.test(rev)) {
+ return pull(
+ ph('div.error', 'rev is not a git object id'),
+ self.wrapPage('git'),
+ self.respondSink(400)
+ )
+ }
+ if (!u.isRef(self.query.msg)) return pull(
+ ph('div.error', 'missing message id'),
+ self.wrapPage('git tree ' + rev),
+ self.respondSink(400)
+ )
+
+ self.app.git.openObject({
+ obj: rev,
+ msg: self.query.msg,
+ }, function (err, obj) {
+ var missingBlobs
+ if (err && err.name === 'BlobNotFoundError')
+ missingBlobs = err.links, err = null
+ if (err) return pull(
+ pull.once(u.renderError(err).outerHTML),
+ self.wrapPage('git tree ' + rev),
+ self.respondSink(400)
+ )
+ var msgDate = new Date(obj.msg.value.timestamp)
+ pull(
+ ph('section', [
+ ph('h3', ph('a', {href: ''}, rev)),
+ ph('div', [
+ self.phIdLink(obj.msg.value.author), ' ',
+ ph('a', {
+ href: self.app.render.toUrl(obj.msg.key),
+ title: msgDate.toLocaleString(),
+ }, htime(msgDate))
+ ]),
+ missingBlobs ? self.askWantBlobsForm(missingBlobs) : ph('table', [
+ pull(
+ self.app.git.readTree(obj),
+ paramap(function (file, cb) {
+ self.app.git.getObjectMsg({
+ obj: file.hash,
+ headMsgId: obj.msg.key,
+ }, function (err, msg) {
+ if (err && err.name === 'ObjectNotFoundError') return cb(null, file)
+ if (err) return cb(err)
+ file.msg = msg
+ cb(null, file)
+ })
+ }, 8),
+ pull.map(function (item) {
+ if (!item.msg) return ph('tr', [
+ ph('td',
+ u.escapeHTML(item.name) + (item.type === 'tree' ? '/' : '')),
+ ph('td', u.escapeHTML(item.hash)),
+ ph('td', 'missing')
+ ])
+ var ext = item.name.replace(/.*\./, '')
+ var path = '/git/' + item.type + '/' + item.hash
+ + '?msg=' + encodeURIComponent(item.msg.key)
+ + (ext ? '&ext=' + ext : '')
+ var fileDate = new Date(item.msg.value.timestamp)
+ return ph('tr', [
+ ph('td',
+ ph('a', {href: self.app.render.toUrl(path)},
+ u.escapeHTML(item.name) + (item.type === 'tree' ? '/' : ''))),
+ ph('td',
+ self.phIdLink(item.msg.value.author)),
+ ph('td',
+ ph('a', {
+ href: self.app.render.toUrl(item.msg.key),
+ title: fileDate.toLocaleString(),
+ }, htime(fileDate))
+ ),
+ ])
+ })
+ )
+ ]),
+ ]),
+ self.wrapPage('git tree ' + rev),
+ self.respondSink(missingBlobs ? 409 : 200)
+ )
+ })
+}
+
+Serve.prototype.gitBlob = function (rev) {
+ var self = this
+ if (!/[0-9a-f]{24}/.test(rev)) {
+ return pull(
+ ph('div.error', 'rev is not a git object id'),
+ self.wrapPage('git'),
+ self.respondSink(400)
+ )
+ }
+ if (!u.isRef(self.query.msg)) return pull(
+ ph('div.error', 'missing message id'),
+ self.wrapPage('git object ' + rev),
+ self.respondSink(400)
+ )
+
+ self.app.getMsgDecrypted(self.query.msg, function (err, msg) {
+ if (err) return pull(
+ pull.once(u.renderError(err).outerHTML),
+ self.wrapPage('git object ' + rev),
+ self.respondSink(400)
+ )
+ var msgDate = new Date(msg.value.timestamp)
+ self.app.git.openObject({
+ obj: rev,
+ msg: msg.key,
+ }, function (err, obj) {
+ var missingBlobs
+ if (err && err.name === 'BlobNotFoundError')
+ missingBlobs = err.links, err = null
+ if (err) return pull(
+ pull.once(u.renderError(err).outerHTML),
+ self.wrapPage('git object ' + rev),
+ self.respondSink(400)
+ )
+ pull(
+ ph('section', [
+ ph('h3', ph('a', {href: ''}, rev)),
+ ph('div', [
+ self.phIdLink(msg.value.author), ' ',
+ ph('a', {
+ href: self.app.render.toUrl(msg.key),
+ title: msgDate.toLocaleString(),
+ }, htime(msgDate))
+ ]),
+ missingBlobs ? self.askWantBlobsForm(missingBlobs) : pull(
+ self.app.git.readObject(obj),
+ self.wrapBinary({
+ rawUrl: self.app.render.toUrl('/git/raw/' + rev
+ + '?msg=' + encodeURIComponent(msg.key)),
+ ext: self.query.ext
+ })
+ ),
+ ]),
+ self.wrapPage('git blob ' + rev),
+ self.respondSink(200)
+ )
+ })
+ })
+}
+
+Serve.prototype.gitObjectLinks = function (headMsgId, type) {
+ var self = this
+ return paramap(function (id, cb) {
+ self.app.git.getObjectMsg({
+ obj: id,
+ headMsgId: headMsgId,
+ type: type,
+ }, function (err, msg) {
+ if (err && err.name === 'BlobNotFoundError')
+ return cb(null, self.askWantBlobsForm(err.links))
+ if (err && err.name === 'ObjectNotFoundError')
+ return cb(null, [
+ ph('code', u.escapeHTML(id.substr(0, 8))), '(missing)'])
+ if (err) return cb(err)
+ var path = '/git/' + type + '/' + id
+ + '?msg=' + encodeURIComponent(msg.key)
+ cb(null, [ph('code', ph('a', {
+ href: self.app.render.toUrl(path)
+ }, u.escapeHTML(id.substr(0, 8)))), ' '])
+ })
+ }, 8)
+}
+
+Serve.prototype.npm = function (url) {
+ var self = this
+ var parts = url.split('/')
+ var author = parts[1] && parts[1][0] === '@'
+ ? u.unescapeId(parts.splice(1, 1)[0]) : null
+ var name = parts[1]
+ var version = parts[2]
+ var distTag = parts[3]
+ var prefix = 'npm:' +
+ (name ? name + ':' +
+ (version ? version + ':' +
+ (distTag ? distTag + ':' : '') : '') : '')
+
+ var render = self.app.render
+ var base = '/npm/' + (author ? u.escapeId(author) + '/' : '')
+ var pathWithoutAuthor = '/npm' +
+ (name ? '/' + name +
+ (version ? '/' + version +
+ (distTag ? '/' + distTag : '') : '') : '')
+ return pull(
+ ph('section', {}, [
+ ph('h3', [ph('a', {href: render.toUrl('/npm/')}, 'npm'), ' : ',
+ author ? [
+ self.phIdLink(author), ' ',
+ ph('sub', ph('a', {href: render.toUrl(pathWithoutAuthor)}, '&times;')),
+ ' : '
+ ] : '',
+ name ? [ph('a', {href: render.toUrl(base + name)}, name), ' : '] : '',
+ version ? [ph('a', {href: render.toUrl(base + name + '/' + version)}, version), ' : '] : '',
+ distTag ? [ph('a', {href: render.toUrl(base + name + '/' + version + '/' + distTag)}, distTag)] : ''
+ ]),
+ ph('table', [
+ ph('thead', ph('tr', [
+ ph('td', 'publisher'),
+ ph('td', 'package'),
+ ph('td', 'version'),
+ ph('td', 'tag'),
+ ph('td', 'size'),
+ ph('td', 'tarball'),
+ ph('td', 'readme')
+ ])),
+ ph('tbody', pull(
+ self.app.blobMentions({
+ name: {$prefix: prefix},
+ author: author,
+ }),
+ distTag && !version && pull.filter(function (link) {
+ return link.name.split(':')[3] === distTag
+ }),
+ paramap(function (link, cb) {
+ self.app.render.npmPackageMention(link, {
+ withAuthor: true,
+ author: author,
+ name: name,
+ version: version,
+ distTag: distTag,
+ }, cb)
+ }, 4),
+ pull.map(u.toHTML)
+ ))
+ ])
+ ]),
+ self.wrapPage(prefix),
+ self.respondSink(200)
+ )
+}
+
+Serve.prototype.npmReadme = function (url) {
+ var self = this
+ var id = decodeURIComponent(url.substr(1))
+ return pull(
+ ph('section', {}, [
+ ph('h3', [
+ 'npm readme for ',
+ ph('a', {href: '/links/' + id}, id.substr(0, 8) + '…')
+ ]),
+ ph('blockquote', u.readNext(function (cb) {
+ self.app.getNpmReadme(id, function (err, readme, isMarkdown) {
+ if (err) return cb(null, ph('div', u.renderError(err).outerHTML))
+ cb(null, isMarkdown
+ ? ph('div', self.app.render.markdown(readme))
+ : ph('pre', readme))
+ })
+ }))
+ ]),
+ self.wrapPage('npm readme'),
+ self.respondSink(200)
+ )
+}
+
+// wrap a binary source and render it or turn into an embed
+Serve.prototype.wrapBinary = function (opts) {
+ var self = this
+ var ext = opts.ext
+ return function (read) {
+ var readRendered, type
+ read = ident(function (_ext) {
+ if (_ext) ext = _ext
+ type = ext && mime.lookup(ext) || 'text/plain'
+ })(read)
+ return function (abort, cb) {
+ if (readRendered) return readRendered(abort, cb)
+ if (abort) return read(abort, cb)
+ if (!type) read(null, function (end, buf) {
+ if (end) return cb(end)
+ if (!type) return cb(new Error('unable to get type'))
+ readRendered = pickSource(type, cat([pull.once(buf), read]))
+ readRendered(null, cb)
+ })
+ }
+ }
+ function pickSource(type, read) {
+ if (/^image\//.test(type)) {
+ read(true, function (err) {
+ if (err && err !== true) console.trace(err)
+ })
+ return ph('img', {
+ src: opts.rawUrl
+ })
+ }
+ return ph('pre', pull.map(function (buf) {
+ return self.app.render.highlight(buf.toString('utf8'), ext)
+ })(read))
+ }
+}
+
Serve.prototype.wrapPublic = function (opts) {
var self = this
return u.hyperwrap(function (thread, cb) {
@@ -1275,6 +2045,36 @@ Serve.prototype.wrapPublic = function (opts) {
})
}
+Serve.prototype.askWantBlobsForm = function (links) {
+ var self = this
+ return ph('form', {action: '', method: 'post'}, [
+ ph('section', [
+ ph('h3', 'Missing blobs'),
+ ph('p', 'The application needs these blobs to continue:'),
+ ph('table', links.map(u.toLink).map(function (link) {
+ if (!u.isRef(link.link)) return
+ return ph('tr', [
+ ph('td', ph('code', link.link)),
+ !isNaN(link.size) ? ph('td', self.app.render.formatSize(link.size)) : '',
+ ])
+ })),
+ ph('input', {type: 'hidden', name: 'action', value: 'want-blobs'}),
+ ph('input', {type: 'hidden', name: 'blob_ids',
+ value: links.map(u.linkDest).join(',')}),
+ ph('p', ph('input', {type: 'submit', value: 'Want Blobs'}))
+ ])
+ ])
+}
+
+Serve.prototype.askWantBlobs = function (links) {
+ var self = this
+ pull(
+ self.askWantBlobsForm(links),
+ self.wrapPage('missing blobs'),
+ self.respondSink(409)
+ )
+}
+
Serve.prototype.wrapPrivate = function (opts) {
var self = this
return u.hyperwrap(function (thread, cb) {
@@ -1438,6 +2238,7 @@ Serve.prototype.composer = function (opts, cb) {
var self = this
opts = opts || {}
var data = self.data
+ var myId = self.app.sbot.id
var blobs = u.tryDecodeJSON(data.blobs) || {}
if (data.upload && typeof data.upload === 'object') {
@@ -1459,6 +2260,19 @@ Serve.prototype.composer = function (opts, cb) {
formNames[mentionNames[i]] = u.extractFeedIds(mentionIds[i])[0]
}
+ var formEmojiNames = {}
+ var emojiIds = u.toArray(data.emoji_id)
+ var emojiNames = u.toArray(data.emoji_name)
+ for (var i = 0; i < emojiIds.length && i < emojiNames.length; i++) {
+ var upload = data['emoji_upload_' + i]
+ formEmojiNames[emojiNames[i]] =
+ (upload && upload.link) || u.extractBlobIds(emojiIds[i])[0]
+ if (upload) blobs[upload.link] = {
+ type: upload.type,
+ size: upload.size,
+ }
+ }
+
if (data.upload) {
var href = data.upload.link
+ (data.upload.key ? '#' + data.upload.key : '')
@@ -1471,7 +2285,8 @@ Serve.prototype.composer = function (opts, cb) {
// get bare feed names
var unknownMentionNames = {}
- var unknownMentions = ssbMentions(data.text, {bareFeedNames: true})
+ var mentions = ssbMentions(data.text, {bareFeedNames: true, emoji: true})
+ var unknownMentions = mentions
.filter(function (mention) {
return mention.link === '@'
})
@@ -1484,6 +2299,26 @@ Serve.prototype.composer = function (opts, cb) {
return {name: name, id: id}
})
+ var emoji = mentions
+ .filter(function (mention) { return mention.emoji })
+ .map(function (mention) { return mention.name })
+ .filter(uniques())
+ .map(function (name) {
+ // 1. check emoji-image mapping for this message
+ var id = formEmojiNames[name]
+ if (id) return {name: name, id: id}
+ // 2. TODO: check user's preferred emoji-image mapping
+ // 3. check builtin emoji
+ var link = self.getBuiltinEmojiLink(name)
+ if (link) {
+ return {name: name, id: link.link}
+ blobs[id] = {type: link.type, size: link.size}
+ }
+ // 4. check recently seen emoji
+ id = self.app.getReverseEmojiNameSync(name)
+ return {name: name, id: id}
+ })
+
// strip content other than feed ids from the recps field
if (data.recps) {
data.recps = u.extractFeedIds(data.recps).filter(uniques()).join(', ')
@@ -1522,7 +2357,19 @@ Serve.prototype.composer = function (opts, cb) {
h('input', {name: 'mention_name', type: 'hidden',
value: mention.name}),
h('input.id-input', {name: 'mention_id', size: 60,
- value: mention.id, placeholder: 'id'}))
+ value: mention.id, placeholder: '@id'}))
+ }))
+ ] : '',
+ emoji.length > 0 ? [
+ h('div', h('em', 'emoji:')),
+ h('ul.mentions', emoji.map(function (link, i) {
+ return h('li',
+ h('code', link.name), ': ',
+ h('input', {name: 'emoji_name', type: 'hidden',
+ value: link.name}),
+ h('input.id-input', {name: 'emoji_id', size: 60,
+ value: link.id, placeholder: '&id'}), ' ',
+ h('input', {type: 'file', name: 'emoji_upload_' + i}))
}))
] : '',
h('table.ssb-msgs',
@@ -1544,75 +2391,132 @@ Serve.prototype.composer = function (opts, cb) {
))
done(cb)
+ function prepareContent(cb) {
+ var done = multicb({pluck: 1})
+ content = {
+ type: 'post',
+ text: String(data.text).replace(/\r\n/g, '\n'),
+ }
+ var mentions = ssbMentions(data.text, {bareFeedNames: true, emoji: true})
+ .filter(function (mention) {
+ if (mention.emoji) {
+ mention.link = formEmojiNames[mention.name]
+ if (!mention.link) {
+ var link = self.getBuiltinEmojiLink(mention.name)
+ if (link) {
+ mention.link = link.link
+ mention.size = link.size
+ mention.type = link.type
+ } else {
+ mention.link = self.app.getReverseEmojiNameSync(mention.name)
+ if (!mention.link) return false
+ }
+ }
+ }
+ var blob = blobs[mention.link]
+ if (blob) {
+ if (!isNaN(blob.size))
+ mention.size = blob.size
+ if (blob.type && blob.type !== 'application/octet-stream')
+ mention.type = blob.type
+ } else if (mention.link === '@') {
+ // bare feed name
+ var name = mention.name
+ var id = formNames[name] || self.app.getReverseNameSync('@' + name)
+ if (id) mention.link = id
+ else return false
+ }
+ if (mention.link && mention.link[0] === '&' && mention.size == null) {
+ var linkCb = done()
+ self.app.sbot.blobs.size(mention.link, function (err, size) {
+ if (!err && size != null) mention.size = size
+ linkCb()
+ })
+ }
+ return true
+ })
+ if (mentions.length) content.mentions = mentions
+ if (data.recps != null) {
+ if (opts.recps) return cb(new Error('got recps in opts and data'))
+ content.recps = [myId]
+ u.extractFeedIds(data.recps).forEach(function (recp) {
+ if (content.recps.indexOf(recp) === -1) content.recps.push(recp)
+ })
+ } else {
+ if (opts.recps) content.recps = opts.recps
+ }
+ if (data.fork_thread) {
+ content.root = opts.post || undefined
+ content.branch = u.fromArray(opts.postBranches) || undefined
+ } else {
+ content.root = opts.root || undefined
+ content.branch = u.fromArray(opts.branches) || undefined
+ }
+ if (channel) content.channel = data.channel
+
+ done(function (err) {
+ cb(err, content)
+ })
+ }
+
function preview(raw, cb) {
- var myId = self.app.sbot.id
+ var msgContainer = h('table.ssb-msgs')
+ var contentInput = h('input', {type: 'hidden', name: 'content'})
+ var warningsContainer = h('div')
+
var content
- try {
- content = JSON.parse(data.text)
- } catch (err) {
- data.text = String(data.text).replace(/\r\n/g, '\n')
- content = {
- type: 'post',
- text: data.text,
- }
- var mentions = ssbMentions(data.text, {bareFeedNames: true})
- .filter(function (mention) {
- var blob = blobs[mention.link]
- if (blob) {
- if (!isNaN(blob.size))
- mention.size = blob.size
- if (blob.type && blob.type !== 'application/octet-stream')
- mention.type = blob.type
- } else if (mention.link === '@') {
- // bare feed name
- var name = mention.name
- var id = formNames[name] || self.app.getReverseNameSync('@' + name)
- if (id) mention.link = id
- else return false
- }
- return true
- })
- if (mentions.length) content.mentions = mentions
- if (data.recps != null) {
- if (opts.recps) return cb(new Error('got recps in opts and data'))
- content.recps = [myId]
- u.extractFeedIds(data.recps).forEach(function (recp) {
- if (content.recps.indexOf(recp) === -1) content.recps.push(recp)
- })
- } else {
- if (opts.recps) content.recps = opts.recps
- }
- if (data.fork_thread) {
- content.root = opts.post || undefined
- content.branch = u.fromArray(opts.postBranches) || undefined
- } else {
- content.root = opts.root || undefined
- content.branch = u.fromArray(opts.branches) || undefined
+ try { content = JSON.parse(data.text) }
+ catch (err) {}
+ if (content) gotContent(null, content)
+ else prepareContent(gotContent)
+
+ function gotContent(err, content) {
+ if (err) return cb(err)
+ contentInput.value = JSON.stringify(content)
+ var msg = {
+ value: {
+ author: myId,
+ timestamp: Date.now(),
+ content: content
+ }
}
- if (channel) content.channel = data.channel
- }
- var msg = {
- value: {
- author: myId,
- timestamp: Date.now(),
- content: content
+ if (content.recps) msg.value.private = true
+
+ var warnings = []
+ u.toLinkArray(content.mentions).forEach(function (link) {
+ if (link.emoji && link.size >= 10e3) {
+ warnings.push(h('li',
+ 'emoji ', h('q', link.name),
+ ' (', h('code', String(link.link).substr(0, 8) + '…'), ')'
+ + ' is >10KB'))
+ } else if (link.link[0] === '&' && link.size >= 10e6 && link.type) {
+ // if link.type is set, we probably just uploaded this blob
+ warnings.push(h('li',
+ 'attachment ',
+ h('code', String(link.link).substr(0, 8) + '…'),
+ ' is >10MB'))
+ }
+ })
+ if (warnings.length) {
+ warningsContainer.appendChild(h('div', h('em', 'warning:')))
+ warningsContainer.appendChild(h('ul.mentions', warnings))
}
+
+ pull(
+ pull.once(msg),
+ self.app.unboxMessages(),
+ self.app.render.renderFeeds(raw),
+ pull.drain(function (el) {
+ msgContainer.appendChild(h('tbody', el))
+ }, cb)
+ )
}
- if (content.recps) msg.value.private = true
- var msgContainer = h('table.ssb-msgs')
- pull(
- pull.once(msg),
- self.app.unboxMessages(),
- self.app.render.renderFeeds(raw),
- pull.drain(function (el) {
- msgContainer.appendChild(h('tbody', el))
- }, cb)
- )
+
return [
- h('input', {type: 'hidden', name: 'content',
- value: JSON.stringify(content)}),
+ contentInput,
opts.redirectToPublishedMsg ? h('input', {type: 'hidden',
name: 'redirect_to_published_msg', value: '1'}) : '',
+ warningsContainer,
h('div', h('em', 'draft:')),
msgContainer,
h('div.composer-actions',
@@ -1622,3 +2526,57 @@ Serve.prototype.composer = function (opts, cb) {
}
}
+
+function hashBuf(buf) {
+ var hash = crypto.createHash('sha256')
+ hash.update(buf)
+ return '&' + hash.digest('base64') + '.sha256'
+}
+
+Serve.prototype.getBuiltinEmojiLink = function (name) {
+ if (!(name in emojis)) return
+ var file = path.join(emojiDir, name + '.png')
+ var fileBuf = fs.readFileSync(file)
+ var id = hashBuf(fileBuf)
+ // seed the builtin emoji
+ pull(pull.once(fileBuf), this.app.sbot.blobs.add(id, function (err) {
+ if (err) console.error('error adding builtin emoji as blob', err)
+ }))
+ return {
+ link: id,
+ type: 'image/png',
+ size: fileBuf.length,
+ }
+}
+
+Serve.prototype.emojis = function (path) {
+ var self = this
+ var seen = {}
+ pull(
+ ph('section', [
+ ph('h3', 'Emojis'),
+ ph('ul', {class: 'mentions'}, pull(
+ self.app.streamEmojis(),
+ pull.map(function (emoji) {
+ if (!seen[emoji.name]) {
+ // cache the first use, so that our uses take precedence over other feeds'
+ self.app.reverseEmojiNameCache.set(emoji.name, emoji.link)
+ seen[emoji.name] = true
+ }
+ return ph('li', [
+ ph('a', {href: self.app.render.toUrl('/links/' + emoji.link)},
+ ph('img', {
+ class: 'ssb-emoji',
+ src: self.app.render.imageUrl(emoji.link),
+ size: 32,
+ })
+ ), ' ',
+ u.escapeHTML(emoji.name)
+ ])
+ })
+ ))
+ ]),
+ this.wrapPage('emojis'),
+ this.respondSink(200)
+ )
+}
diff --git a/lib/util.js b/lib/util.js
index 5546716..fb0b13f 100644
--- a/lib/util.js
+++ b/lib/util.js
@@ -1,9 +1,11 @@
var pull = require('pull-stream')
var cat = require('pull-cat')
var h = require('hyperscript')
+var b64url = require('base64-url')
var u = exports
u.ssbRefRegex = /((?:@|%|&|ssb:\/\/%)[A-Za-z0-9\/+]{43}=\.[\w\d]+)/g
+u.ssbRefEncRegex = /((?:ssb:\/\/)?(?:[@%&]|%26|%40|%25)(?:[A-Za-z0-9\/+]|%2[fF]|%2[bB]){43}(?:=|%3[dD])\.[\w\d]+)/g
u.isRef = function (str) {
if (!str) return false
@@ -62,18 +64,34 @@ u.hyperwrap = function (fn) {
}
}
+u.ifString = function (str) {
+ if (typeof str === 'string') return str
+}
+
+u.ifNumber = function (num) {
+ if (!isNaN(num)) return num
+}
+
+u.toLink = function (link) {
+ return typeof link === 'string' ? {link: link} : link || null
+}
+
u.linkDest = function (link) {
- return typeof link === 'string' ? link : link && link.link || link
+ return link && (u.ifString(link) || u.ifString(link.link))
}
u.toArray = function (x) {
- return !x ? [] : Array.isArray(x) ? x : [x]
+ return x == null ? [] : Array.isArray(x) ? x : [x]
}
u.fromArray = function (arr) {
return Array.isArray(arr) && arr.length === 1 ? arr[0] : arr
}
+u.toLinkArray = function (x) {
+ return u.toArray(x).map(u.toLink).filter(u.linkDest)
+}
+
u.renderError = function(err) {
return h('div.error',
h('h3', err.name),
@@ -97,7 +115,7 @@ u.tryDecodeJSON = function (json) {
}
}
-u.extractFeedIds = function (str) {
+u.extractRefs = function (str) {
var ids = []
String(str).replace(u.ssbRefRegex, function (id) {
ids.push(id)
@@ -105,6 +123,18 @@ u.extractFeedIds = function (str) {
return ids
}
+u.extractFeedIds = function (str) {
+ return u.extractRefs(str).filter(function (ref) {
+ return ref[0] === '@'
+ })
+}
+
+u.extractBlobIds = function (str) {
+ return u.extractRefs(str).filter(function (ref) {
+ return ref[0] === '&'
+ })
+}
+
u.isMsgReadable = function (msg) {
var c = msg && msg.value && msg.value.content
return typeof c === 'object' && c !== null
@@ -114,3 +144,76 @@ u.isMsgEncrypted = function (msg) {
var c = msg && msg.value.content
return typeof c === 'string'
}
+
+u.pullConcat = function (cb) {
+ return pull.collect(function (err, bufs) {
+ if (err) return cb(err)
+ cb(null, Buffer.concat(bufs))
+ })
+}
+
+u.customError = function (name) {
+ return function (message) {
+ var error = new Error(message)
+ error.name = name
+ error.stack = error.stack.replace(/^ at .*\n/m, '')
+ return error
+ }
+}
+
+u.escapeHTML = function (html) {
+ if (!html) return ''
+ return html.toString('utf8')
+ .replace(/</g, '&lt;')
+ .replace(/>/g, '&gt;')
+}
+
+u.pullSlice = function (start, end) {
+ if (end == null) end = Infinity
+ var offset = 0
+ return function (read) {
+ return function (abort, cb) {
+ if (abort) read(abort, cb)
+ else if (offset >= end) read(true, function (err) {
+ cb(err || true)
+ })
+ else if (offset < start) read(null, function next(err, data) {
+ if (err) return cb(err)
+ offset += data.length
+ if (offset <= start) read(null, next)
+ else if (offset < end) cb(null,
+ data.slice(data.length - (offset - start)))
+ else cb(null, data.slice(data.length - (offset - start),
+ data.length - (offset - end)))
+ })
+ else read(null, function (err, data) {
+ if (err) return cb(err)
+ offset += data.length
+ if (offset <= end) cb(null, data)
+ else cb(null, data.slice(0, data.length - (offset - end)))
+ })
+ }
+ }
+}
+
+u.mergeOpts = function (a, b) {
+ var obj = {}, k
+ for (k in a) {
+ obj[k] = a[k]
+ }
+ for (k in b) {
+ if (b[k] != null) obj[k] = b[k]
+ else delete obj[k]
+ }
+ return obj
+}
+
+u.escapeId = function (id) {
+ return b64url.escape(id)
+}
+
+u.unescapeId = function (str) {
+ var m = /^(.)(.*)(\..*)$/.exec(str)
+ if (!m) return b64url.unescape(str)
+ return m[1] + b64url.unescape(m[2]) + m[3]
+}
diff --git a/package.json b/package.json
index c09174c..7f10c07 100644
--- a/package.json
+++ b/package.json
@@ -4,26 +4,32 @@
"description": "plain ssb web ui",
"dependencies": {
"asyncmemo": "^1.0.0",
+ "base64-url": "^2.0.0",
"busboy": "^0.2.14",
"emoji-named-characters": "^1.0.2",
"emoji-server": "^1.0.0",
+ "hashlru": "^2.1.0",
+ "highlight.js": "^9.12.0",
"human-time": "^0.0.1",
"hyperscript": "^2.0.2",
- "hashlru": "^2.1.0",
+ "jpeg-autorotate": "^3.0.0",
+ "looper": "^4.0.0",
"mime-types": "^2.1.12",
"multicb": "^1.2.1",
"pull-box-stream": "^1.0.12",
"pull-cat": "^1.1.11",
- "pull-hash": "^1.0.0",
+ "pull-git-packidx-parser": "^1.0.0",
"pull-hyperscript": "^0.2.2",
"pull-identify-filetype": "^1.1.0",
+ "pull-kvdiff": "^0.0.1",
"pull-paginate": "^1.0.0",
"pull-paramap": "^1.2.1",
+ "pull-reader": "^1.2.9",
"pull-stream": "^3.5.0",
"ssb-contact": "^1.0.0",
"ssb-marked": "^0.7.1",
- "ssb-mentions": "^0.2.0",
- "ssb-party": "^0.3.0",
+ "ssb-mentions": "^0.4.0",
+ "ssb-party": "^0.5.0",
"ssb-sort": "^1.0.0",
"stream-to-pull-stream": "^1.7.2"
},
diff --git a/static/styles.css b/static/styles.css
index 3d513f2..c26cf59 100644
--- a/static/styles.css
+++ b/static/styles.css
@@ -8,20 +8,43 @@ section {
padding: 1ex;
}
-/*
.symbol {
font-family: Symbola;
}
-*/
-.ssb-post img {
+.ssb-post img,
+.ssb-issue img {
max-width: 100%;
- background-color: #eee;
+ max-height: 500px;
+}
+
+.ssb-post blockquote {
+ border-left: 4px #e0e0e0 solid;
+ color: #444;
+ background-color: #f9f9f9;
+ padding: .2rem .2rem .2rem 1rem;
+ margin: .5rem 0;
+}
+
+.ssb-post-text pre, .ssb-post-text pre * {
+ overflow-wrap: break-word;
+ word-wrap: break-word;
+ -ms-word-break: break-all;
+ /* This is the dangerous one in WebKit, as it breaks things wherever */
+ word-break: break-all;
+ /* Instead use this non-standard one: */
+ word-break: break-word;
+ /* Adds a hyphen where the word breaks, if supported (No Blink) */
+ -ms-hyphens: auto;
+ -moz-hyphens: auto;
+ -webkit-hyphens: auto;
+ hyphens: auto;
}
img.ssb-emoji {
height: 1em;
width: auto;
+ border: none;
}
.nav-bar {
@@ -160,3 +183,13 @@ table.ssb-object {
table.ssb-object td {
border: 1px solid black;
}
+
+.chess-square {
+ width: 1em;
+ height: 1em;
+ text-align: center;
+}
+
+.chess-square-dark {
+ background-color: #ccc;
+}