diff options
-rw-r--r-- | lib/about.js | 1 | ||||
-rw-r--r-- | lib/app.js | 41 | ||||
-rw-r--r-- | lib/git.js | 143 | ||||
-rw-r--r-- | lib/render-msg.js | 78 | ||||
-rw-r--r-- | lib/render.js | 30 | ||||
-rw-r--r-- | lib/serve.js | 369 | ||||
-rw-r--r-- | lib/util.js | 21 | ||||
-rw-r--r-- | package.json | 4 | ||||
-rw-r--r-- | static/styles.css | 2 |
9 files changed, 571 insertions, 118 deletions
diff --git a/lib/about.js b/lib/about.js index e94b7fc..4ad153b 100644 --- a/lib/about.js +++ b/lib/about.js @@ -96,6 +96,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) { @@ -12,6 +12,7 @@ var About = require('./about') var Serve = require('./serve') var Render = require('./render') var Git = require('./git') +var cat = require('pull-cat') module.exports = App @@ -38,6 +39,7 @@ 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.unboxMsg = this.unboxMsg.bind(this) @@ -170,6 +172,17 @@ App.prototype.publish = function (content, cb) { tryPublish(2) } +App.prototype.wantSizeBlob = function (id, cb) { + var blobs = this.sbot.blobs + blobs.size(id, function (err, size) { + if (size != null) return cb(null, size) + blobs.want(id, function (err) { + if (err) return cb(err) + blobs.size(id, cb) + }) + }) +} + App.prototype.addBlob = function (cb) { var done = multicb({pluck: 1, spread: true}) var hashCb = done() @@ -177,7 +190,10 @@ App.prototype.addBlob = function (cb) { done(function (err, hash, add) { cb(err, hash) }) - return sink + return pull( + hasher(hashCb), + this.sbot.blobs.add(addCb) + ) } App.prototype.pushBlob = function (id, cb) { @@ -230,6 +246,10 @@ App.prototype.getReverseNameSync = function (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 @@ -432,3 +452,22 @@ App.prototype.getVoted = function (_opts, cb) { 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') + ) +} @@ -7,6 +7,9 @@ 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') @@ -15,6 +18,7 @@ var types = { commit: true, tree: true, } +var emptyBlobHash = 'e69de29bb2d1d6434b8b29ae775ad8c2e48c5391' module.exports = Git @@ -81,6 +85,7 @@ Git.prototype.openObject = function (opts, cb) { } 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({ @@ -99,6 +104,19 @@ Git.prototype._findObject = function (opts, cb) { 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) @@ -501,15 +519,20 @@ Git.prototype.getTag = function (obj, cb) { function readCString(reader, cb) { var chars = [] - reader.read(1, function next(err, ch) { + 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) - reader.read(1, next) - }) + loop() + } + loop() } Git.prototype.readTree = function (obj) { + var self = this var reader = Reader() reader(this.readObject(obj)) return function (abort, cb) { @@ -524,9 +547,121 @@ Git.prototype.readTree = function (obj) { cb(null, { name: name, mode: mode, - hash: hash.toString('hex') + 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: diff.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 72b8015..8346baf 100644 --- a/lib/render-msg.js +++ b/lib/render-msg.js @@ -147,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, ' '] : '' ] @@ -175,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) { @@ -231,9 +250,21 @@ 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}) @@ -360,15 +391,27 @@ RenderMsg.prototype.link1 = function (link, 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 @@ -493,13 +536,16 @@ RenderMsg.prototype.gitUpdate = function (cb) { 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) @@ -513,14 +559,6 @@ RenderMsg.prototype.gitUpdate = function (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}) diff --git a/lib/render.js b/lib/render.js index 102035b..c1c7edf 100644 --- a/lib/render.js +++ b/lib/render.js @@ -88,13 +88,27 @@ function Render(app, opts) { 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,6 +127,8 @@ 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') @@ -290,6 +306,14 @@ Render.prototype.renderFeeds = function (opts) { }, 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 diff --git a/lib/serve.js b/lib/serve.js index b971572..4f2ed42 100644 --- a/lib/serve.js +++ b/lib/serve.js @@ -19,6 +19,7 @@ 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') module.exports = Serve @@ -74,6 +75,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 done = multicb({pluck: 1, spread: true}) var cb = filesCb() @@ -85,14 +91,13 @@ Serve.prototype.go = function () { done(function (err, size, id) { if (err) return cb(err) if (size === 0 && !filename) return cb() - data[fieldname] = {link: id, name: filename, type: mimetype, size: size} + addField(fieldname, + {link: id, name: filename, type: mimetype, size: size}) 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 { @@ -117,9 +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() } @@ -156,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(',') @@ -173,6 +179,19 @@ 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() @@ -264,6 +283,7 @@ Serve.prototype.path = function (url) { 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) @@ -1004,22 +1024,18 @@ Serve.prototype.blob = function (id) { var self = this var blobs = self.app.sbot.blobs if (self.req.headers['if-none-match'] === id) return self.respond(304) - 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( blobs.get(id), 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) if (typeof size === 'number') self.res.setHeader('Content-Length', size) @@ -1028,7 +1044,7 @@ Serve.prototype.blob = function (id) { self.res.setHeader('Cache-Control', 'public, max-age=315360000') self.res.setHeader('etag', id) self.res.writeHead(200) - }) + } }) } @@ -1136,8 +1152,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) @@ -1216,6 +1237,7 @@ Serve.prototype.wrapPage = function (title, searchQ) { 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'}) @@ -1486,7 +1508,23 @@ Serve.prototype.gitCommit = function (rev) { pull.once(commit.tree), self.gitObjectLinks(obj.msg.key, 'tree') )]) : '', - h('pre', self.app.render.linkify(commit.body)).outerHTML, + 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), @@ -1619,20 +1657,19 @@ Serve.prototype.gitTree = function (rev) { }) }, 8), pull.map(function (item) { - var type = item.mode === 0040000 ? 'tree' : - item.mode === 0160000 ? 'commit' : 'blob' if (!item.msg) return ph('tr', [ ph('td', - u.escapeHTML(item.name) + (type === 'tree' ? '/' : '')), + u.escapeHTML(item.name) + (item.type === 'tree' ? '/' : '')), + ph('td', u.escapeHTML(item.hash)), ph('td', 'missing') ]) - var path = '/git/' + type + '/' + item.hash + var path = '/git/' + item.type + '/' + item.hash + '?msg=' + encodeURIComponent(item.msg.key) 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) + (type === 'tree' ? '/' : ''))), + u.escapeHTML(item.name) + (item.type === 'tree' ? '/' : ''))), ph('td', self.phIdLink(item.msg.value.author)), ph('td', @@ -1719,6 +1756,8 @@ Serve.prototype.gitObjectLinks = function (headMsgId, type) { 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)']) @@ -1793,7 +1832,7 @@ Serve.prototype.askWantBlobsForm = function (links) { if (!u.isRef(link.link)) return return ph('tr', [ ph('td', ph('code', link.link)), - ph('td', self.app.render.formatSize(link.size)), + !isNaN(link.size) ? ph('td', self.app.render.formatSize(link.size)) : '', ]) })), ph('input', {type: 'hidden', name: 'action', value: 'want-blobs'}), @@ -1975,6 +2014,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') { @@ -1995,6 +2035,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) { // TODO: be able to change the content-type var isImage = /^image\//.test(data.upload.type) @@ -2005,7 +2058,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 === '@' }) @@ -2018,6 +2072,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(', ') @@ -2056,7 +2130,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', @@ -2076,75 +2162,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', @@ -2154,3 +2297,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 d25ecaf..e4cf415 100644 --- a/lib/util.js +++ b/lib/util.js @@ -72,13 +72,17 @@ u.linkDest = function (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), @@ -102,7 +106,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) @@ -110,6 +114,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 @@ -137,6 +153,7 @@ u.customError = function (name) { } u.escapeHTML = function (html) { + if (!html) return '' return html.toString('utf8') .replace(/</g, '<') .replace(/>/g, '>') diff --git a/package.json b/package.json index 2a78964..816c115 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "human-time": "^0.0.1", "hyperscript": "^2.0.2", "hashlru": "^2.1.0", + "looper": "^4.0.0", "mime-types": "^2.1.12", "multicb": "^1.2.1", "pull-cat": "^1.1.11", @@ -17,13 +18,14 @@ "pull-hash": "^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-mentions": "github:ssbc/ssb-mentions#emoji", "ssb-party": "^0.3.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..4497f90 100644 --- a/static/styles.css +++ b/static/styles.css @@ -16,12 +16,12 @@ section { .ssb-post img { max-width: 100%; - background-color: #eee; } img.ssb-emoji { height: 1em; width: auto; + border: none; } .nav-bar { |