sbot/ui/lib/com/message.js

419 lines
14 KiB
JavaScript

'use strict'
var h = require('hyperscript')
var pull = require('pull-stream')
var paramap = require('pull-paramap')
var mlib = require('ssb-msgs')
var schemas = require('ssb-msg-schemas')
var ssbref = require('ssb-ref')
var pauser = require('pause-offscreen')
var app = require('../app')
var ui = require('../ui')
var modals = require('../ui/modals')
var com = require('./index')
var u = require('../util')
var markdown = require('../markdown')
var social = require('../social-graph')
module.exports = function (msg, opts) {
// markup
msg.plaintext = (typeof msg.value.content !== 'string')
var msgComments = h('.message-comments')
var msgEl = h('.message'+(!msg.plaintext?'.secret':'')+((opts && opts.fullview)?'.fullview':'.smallview'),
{ onclick: (!(opts && opts.fullview) ? onopen(msg) : null) },
com.userImg(msg.value.author),
h('.message-inner',
h('ul.message-header.list-inline',
h('li', com.user(msg.value.author)),
!msg.plaintext ? h('li', com.icon('lock')) : '',
h('li.pull-right', h('a', { href: '#', onclick: onflag(msg), title: 'Flag this post' }, com.icon('flag'))),
h('li.favorite.pull-right',
h('span.users'),
h('a', { href: '#', onclick: onfavorite(msg), title: 'Favorite this post' }, com.icon('star'))
)
),
h('.message-body', fetchAndRenderReplyLink(msg), (typeof msg.value.content != 'string') ? com.messageContent(msg) : ''),
h('ul.message-footer.list-inline',
(!(opts && opts.fullview)) ?
h('li', com.a('#/msg/'+msg.key, h('small.comment-count-digits'))) :
'',
h('li.pull-right', h('small', com.a('#/msg/'+msg.key, u.prettydate(new Date(msg.value.timestamp), true))))
)
),
msgComments
)
msg.el = msgEl // attach el to msg for the handler-funcs to access
fetchState(msg, opts)
// unread
app.ssb.patchwork.isRead(msg.key, function (err, isread) {
if (!err && !isread)
msg.el.classList.add('unread')
})
// if encrypted, attempt to decrypt
if (!msg.plaintext) {
app.ssb.private.unbox(msg.value.content, function (err, decrypted) {
if (decrypted) {
msg.value.content = decrypted
// render content
var body = msgEl.querySelector('.message-body')
body.innerHTML = ''
body.appendChild(com.messageContent(msg))
}
})
}
if (opts && opts.live) {
// create a live-stream of the log
var livelog = app.ssb.createLogStream({ gt: Date.now(), live: true })
ui.onTeardown(function() { livelog(true, function(){}) })
pull(livelog, pull.drain(function (newmsg) {
if (newmsg.sync) return
// decrypt, if needed
newmsg.plaintext = (typeof newmsg.value.content !== 'string')
if (!newmsg.plaintext) {
app.ssb.private.unbox(newmsg.value.content, function (err, decrypted) {
if (decrypted) {
newmsg.value.content = decrypted
next()
}
})
} else next()
function next () {
var c = newmsg.value.content
// only messages in this thread
if (!(c.type && c.root && mlib.link(c.root).link == msg.key))
return
// render new comments automatically
var el = renderComment(newmsg)
el.classList.add('add-anim')
setTimeout(function() {
el.querySelector('.message-inner').style.background = '#fff'
}, 33)
msg.el.querySelector('.message-comments').appendChild(el)
}
}))
// render a notice that this is live
msg.el.appendChild(h('.well.text-muted', { style: 'margin: 5px 0 0 88px' }, com.icon('flash'), ' Replies will auto-update in realtime.'))
}
// add offscreen pausing
var unlistenPauser = pauser(msgEl)
ui.onTeardown(unlistenPauser)
return msgEl
}
function onpostreply (msg, opts) {
return function (comment) {
if (opts && opts.fullview)
return
if (typeof comment.value.content == 'string') // an encrypted message?
ui.refreshPage() // easier just to refresh to page, for now
else
msg.el.querySelector('.message-comments').appendChild(renderComment(comment))
}
}
function onopen (msg) {
return function (e) {
// make sure this isnt a click on a link
var node = e.target
while (node && node !== msg.el) {
if (node.tagName == 'A')
return
node = node.parentNode
}
e.preventDefault()
e.stopPropagation()
var root = mlib.link(msg.value.content.root || msg.value.content.flag)
var key = root ? root.link : msg.key
window.location.hash = '#/msg/'+key+((key!=msg.key)?('?jumpto='+encodeURIComponent(msg.key)):'')
}
}
function onfavorite (msg) {
var voting = false
return function (e) {
e.preventDefault()
e.stopPropagation()
if (voting)
return // wait please
voting = true
var favoriteBtn = this
// get current state by checking if the control is selected
// this won't always be the most recent info, but it will be close and harmless to get wrong,
// plus it will reflect what the user expects to happen happening
var wasSelected = favoriteBtn.classList.contains('selected')
var newvote = (wasSelected) ? 0 : 1
updateFavBtn(favoriteBtn, !wasSelected)
app.ssb.publish(schemas.vote(msg.key, newvote), function (err) {
voting = false
if (err) {
updateFavBtn(favoriteBtn, wasSelected) // undo
modals.error('Error While Publishing', err, 'This error occured while trying to fav/unfav message.')
} else {
// update ui
var users = msg.el.querySelector('.message-header .favorite .users')
if (newvote === 0) {
try { users.removeChild(users.querySelector('.this-user')) } catch (e) {}
} else {
var userimg = com.userImg(app.user.id)
userimg.classList.add('this-user')
users.insertBefore(userimg, users.firstChild)
}
}
})
}
}
function onflag (msg) {
return function (e) {
e.preventDefault()
e.stopPropagation()
ui.dropdown(e.target, [
{ value: 'nsfw', label: 'NSFW', title: 'Graphic or adult content' },
{ value: 'spam', label: 'Spam', title: 'Off-topic or nonsensical' },
{ value: 'abuse', label: 'Abuse', title: 'Harrassment or needlessly derogatory' }
], function (value) {
if (!value) return
// publish flag
app.ssb.publish(schemas.flag(msg.key, value), function (err, flagmsg) {
if (err) {
modals.error('Error While Publishing', err, 'This error occured while trying to flag a message.')
} else {
// render new flag
msg.el.querySelector('.message-comments').appendChild(renderComment(flagmsg))
}
})
})
}
}
function updateFavBtn (el, b) {
if (b)
el.classList.add('selected')
else
el.classList.remove('selected')
el.setAttribute('title', b ? 'Unfavorite this post' : 'Favorite this post')
}
var fetchState =
module.exports.fetchState = function (msg, opts) {
// reply messages
app.ssb.relatedMessages({ id: msg.key, count: true }, function (err, thread) {
if (!thread || !thread.related) {
if (opts && opts.fullview)
msg.el.appendChild(com.composer(msg, msg, { onpost: onpostreply(msg, opts) }))
if (opts && opts.markread)
app.ssb.patchwork.markRead(msg.key)
return
}
u.decryptThread(thread, function () {
// copy the original message's value over, in case it was decrypted above
thread.value = msg.value
// handle votes, flags
renderSignals(msg.el, thread)
// get comments
var cids = {}
var comments = thread.related.filter(function (r) {
if (cids[r.key]) return false // only appear once
cids[r.key] = 1
var c = r.value.content
if (c.type == 'flag' && c.flag && c.flag.reason && !isFlagUndone(r))
return true // render a flag if it's still active
return (c.type == 'post') && isaReplyTo(r, msg)
})
// render composer now that we know the last message, and thus can give the branch link
if (opts && opts.fullview)
msg.el.appendChild(com.composer(thread, comments[comments.length - 1] || thread, { onpost: onpostreply(msg, opts) }))
// render comments
if (opts && opts.fullview)
renderComments()
else {
if (opts && opts.markread)
app.ssb.patchwork.markRead(thread.key) // go ahead and mark the root read
if (comments.length)
msg.el.querySelector('.comment-count-digits').innerText = comments.length + (comments.length == 1?' reply':' replies')
}
function renderComments (e) {
e && e.preventDefault()
// render
var commentsEl = msg.el.querySelector('.message-comments')
var existingCommentEl = commentsEl.firstChild
comments.forEach(function (comment) {
commentsEl.insertBefore(renderComment(comment), existingCommentEl)
})
// mark read
if (opts && opts.markread) {
var ids = [thread.key].concat(comments.map(function (c) { return c.key }))
app.ssb.patchwork.markRead(ids)
}
}
})
})
}
function renderComment (msg, encryptionNotice) {
var el = h('.message',
{ 'data-key': msg.key },
com.userImg(msg.value.author),
h('.message-inner',
h('ul.message-header.list-inline',
h('li', com.user(msg.value.author)),
h('li', h('small', com.a('#/msg/'+msg.key, u.prettydate(new Date(msg.value.timestamp), true)))),
(msg.plaintext === false) ? h('li', com.icon('lock')) : '',
h('li.pull-right', h('a', { href: '#', onclick: onflag(msg), title: 'Flag this post' }, com.icon('flag'))),
h('li.favorite.pull-right',
h('span.users'),
h('a', { href: '#', onclick: onfavorite(msg), title: 'Favorite this post' }, com.icon('star'))
)
),
h('.message-body',
((encryptionNotice) ?
(msg.plaintext ?
h('em.text-danger.pull-right', 'Warning: This comment was not encrypted!') :
h('span.pull-right', com.icon('lock')))
: ''),
com.messageContent(msg)
),
h('ul.message-footer.list-inline',
h('li.pull-right', h('small', com.a('#/msg/'+msg.key, u.prettydate(new Date(msg.value.timestamp), true))))
)
)
)
msg.el = el // attach for handlers
renderSignals(el, msg)
// unread
app.ssb.patchwork.isRead(msg.key, function (err, isread) {
if (!err && !isread)
msg.el.classList.add('unread')
})
return el
}
function isaReplyTo (a, b) {
var c = a.value.content
return (c.root && mlib.link(c.root).link == b.key || c.branch && mlib.link(c.branch).link == b.key)
}
function isaMentionOf (a, b) {
var c = a.value.content
return mlib.links(c.mentions).filter(function(l) { return l.link == b.key }).length !== 0
}
function renderSignals (el, msg) {
if (!msg || !msg.related)
return
// collect mentions and votes
var mentions = []
var upvoters = {}, flaggers = {}
msg.related.forEach(function (r) {
var c = r.value.content
if (c.type === 'vote') {
if (c.vote.value === 1)
upvoters[r.value.author] = 1
else
delete upvoters[r.value.author]
}
else if (c.type == 'flag') {
if (c.flag && c.flag.reason)
flaggers[r.value.author] = c.flag.reason
else
delete flaggers[r.value.author]
}
else if (c.type == 'post') {
if (!isaReplyTo(r, msg) && isaMentionOf(r, msg))
mentions.push(r)
}
})
// update vote ui
if (upvoters[app.user.id])
updateFavBtn(el.querySelector('.message-header .favorite a'), true)
upvoters = Object.keys(upvoters)
var nupvoters = upvoters.length
var favusers = el.querySelector('.message-header .favorite .users')
favusers.innerHTML = ''
upvoters.slice(0, 5).forEach(function (id) {
var userimg = com.userImg(id)
favusers.appendChild(userimg)
})
if (nupvoters > 5)
favusers.appendChild(h('span', '+', nupvoters-5))
// handle flags
el.classList.remove('flagged-nsfw', 'flagged-spam', 'flagged-abuse')
for (var k in flaggers) {
// use the flag if we dont follow the author, or if we follow the flagger
// (that is, dont use flags by strangers on people we follow)
if (k == app.user.id || !social.follows(app.user.id, msg.value.author) || social.follows(app.user.id, k))
el.classList.add('flagged-'+flaggers[k])
}
// render mentions
if (mentions.length) {
el.querySelector('.message-inner').appendChild(h('.message-mentions', mentions.map(renderMention)))
}
}
function fetchAndRenderReplyLink (msg) {
var root = mlib.link(msg.value.content.root)
var branch = mlib.link(msg.value.content.branch)
if (!root) return ''
var anchor = h('a.text-muted', { href: '#/msg/'+root.link }, 'replies to...')
app.ssb.get((branch && branch.link) ? branch.link : root.link, function (err, msg) {
if (!msg) return
var text = '@' + com.userName(msg.author)
if (msg.content.text && typeof msg.content.text == 'string')
text += ': ' + markdown.inline(msg.content.text)
if (text.length > 60)
text = text.slice(0, 57) + '...'
anchor.textContent = text
})
return h('p', { style: 'margin-bottom: 1em; background: #fafafa; padding: 5px' }, anchor)
}
function renderMention (m) {
var text = m.value.content.text
if (text.length > 40)
text = text.slice(0, 37) + '...'
if (text)
text = ': ' + text
return h('div', h('a', { href: '#/msg/'+m.key }, '↳ @', com.userName(m.value.author), text))
}
function isFlagUndone (r) {
if (r.related) {
return r.related.filter(function (msg) {
var c = msg.value.content
return (mlib.link(c.redacts) && mlib.link(c.redacts).link == r.key)
}).length > 0
}
return false
}