move ssb-patchwork-api and ssb-patchwork-ui into this repo

This commit is contained in:
Paul Frazee
2015-09-22 13:44:28 -05:00
parent ca4830cec8
commit 0f60126e9d
1094 changed files with 22959 additions and 8 deletions

135
ui/lib/app.js Normal file
View File

@@ -0,0 +1,135 @@
'use strict'
/*
Application Master State
========================
Common state which either exists as part of the session,
or which has been loaded from scuttlebot during page
refresh because its commonly needed during rendering.
*/
var o = require('observable')
var multicb = require('multicb')
var SSBClient = require('./muxrpc-ipc')
var emojis = require('emoji-named-characters')
// master state object
var app =
module.exports = {
// sbot rpc connection
ssb: SSBClient(),
// pull state from sbot, called on every pageload
fetchLatestState: fetchLatestState,
// page params parsed from the url
page: {
id: 'home',
param: null,
qs: {}
},
// ui data
suggestOptions: {
':': Object.keys(emojis).map(function (emoji) {
return {
image: './img/emoji/' + emoji + '.png',
title: emoji,
subtitle: emoji,
value: emoji + ':'
}
}),
'@': []
},
homeMode: {
view: 'all',
live: true
},
filters: {
nsfw: true,
spam: true,
abuse: true
},
// application state, fetched every refresh
actionItems: {},
indexCounts: {},
user: {
id: null,
profile: {}
},
users: {
names: {},
profiles: {}
},
peers: [],
// global observables, updated by persistent events
observ: {
sideview: o(true),
peers: o([]),
hasSyncIssue: o(false),
newPosts: o(0),
indexCounts: {
inbox: o(0),
votes: o(0),
follows: o(0),
inboxUnread: o(0),
votesUnread: o(0),
followsUnread: o(0)
}
}
}
var firstFetch = true
function fetchLatestState (cb) {
var done = multicb({ pluck: 1 })
app.ssb.whoami(done())
app.ssb.patchwork.getNamesById(done())
app.ssb.patchwork.getAllProfiles(done())
app.ssb.patchwork.getActionItems(done())
app.ssb.patchwork.getIndexCounts(done())
app.ssb.gossip.peers(done())
done(function (err, data) {
if (err) throw err.message
app.user.id = data[0].id
app.users.names = data[1]
app.users.profiles = data[2]
app.actionItems = data[3]
app.indexCounts = data[4]
app.peers = data[5]
app.user.profile = app.users.profiles[app.user.id]
// update observables
app.observ.peers(app.peers)
var stats = require('./util').getPubStats()
app.observ.hasSyncIssue(!stats.membersof || !stats.active)
for (var k in app.indexCounts)
if (app.observ.indexCounts[k])
app.observ.indexCounts[k](app.indexCounts[k])
// refresh suggest options for usernames
app.suggestOptions['@'] = []
for (var id in app.users.profiles) {
if (id == app.user.profile.id || (app.user.profile.assignedTo[id] && app.user.profile.assignedTo[id].following)) {
var name = app.users.names[id]
app.suggestOptions['@'].push({
id: id,
cls: 'user',
title: name || id,
image: require('./com').profilePicUrl(id),
subtitle: name || id,
value: name || id.slice(1) // if using id, dont include the @ sigil
})
}
}
// do some first-load things
if (firstFetch) {
app.observ.newPosts(0) // trigger title render, so we get the correct name
firstFetch = false
}
cb()
})
}

43
ui/lib/com/composer.js Normal file
View File

@@ -0,0 +1,43 @@
'use strict'
var h = require('hyperscript')
var o = require('observable')
var com = require('./index')
module.exports = function (rootMsg, branchMsg, opts) {
var selection = o('post')
function navitem (icon, value) {
return o.transform(selection, function (s) {
return h('a'+((s == value) ? '.selected' : ''), { onclick: onSelect(value) }, com.icon(icon))
})
}
// markup
var header = h('.composer-header',
h('.composer-header-nav',
navitem('comment', 'post'),
navitem('facetime-video', 'webcam')
// navitem('picture', 'image')
),
h('.composer-header-body', o.transform(selection, function (s) {
if (s == 'post')
return com.postForm(rootMsg, branchMsg, { onpost: opts.onpost, noheader: true })
if (s == 'webcam')
return com.webcamGifferForm(rootMsg, branchMsg, { onpost: opts.onpost })
if (s == 'image')
return com.imagesForm()
}))
)
// handlers
function onSelect (value) {
return function (e) {
e.preventDefault()
selection(value)
}
}
return header
}

View File

@@ -0,0 +1,90 @@
var h = require('hyperscript')
var app = require('../app')
var com = require('./index')
module.exports = function (from, to, opts) {
var container = h('.connection-graph')
opts = opts || {}
opts.w = opts.w || 3
opts.h = opts.h || 1
app.ssb.friends.all(function (err, friends) {
// generate graph
var graph = { nodes: [], edges: [] }
for (var id in friends) {
// add node
var inbounds = countInbounds(friends, id)
if (id == from) {
graph.nodes.push({
id: id,
type: 'square',
label: com.userName(id),
x: 0.05 * opts.w,
y: 0.5 * opts.h,
size: inbounds+1,
color: '#970'
})
} else if (id == to) {
graph.nodes.push({
id: id,
type: 'square',
label: com.userName(id),
x: 0.95 * opts.w,
y: 0.5 * opts.h,
size: inbounds+1,
color: '#970'
})
} else if (onpath(friends, from, to, id)) {
var xr = Math.random() * 0.2
var yr = Math.random()
graph.nodes.push({
id: id,
type: 'square',
label: com.userName(id),
x: (0.4 + xr) * opts.w,
y: yr * opts.h,
size: inbounds+1,
color: '#790'
})
} else {
continue;
}
// show edges related to from/to
for (var id2 in friends[id]) {
if (id == from && onpath(friends, from, to, id2) || id2 == to && onpath(friends, from, to, id) || id == from && id2 == to) {
graph.edges.push({
id: id+'->'+id2,
source: id,
target: id2,
size: (id == from && id2 == to) ? 1 : 0.1,
color: (id == from && id2 == to) ? '#97a' : (id == from) ? '#c93' : '#9a3'
})
}
}
}
// render
var s = new sigma({
graph: graph,
renderer: { container: container, type: 'canvas' },
settings: opts
})
})
return container
}
function countInbounds (graph, id) {
var n=0
for (var id2 in graph) {
if (id in graph[id2])
n++
}
return n
}
function onpath (graph, from, to, id) {
if (graph[from] && graph[from][id] && graph[id] && graph[id][to])
return true
return false
}

View File

@@ -0,0 +1,29 @@
'use strict'
var h = require('hyperscript')
var mlib = require('ssb-msgs')
var pull = require('pull-stream')
var multicb = require('multicb')
var app = require('../app')
var com = require('../com')
var mustRenderOpts = { mustRender: true }
module.exports = function (opts) {
opts = opts || {}
// markup
var items = []
for (var uid in app.users.profiles) {
if (!opts.filter || opts.filter(app.users.profiles[uid]))
items.push(com.contactListing(app.users.profiles[uid], opts))
}
items.sort(function (a, b) {
return b.dataset.followers - a.dataset.followers
})
var feedel = h('.contact-feed', items.slice(0, opts.limit || 30))
if (items.length === 0 && opts.onempty)
opts.onempty(feedel)
return h('.contact-feed-container', feedel)
}

View File

@@ -0,0 +1,65 @@
var h = require('hyperscript')
var schemas = require('ssb-msg-schemas')
var app = require('../app')
var ui = require('../ui')
var modals = require('../ui/modals')
var com = require('./index')
var u = require('../util')
var social = require('../social-graph')
module.exports = function (profile, opts) {
// markup
var id = profile.id
var isfollowed = social.follows(app.user.id, profile.id)
var nfollowers = social.followedFollowers(app.user.id, id).length
var followbtn
renderFollow()
function renderFollow () {
if (id != app.user.id) {
var newbtn
if (!isfollowed)
newbtn = h('button.btn.btn-3d', { title: 'Follow', onclick: toggleFollow }, com.icon('plus'), ' Follow')
else
newbtn = h('button.btn.btn-3d', { title: 'Unfollow', onclick: toggleFollow }, com.icon('minus'), ' Unfollow')
if (followbtn)
followbtn.parentNode.replaceChild(newbtn, followbtn)
followbtn = newbtn
}
}
// render
var listing = h('.contact-listing' + ((opts && opts.compact) ? '.compact' : ''),
h('.profpic', com.userHexagon(id, (opts && opts.compact) ? 45 : 80)),
h('.details',
h('p.name', com.a('#/profile/'+id, app.users.names[id] || id)),
h('p', com.userRelationship(id, nfollowers))
),
(!opts || !opts.compact) ? h('.actions', followbtn) : ''
)
listing.dataset.followers = nfollowers
return listing
// handlers
function toggleFollow (e) {
e.preventDefault()
// optimistically render
isfollowed = !isfollowed
renderFollow()
// update
ui.pleaseWait(true, 1000)
app.ssb.publish((isfollowed) ? schemas.follow(profile.id) : schemas.unfollow(profile.id), function (err) {
ui.pleaseWait(false)
if (err) {
isfollowed = !isfollowed
renderFollow()
modals.error('Error While Publishing', err, 'This error occurred while trying to toggle follow on another user.')
}
})
}
}

View File

@@ -0,0 +1,68 @@
var h = require('hyperscript')
var schemas = require('ssb-msg-schemas')
var app = require('../app')
var com = require('./index')
var u = require('../util')
module.exports = function (profile, nfollowers, nflaggers) {
// markup
var contactId = profile.id
var isSelf = (contactId == app.user.id)
var profileImg = com.profilePicUrl(contactId)
var totem = h('.totem',
h('span.corner.topleft'),
h('span.corner.topright'),
h('span.corner.botleft', { 'data-overlay': 'Followers' }, h('.corner-inner', nfollowers, com.icon('user'))),
h('span.corner.botright', { 'data-overlay': 'Flags' }, h('.corner-inner', nflaggers, com.icon('flag'))),
h('a.profpic', { href: '#/profile/'+contactId }, com.hexagon(profileImg, 275)))
// profile title
var title = h('.title', h('h2', com.userName(contactId)))
// totem colors derived from the image
var tmpImg = document.createElement('img')
tmpImg.src = profileImg
tmpImg.onload = function () {
var rgb = u.getAverageRGB(tmpImg)
if (rgb) {
// color-correct to try to go within 96-128 of average
var avg = (rgb.r + rgb.g + rgb.b) / 3
if (avg > 128) {
rgb.r = (rgb.r/2)|0
rgb.g = (rgb.g/2)|0
rgb.b = (rgb.b/2)|0
avg = (rgb.r + rgb.g + rgb.b) / 3
}
var n=0
while (avg < 96 && (n++ < 50)) {
var ratio = (96 - avg)/96 + 1
if (ratio < 1.2)
ratio = 1.2
rgb.r = (rgb.r*ratio)|0
rgb.g = (rgb.g*ratio)|0
rgb.b = (rgb.b*ratio)|0
avg = (rgb.r + rgb.g + rgb.b) / 3
}
var rgb2 = { r: ((rgb.r/2)|0), g: ((rgb.g/2)|0), b: ((rgb.b/2)|0) }
try { title.querySelector('h2').style.color = 'rgb('+rgb2.r+','+rgb2.g+','+rgb2.b+')' } catch (e) {}
try { title.querySelector('h3').style.color = 'rgba('+rgb2.r+','+rgb2.g+','+rgb2.b+', 0.75)' } catch (e) {}
try { title.querySelector('p').style.color = 'rgba('+rgb2.r+','+rgb2.g+','+rgb2.b+', 0.75)' } catch (e) {}
function setColors (el) {
if (!el.classList.contains('selected')) {
el.style.color = 'rgba(255,255,255,0.35)'//'rgb('+rgb.r+','+rgb.g+','+rgb.b+')'
el.style.background = 'rgb('+rgb2.r+','+rgb2.g+','+rgb2.b+')'
} else {
el.style.color = 'rgba(255,255,255,0.5)'
el.style.background = 'rgb('+rgb.r+','+rgb.g+','+rgb.b+')'
}
}
Array.prototype.forEach.call(totem.querySelectorAll('.corner'), setColors)
}
}
return h('.contact-summary', totem, title)
}

42
ui/lib/com/files.js Normal file
View File

@@ -0,0 +1,42 @@
var h = require('hyperscript')
var com = require('./index')
var app = require('../app')
var u = require('../util')
module.exports = function (uid) {
var el = h('.files-items')
/*app.ssb.patchwork.*/getNamespace(uid, function (err, items) {
if (!items) return
items.forEach(function (item) {
el.appendChild(file(uid, item))
})
})
return h('.files',
h('.files-headers', h('div', 'Name'), h('div', 'File Size'), h('div', 'Modified')),
el
)
}
function file (uid, item) {
return h('.file',
h('.file-name',
h('a', u.getExtLinkName(item)),
h('.actions',
h('a', 'rename'),
h('a', 'delete')
)
),
h('.file-size', u.bytesHuman(item.size)),
h('.file-date', (new Date(item.timestamp)).toLocaleDateString())
)
}
// :HACK: remove me
function getNamespace (uid, cb) {
cb(null, [
{ name: 'cats.png', timestamp: Date.now(), size: 1503 },
{ name: 'WHOAMI.md', timestamp: Date.now()-100000, size: 51235 },
{ name: 'index.html', timestamp: Date.now()-1000440, size: 2234 },
{ name: 'index.js', timestamp: Date.now()-1001440, size: 35553 }
])
}

33
ui/lib/com/finder.js Normal file
View File

@@ -0,0 +1,33 @@
'use strict'
var h = require('hyperscript')
module.exports = function () {
var input = h('input', { onkeydown: onkeydown, placeholder: 'Search...' })
var finder = h('#finder', input)
function onkeydown (e) {
if (e.keyCode == 13) // enter
finder.find()
}
finder.find = find.bind(finder)
window.addEventListener('keydown', onwinkeydown)
function onwinkeydown (e) {
if (e.keyCode == 27) { // esc
window.removeEventListener('keydown', onwinkeydown)
finder.removeEventListener('keydown', onkeydown)
finder.parentNode.removeChild(finder)
}
}
return finder
}
function find () {
var el = this.querySelector('input')
var v = el.value
el.blur()
document.body.querySelector('#page').focus()
window.find(v,0,0,0,0,0,1)
}

91
ui/lib/com/flag-form.js Normal file
View File

@@ -0,0 +1,91 @@
var h = require('hyperscript')
var suggestBox = require('suggest-box')
var multicb = require('multicb')
var schemas = require('ssb-msg-schemas')
var app = require('../app')
var ui = require('../ui')
var modals = require('../ui/modals')
var com = require('./index')
var mentionslib = require('../mentions')
module.exports = function (id, opts) {
// markup
var name = com.userName(id)
var textarea = h('textarea.form-control', { placeholder: 'Write your reason for flagging here.', rows: 4 })
suggestBox(textarea, app.suggestOptions)
var form = h('.flag-form',
h('h3', com.icon('flag'), ' Flag "', name, '"'),
h('p.text-muted', h('small', 'Warn your followers about this user.')),
h('form', { onsubmit: onsubmit },
h('.radios',
opt('old-account', 'Old account'),
opt('spammer', 'Spammer'),
opt('abusive', 'Abusive'),
opt('nsfw', 'NSFW'),
opt('other', 'Other')
),
h('p', textarea),
h('p.text-right', h('button.btn.btn-3d', 'Publish'))
)
)
function opt (value, label) {
return h('.radio',
h('label',
h('input', { type: 'radio', name: 'flag-choice', value: value }),
label
)
)
}
// handlers
function onsubmit (e) {
e.preventDefault()
// prep text
ui.pleaseWait(true)
ui.setStatus('Publishing...')
var reason = textarea.value
var flag
try { flag = form.querySelector(':checked').value } catch (e) {}
mentionslib.extract(reason, function (err, mentions) {
if (err) {
ui.setStatus(null)
ui.pleaseWait(false)
if (err.conflict)
modals.error('Error While Publishing', 'You follow multiple people with the name "'+err.name+'." Go to the homepage to resolve this before publishing.')
else
modals.error('Error While Publishing', err, 'This error occurred while trying to extract the mentions from the text of a flag post.')
return
}
// publish
var done = multicb({ pluck: 1 })
app.ssb.publish(schemas.block(id), done())
app.ssb.publish(schemas.flag(id, flag||'other'), done())
done(function (err, msgs) {
if (err) {
ui.setStatus(null)
ui.pleaseWait(false)
return modals.error('Error While Publishing', err, 'This error occurred while trying to publish the block and flag messages.')
}
if (!reason) {
ui.setStatus(null)
ui.pleaseWait(false)
return opts.onsubmit()
}
app.ssb.publish(schemas.post(reason, msgs[1].key, msgs[1].key, (mentions.length) ? mentions : null), function (err) {
if (err) modals.error('Error While Publishing', err, 'This error occured while trying to publish the reason-post of a new flag.')
else opts.onsubmit()
})
})
})
}
return form
}

230
ui/lib/com/help.js Normal file
View File

@@ -0,0 +1,230 @@
var h = require('hyperscript')
var com = require('./index')
var app = require('../app')
var modals = require('../ui/modals')
var subwindows = require('../ui/subwindows')
exports.helpBody = function (item) {
if (item == 'howto-pubs') {
return h('div', { style: 'padding: 20px' },
h('p',
h('strong', 'To get on the public mesh, you need a public node to follow you. '),
'A public node is a usually a cloud server, but it could be any device with a public address. '
),
h('br'),
h('p',
h('strong', 'During the alpha period, you have to know somebody that runs a public node. '),
'Ask the node owner for an invite code, then ', com.a('#/sync', 'use the network-sync page'), ' to join their node.'
),
h('br'),
h('p',
h('strong', 'If you\'re a neckbeard, you can set up a public node. '),
'We have ', h('br'), h('a', { href: 'https://github.com/ssbc/scuttlebot', target: '_blank' }, 'detailed instructions'), ' available to help you get this done. '
)
)
}
if (item == 'howto-find-ppl') {
return h('div', { style: 'padding: 20px' },
h('p', h('strong', 'Method 1: Recommendations')),
h('p',
'Click the ', com.icon('user'), ' icon in the homepage links to see ', h('a', { href: '#/friends' }, 'your friends page'), '. ',
'It recommends users who your friends follow.'
),
h('br'),
h('p', h('strong', 'Method 2: Search')),
h('p',
'Try typing your friend\'s username into the location bar (top center). ',
'They may show up in the results.'
),
h('br'),
h('p', h('strong', 'Method 3: Send ID')),
h('p',
'Have your friend send you their ID. ',
'Then, put it in the location bar (top center) and press enter. ',
'If you need to download their data, Patchwork will prompt you to do so.'
),
h('p',
'To find your ID, open ', com.a('#/profile/'+app.user.id, 'your profile'), ' and copy it out of the location bar.'
)
)
}
if (item == 'howto-posts') {
return h('div', { style: 'padding: 20px' },
h('p',
h('strong', 'Go to the ', com.a('#/news', 'news feed')), ', click on the input box at the top and start typing. ',
'When you\'re happy with your post, press Publish.'
),
h('br'),
h('p',
h('strong', 'Markdown is supported, and you can mention other users by typing @, then their username. '),
'If the mentioned users follow you, they\'ll see the message in their inbox.'
),
h('br'),
h('p',
h('strong', 'You can insert emojis with the : character. '),
'Check the ', h('a', { href: 'http://www.emoji-cheat-sheet.com/', target: '_blank' }, 'Emoji Cheat Sheet'), ' to see what\'s available.',
h('.text-muted', { style: 'padding: 20px; padding-bottom: 10px' }, 'eg ":smile:" = ', h('img.emoji', { src: './img/emoji/smile.png', height: 20, width: 20}))
)
)
}
if (item == 'howto-webcam') {
return h('div', { style: 'padding: 20px' },
h('p',
h('strong', 'Go to the ', com.a('#/news', 'news feed')), ', then mouse over the ', com.icon('comment'), ' icon next to the input box at the top. ',
'Click the ', com.icon('facetime-video'), ' icon to select the webcam tool.'
),
h('br'),
h('p',
h('strong', 'Click and hold the video stream to record. '),
'Alternatively, click the record 1/2/3s buttons to record for fixed durations. ',
'You can record multiple times to put the clips together.'
),
h('br'),
h('p',
h('strong', 'You can add a text message on the right. '),
'As in text posts, you can put emojis and @-mentions in the text.'
)
)
}
if (item == 'howto-post-files') {
return h('div', { style: 'padding: 20px' },
h('p',
h('strong', 'Go to the ', com.a('#/news', 'news feed')), ', click on the input box at the top and start typing. ',
'The input will expand, and you\'ll be shown a link to add attachments. ',
'You can attach files up to 5MB.'
),
h('br'),
h('p',
h('strong', 'If you want to embed a photo, attach it, then put an ! in front of the inserted link. '),
'An example: '
),
h('pre', '![my photo](&XXsJbhxj+kv1cAVJkc7jttb7/JFBkHYwMkQtxZmk+cQ=.sha256)')
)
}
if (item == 'secret-messages') {
return h('div', { style: 'padding: 20px' },
h('p',
h('strong', 'Secret Messages'), ' are completely private messages. ',
'They are encrypted end-to-end, which means the network operators can not read them. ',
'The recipients, subject, and content are hidden. '
),
h('br'),
h('p',
h('strong', 'The recipients ', h('em', 'must'), ' follow you to see the message. '),
'If you happen to send a message to someone that doesn\'t follow you, ',
'then they\'ll receive the message once they do follow you.'
)
)
}
if (item == 'howto-secret-messages') {
return h('div', { style: 'padding: 20px' },
h('p',
'Open your ', com.a('#/inbox', 'inbox page'), ' and click "Secret Message." ',
'Then, add your recipients, write the message, and click Send.'
)
)
}
}
exports.helpTitle = function (item) {
return ({
'howto-pubs': 'How do I get onto the public mesh?',
'howto-find-ppl': 'How do I find people?',
'howto-posts': 'How do I make a new post?',
'howto-webcam': 'How do I make a webcam gif?',
'howto-post-files': 'How do I post a file or photo?',
'secret-messages': 'What are secret messages?',
'howto-secret-messages': 'How do I send a secret message?',
})[item] || ''
}
exports.welcome = function () {
return h('.message',
h('span.user-img', h('img', { src: com.profilePicUrl(false) })),
h('.message-inner',
h('ul.message-header.list-inline', h('li', h('strong', 'Scuttlebot'))),
h('.message-body',
h('.markdown',
h('h3', 'Hello! And welcome to ', h('strong', 'Patchwork.')),
h('p',
'Patchwork is an independent network of users. ',
'The software is Free and Open-source, and the data is stored on your computer.'
),
h('p', h('img.emoji', { src: './img/emoji/facepunch.png', height: 20, width: 20}), ' We fight for the user.')
)
)
),
h('.message-comments',
h('.message',
h('span.user-img', h('img', { src: com.profilePicUrl(false) })),
h('.message-inner',
h('ul.message-header.list-inline', h('li', h('strong', 'Scuttlebot'))),
h('.message-body',
h('.markdown',
h('h4', 'Step 1: Join a public mesh node ', h('img.emoji', { src: './img/emoji/computer.png', height: 20, width: 20})),
h('p', 'To reach across the Internet, you need to belong to a public mesh node, also known as a ', h('strong', 'Pub'), '. '),
h('.text-center', { style: 'padding: 7px; background: rgb(238, 238, 238); margin-bottom: 10px; border-radius: 5px;' },
h('a.btn.btn-3d', { href: '#', onclick: modals.invite }, com.icon('cloud'), ' Join a Public Node')
)
)
)
)
),
h('.message',
h('span.user-img', h('img', { src: com.profilePicUrl(false) })),
h('.message-inner',
h('ul.message-header.list-inline', h('li', h('strong', 'Scuttlebot'))),
h('.message-body',
h('.markdown',
h('h4', 'Step 2: Find your friends ', h('img.emoji', { src: './img/emoji/busts_in_silhouette.png', height: 20, width: 20})),
h('p', 'Have your friends send you their IDs so you can follow them. Paste the ID into the location bar, just like it\'s a URL.')
)
)
)
),
h('.message',
h('span.user-img', h('img', { src: com.profilePicUrl(false) })),
h('.message-inner',
h('ul.message-header.list-inline', h('li', h('strong', 'Scuttlebot'))),
h('.message-body',
h('.markdown',
h('h4', 'Step 3: ', h('img.emoji', { src: './img/emoji/metal.png', height: 20, width: 20})),
h('p', 'You can publish ', h('strong', 'Messages and Files'), ' using the box at the top of your feed, and ', h('strong', 'Secret Messages'), ' via friends\' profile pages.')
)
)
)
)
)
)
}
exports.side = function () {
function onhelp (topic) {
return function (e) {
e.preventDefault()
subwindows.help(topic)
}
}
function help (topic, text) {
return [
h('a', { style: 'color: #555', href: '#', onclick: onhelp(topic), title: text }, com.icon('question-sign'), ' ', text),
h('br')
]
}
return h('div',
h('strong', 'Help Topics:'), h('br'),
help('howto-pubs', 'How do I get onto the public mesh?'),
help('howto-find-ppl', 'How do I find people?'),
h('br'),
help('howto-posts', 'How do I make a new post?'),
help('howto-webcam', 'How do I make a webcam gif?'),
help('howto-post-files', 'How do I post a file or photo?'),
h('br'),
help('secret-messages', 'What are secret messages?'),
help('howto-secret-messages', 'How do I send a secret message?'),
help('howto-find-ppl', 'How do I read my messages?')
)
}

View File

@@ -0,0 +1,221 @@
var h = require('hyperscript')
var NativeImage = require('native-image')
var createHash = require('multiblob/util').createHash
var pull = require('pull-stream')
var pushable = require('pull-pushable')
var app = require('../app')
if (!('URL' in window) && ('webkitURL' in window))
window.URL = window.webkitURL
module.exports = function (opts) {
opts = opts || {}
// markup
var fileInput = h('input', { type: 'file', accept: 'image/*', onchange: fileChosen })
var canvas = h('canvas', {
onmousedown: onmousedown,
onmouseup: onmouseup,
onmouseout: onmouseup,
onmousemove: onmousemove,
width: 275,
height: 275
})
var zoomSlider = h('input', { type: 'range', value: 0, oninput: onresize })
var existing = h('.image-uploader-existing', opts.existing ? h('img', { src: opts.existing }) : '')
var viewer = h('div', existing, fileInput)
var editormsg = h('small', 'drag to crop')
var editor = h('.image-uploader-editor',
{ style: 'display: none' },
editormsg, h('br'),
canvas,
h('p', zoomSlider),
h('div',
h('button.btn.btn-3d.pull-right.savebtn', { onclick: onsave }, 'OK'),
h('button.btn.btn-3d', { onclick: oncancel }, 'Cancel')))
var el = h('.image-uploader', viewer, editor)
el.forceDone = forceDone.bind(el, opts)
// handlers
var img = h('img'), imgdim
var dragging = false, mx, my, ox=0, oy=0, zoom=1, minzoom=1
function draw () {
var ctx = canvas.getContext('2d')
ctx.globalCompositeOperation = 'source-over'
ctx.fillStyle = '#000'
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.drawImage(img, ox, oy, img.width * zoom, img.height * zoom)
if (dragging)
drawHexagonOverlay()
}
function drawHexagonOverlay () {
// hexagon coords (based on the behavior of the css hexagon)
var left = 20
var right = canvas.width - 20
var w12 = canvas.width / 2
var h14 = canvas.height / 4
var h34 = h14 * 3
var ctx = canvas.getContext('2d')
ctx.save()
ctx.fillStyle = '#fff'
ctx.globalAlpha = 0.75;
ctx.globalCompositeOperation = 'overlay'
ctx.beginPath()
ctx.moveTo(w12, 0)
ctx.lineTo(right, h14)
ctx.lineTo(right, h34)
ctx.lineTo(w12, canvas.height)
ctx.lineTo(left, h34)
ctx.lineTo(left, h14)
ctx.lineTo(w12, 0)
ctx.closePath()
ctx.fill()
ctx.restore()
}
function fileChosen (e) {
editor.style.display = 'block'
viewer.style.display = 'none'
editormsg.innerText = 'loading...'
// give the html renderer a turn before loading the image
// if the image is large, it'll block for a sec, and we want to render "loading..." first
setTimeout(function () {
var file = fileInput.files[0]
var ni = NativeImage.createFromPath(file.path)
img.src = ni.toDataUrl()
imgdim = ni.getSize()
var smallest = (imgdim.width < imgdim.height) ? imgdim.width : imgdim.height
ox = oy = 0
minzoom = zoom = 275/smallest
zoomSlider.value = 0
editormsg.innerText = 'drag to crop'
draw()
}, 100)
/*
:OLD: browser method, doesnt work in electron
var reader = new FileReader()
reader.onload = function (e) {
ox = oy = 0
zoom = 1
zoomSlider.value = 50
img.src = e.target.result
draw()
editor.style.display = 'block'
viewer.style.display = 'none'
}
reader.readAsDataURL(file)
*/
}
function onmousedown (e) {
e.preventDefault()
dragging = true
mx = e.clientX
my = e.clientY
draw()
}
function onmouseup (e) {
e.preventDefault()
dragging = false
draw()
}
function onmousemove (e) {
e.preventDefault()
if (dragging) {
ox = Math.max(Math.min(ox + e.clientX - mx, 0), -imgdim.width * zoom + 275)
oy = Math.max(Math.min(oy + e.clientY - my, 0), -imgdim.height * zoom + 275)
draw()
mx = e.clientX
my = e.clientY
}
}
function onresize (e) {
zoom = minzoom + (zoomSlider.value / 100)
draw()
}
function onsave (e) {
e.preventDefault()
if (!opts.onupload)
throw "onupload not specified"
var hasher = createHash('sha256')
var ps = pushable()
pull(
ps,
hasher,
app.ssb.blobs.add(function (err) {
if(err)
return modals.error('Failed to Upload Image to Blobstore', err)
fileInput.value = ''
editor.style.display = 'none'
viewer.style.display = 'block'
opts.onupload(hasher)
})
)
// Send to sbot
var dataUrl = canvas.toDataURL('image/png')
existing.querySelector('img').setAttribute('src', dataUrl)
ps.push(NativeImage.createFromDataUrl(dataUrl).toPng())
ps.end()
/*
:OLD: browser method, doesnt work in electron
canvas.toBlob(function (blob) {
// Send to sbot
var reader = new FileReader()
reader.onloadend = function () {
ps.push(new Buffer(new Uint8Array(reader.result)))
ps.end()
}
reader.readAsArrayBuffer(blob)
// Update "existing" img
var blobUrl = URL.createObjectURL(blob)
existing.querySelector('img').setAttribute('src', blobUrl)
setTimeout(function() { URL.revokeObjectURL(blobUrl) }, 50) // give 50ms to render first
}, 'image/png')
*/
}
function oncancel (e) {
e.preventDefault()
fileInput.value = ''
editor.style.display = 'none'
viewer.style.display = 'block'
}
return el
}
// helper to finish the edit in case the user forgets to press "OK"
function forceDone (opts, cb) {
this.forceDone = null // detach for memory cleanup
// not editing?
if (this.querySelector('.image-uploader-editor').style.display != 'block')
return cb() // we're good
// update cb to run after onupload
var onupload = opts.onupload
opts.onupload = function (hasher) {
onupload(hasher)
cb()
}
this.querySelector('.savebtn').click() // trigger upload
}

121
ui/lib/com/images-form.js Normal file
View File

@@ -0,0 +1,121 @@
'use strict'
var h = require('hyperscript')
var o = require('observable')
var mime = require('mime-types')
var com = require('./index')
var modals = require('../ui/modals')
module.exports = function () {
var images = []
// markup
var filesInput = h('input.hidden', { type: 'file', accept: 'image/*', multiple: true, onchange: filesAdded })
var imagesListEl = h('.images-form-list', { onclick: onlistclick })
var form = h('.images-form',
imagesListEl,
h('.images-form-ctrls',
h('a.btn.btn-3d', { onclick: onadd, title: 'Add a new image to the album' }, '+ Add Image'),
h('a.btn.btn-primary.pull-right.disabled', 'Publish')
)
)
// handlers
function onadd (e) {
e.preventDefault()
filesInput.click()
}
function onlistclick (e) {
if (images.length == 0)
onadd(e)
}
function onremove (hash) {
return function (e) {
e.preventDefault()
images = images.filter(function (img) { return img.link != hash })
imagesListEl.removeChild(imagesListEl.querySelector('.image[data-hash="'+hash+'"]'))
}
}
function filesAdded (e) {
// hash the files
var n = filesInput.files.length
ui.setStatus('Hashing ('+n+' files left)...')
for (var i=0; i < n; i++) {
if (!add(filesInput.files[i])) {
ui.setStatus(false)
return
}
}
filesInput.value = null
function add (f) {
if (f.size > 5 * (1024*1024)) {
var inMB = Math.round(f.size / (1024*1024) * 100) / 100
modals.error('Error Attaching File', f.name + ' is larger than the 5 megabyte limit (' + inMB + ' MB)')
return false
}
app.ssb.patchwork.addFileToBlobs(f.path, function (err, res) {
if (--n === 0)
ui.setStatus(false)
if (err) {
modals.error('Error Attaching File', err, 'This error occurred while trying to add a file to the blobstore.')
} else {
for (var i=0; i < images.length; i++) {
if (images[i].link == res.hash)
return
}
images.push({
link: res.hash,
name: f.name,
desc: '',
size: f.size,
width: res.width,
height: res.height,
type: mime.lookup(f.name) || undefined
})
imagesListEl.appendChild(h('.image', { 'data-hash': res.hash },
h('.image-img', h('img', { src: 'http://localhost:7777/'+res.hash })),
h('.image-ctrls',
h('p',
f.name,
h('a.pull-right.text-danger', { href: '#', title: 'Remove this image from the album', onclick: onremove(res.hash) }, com.icon('remove'))
),
h('textarea.form-control', { rows: 2, placeholder: 'Add a caption (optional)' })
)
))
}
})
return true
}
}
return form
}
/*
{
type: 'image-collection',
updates: {
link: MsgRef, // if this is an update, points to the original collection msg
deleted: Boolean // is the target deleted? defaults to false
},
title: String,
desc: String,
image: BlobLink, // the cover photo
includes: [{
link: BlobRef,
name: String,
desc: String,
size: Number, // in bytes
width: Number,
height: Number,
type: String // mimetype
}],
excludes: BlobLinks
}
*/

284
ui/lib/com/index.js Normal file
View File

@@ -0,0 +1,284 @@
'use strict'
var h = require('hyperscript')
var o = require('observable')
var pull = require('pull-stream')
var app = require('../app')
var ui = require('../ui')
var modals = require('../ui/modals')
var subwindows = require('../ui/subwindows')
var u = require('../util')
var social = require('../social-graph')
var suggestBox = require('suggest-box')
var ago = require('nicedate')
var a =
exports.a = function (href, text, opts) {
opts = opts || {}
opts.href = href
return h('a', opts, text)
}
var icon =
exports.icon = function (i) {
return h('span.glyphicon.glyphicon-'+i)
}
var userlink =
exports.userlink = function (id, text, opts) {
opts = opts || {}
opts.className = (opts.className || '') + ' user-link'
text = text || userName(id) || u.shortString(id)
return h('span.user-link-outer', a('#/profile/'+id, text, opts))
}
var user =
exports.user = function (id, opts) {
var followIcon
if (id != app.user.id && (!app.user.profile.assignedTo[id] || !app.user.profile.assignedTo[id].following)) {
followIcon = [' ', h('a',
{ title: 'This is not somebody you follow.', href: '#/profile/'+id },
h('span.text-muted', icon('question-sign'))
)]
}
var l = userlink
if (opts && opts.thin)
l = userlinkThin
var name = userName(id)
if (opts && opts.maxlength && name.length > opts.maxlength)
name = name.slice(0, opts.maxlength-3) + '...'
return [l(id, name), followIcon]
}
var userName =
exports.userName = function (id) {
return app.users.names[id] || u.shortString(id)
}
var profilePicUrl =
exports.profilePicUrl = function (id) {
var url = './img/default-prof-pic.png'
var profile = app.users.profiles[id]
if (profile) {
var link
// lookup the image link
if (profile.assignedBy[app.user.id] && profile.assignedBy[app.user.id].image)
link = profile.assignedBy[app.user.id].image
else if (profile.self.image)
link = profile.self.image
if (link) {
url = 'http://localhost:7777/'+link.link
// append the 'backup img' flag, so we always have an image
url += '?fallback=img'
// if we know the filetype, try to construct a good filename
if (link.type) {
var ext = link.type.split('/')[1]
if (ext) {
var name = app.users.names[id] || 'profile'
url += '&name='+encodeURIComponent(name+'.'+ext)
}
}
}
}
return url
}
var userImg =
exports.userImg = function (id) {
return h('a.user-img', { href: '#/profile/'+id },
h('img', { src: profilePicUrl(id) })
)
}
var userlinkThin =
exports.userlinkThin = function (id, text, opts) {
opts = opts || {}
opts.className = (opts.className || '') + 'thin'
return userlink(id, text, opts)
}
var hexagon =
exports.hexagon = function (url, size) {
var img = url ? 'url('+url+')' : 'none'
size = size || 30
return h('.hexagon-'+size, { 'data-bg': url, style: 'background-image: '+img },
h('.hexTop'),
h('.hexBottom'))
}
var userHexagon =
exports.userHexagon = function (id, size) {
return h('a.user-hexagon', { href: '#/profile/'+id },
hexagon(profilePicUrl(id), size)
)
}
var userRelationship =
exports.userRelationship = function (id, nfollowers, nflaggers) {
if (id == app.user.id)
return 'This is you!'
// gather followers that you follow
if (typeof nfollowers == 'undefined')
nfollowers = social.followedFollowers(app.user.id, id).length
var summary
if (social.follows(app.user.id, id)) {
summary = 'Followed by you'
if (nfollowers > 0)
summary += ' and ' + nfollowers + ' user' + (nfollowers==1?'':'s') + ' you follow'
} else {
if (nfollowers === 0)
summary = 'Not followed by you or anyone you follow'
else
summary = 'Followed by ' + nfollowers + ' user' + (nfollowers==1?'':'s') + ' you follow'
}
// gather flaggers that you follow (and self)
if (typeof nflaggers == 'undefined')
nflaggers = social.followedFlaggers(app.user.id, id, true).length
if (nflaggers !== 0) {
summary += '. Flagged by '+nflaggers+' user' + (nflaggers==1?'':'s')
}
return summary
}
var hovercard =
exports.hovercard = function (id) {
var name = userName(id)
var following = social.follows(app.user.id, id)
return h('.hovercard', { style: 'background-image: url('+profilePicUrl(id)+')' },
h('h3', userName(id)),
userRelationship(id),
(id != app.user.id) ? h('p', following ? 'You follow ' : 'You do not follow ', name) : ''
)
}
var userHexagrid =
exports.userHexagrid = function (uids, opts) {
var nrow = (opts && opts.nrow) ? opts.nrow : 3
var size = (opts && opts.size) ? opts.size : 60
var els = [], row = []
uids.forEach(function (uid) {
row.push(userHexagon(uid, size))
var n = (opts && opts.uneven && els.length % 2 == 1) ? nrow-1 : nrow
if (row.length >= n) {
els.push(h('div', row))
row = []
}
})
if (row.length)
els.push(h('div', row))
return h('.user-hexagrid-'+size, els)
}
var friendsHexagrid =
exports.friendsHexagrid = function (opts) {
var friends = []
friends.push(app.user.id)
for (var k in app.users.profiles) {
var p = app.users.profiles[k]
if (opts && opts.reverse) {
if (p.assignedTo[app.user.id] && p.assignedTo[app.user.id].following)
friends.push(p.id)
} else {
if (p.assignedBy[app.user.id] && p.assignedBy[app.user.id].following)
friends.push(p.id)
}
}
if (friends.length)
return userHexagrid(friends, opts)
}
exports.filterClasses = function () {
var cls = ''
if (!app.filters.nsfw)
cls += '.show-nsfw'
if (!app.filters.spam)
cls += '.show-spam'
if (!app.filters.abuse)
cls += '.show-abuse'
return cls
}
var nav =
exports.nav = function (opts) {
var items = opts.items.map(function (item) {
var cls = '.navlink-'+item[0]
if (item[0] == opts.current)
cls += '.selected'
if (item[3])
cls += item[3]
if (typeof item[1] == 'function')
return h('a'+cls, { href: '#', 'data-item': item[0], onclick: item[1] }, item[2])
return h('a'+cls, { href: item[1] }, item[2])
})
return h('.navlinks', items)
}
var search =
exports.search = function (opts) {
var searchInput = h('input.search', { type: 'text', name: 'search', placeholder: 'Search', value: opts.value })
return h('form', { onsubmit: opts.onsearch }, searchInput)
}
exports.paginator = function (base, start, count) {
var prevBtn = h('a.btn.btn-primary', { href: base+((start - 30 > 0) ? start - 30 : 0) }, icon('chevron-left'))
var nextBtn = h('a.btn.btn-primary', { href: base+(start+30) }, icon('chevron-right'))
if (start <= 0) prevBtn.setAttribute('disabled', true)
if (start+30 > count) nextBtn.setAttribute('disabled', true)
return h('p', prevBtn, (start + 1), ' - ', Math.min(count, (start + 30)), ' ('+count+')', nextBtn)
}
var panel =
exports.panel = function (title, content) {
return h('.panel.panel-default', [
(title) ? h('.panel-heading', h('h3.panel-title', title)) : '',
h('.panel-body', content)
])
}
var page =
exports.page = function (id, content) {
return h('#page.container-fluid.'+id+'-page', content)
}
exports.prettyRaw = require('./pretty-raw')
exports.messageFeed = require('./message-feed')
exports.message = require('./message')
exports.messageContent = require('./message-content')
exports.messageSummary = require('./message-summary')
exports.messageOneline = require('./message-oneline')
exports.messageAttachments = require('./message-attachments')
exports.messageStats = require('./message-stats')
exports.contactFeed = require('./contact-feed')
exports.contactPlaque = require('./contact-plaque')
exports.contactListing = require('./contact-listing')
exports.files = require('./files')
exports.notifications = require('./notifications')
exports.peers = require('./peers')
exports.postForm = require('./post-form')
exports.pmForm = require('./pm-form')
exports.webcamGifferForm = require('./webcam-giffer-form')
exports.imagesForm = require('./images-form')
exports.composer = require('./composer')
exports.imageUploader = require('./image-uploader')
exports.inviteForm = require('./invite-form')
exports.lookupForm = require('./lookup-form')
exports.renameForm = require('./rename-form')
exports.flagForm = require('./flag-form')
exports.networkGraph = require('./network-graph')
exports.connectionGraph = require('./connection-graph')
exports.userDownloader = require('./user-downloader')
exports.help = require('./help')
exports.pagenav = require('./nav').pagenav
exports.sidenav = require('./nav').sidenav
exports.webview = require('./webview')
exports.finder = require('./finder')

56
ui/lib/com/invite-form.js Normal file
View File

@@ -0,0 +1,56 @@
var h = require('hyperscript')
var com = require('./index')
module.exports = function (opts) {
// markup
var processingInfoText = h('p')
var processingInfo = h('.processing-info', h('.spinner', h('.cube1'), h('.cube2')), processingInfoText)
var errorText = h('span', 'Something went wrong!')
var error = h('.error.text-danger', com.icon('exclamation-sign'), ' ', errorText)
var useBtn = h('button.btn.btn-3d', 'Use Code')
var codeinput = h('input.form-control', { placeholder: 'Enter the invite code here' })
var form = h('.invite-form',
h('h3', 'Join a Public Node'),
h('form.form-inline', { onsubmit: function (e) { e.preventDefault(); opts.onsubmit(codeinput.value) } },
h('p', codeinput, useBtn)),
processingInfo,
error,
h('hr'),
h('p.text-muted', h('strong', 'Public nodes help you communicate across the Internet.')),
h('p.text-muted',
'Neckbeards can setup their own public nodes. ',
h('a', { href: 'https://github.com/ssbc/scuttlebot', target: '_blank' }, 'Read the server documentation here.')
),
h('p.text-muted',
'Don\'t have an invite to a public node? During the closed beta, you\'ll have to find a pub owner and ask for one.'
)
)
// api
form.disable = function () {
useBtn.setAttribute('disabled', true)
codeinput.setAttribute('disabled', true)
}
form.enable = function () {
useBtn.removeAttribute('disabled')
codeinput.removeAttribute('disabled')
}
form.setProcessingText = function (text) {
error.style.display = 'none'
processingInfoText.innerHTML = text
processingInfo.style.display = 'block'
}
form.setErrorText = function (text) {
processingInfo.style.display = 'none'
errorText.innerHTML = text
error.style.display = 'block'
}
return form
}

73
ui/lib/com/lookup-form.js Normal file
View File

@@ -0,0 +1,73 @@
var h = require('hyperscript')
var clipboard = require('clipboard')
var com = require('./index')
module.exports = function (opts) {
// markup
var processingInfoText = h('p')
var processingInfo = h('.processing-info', h('.spinner', h('.cube1'), h('.cube2')), processingInfoText)
var errorText = h('span', 'Something went wrong!')
var error = h('.error.text-danger', com.icon('exclamation-sign'), ' ', errorText)
var useBtn = h('button.btn.btn-3d', 'Find')
var codeinput = h('input.form-control', { placeholder: 'Enter your friend\'s lookup code here' })
var form = h('.lookup-form',
h('h3', 'Find a Friend'),
h('form.form-inline', { onsubmit: function (e) { e.preventDefault(); if (codeinput.value) { opts.onsubmit(codeinput.value) } } },
h('p', codeinput, useBtn)),
processingInfo,
error,
h('hr'),
h('p', h('strong', 'Lookup codes help you find users around the Internet.')),
h('.code',
h('p',
'Your Lookup Code ',
h('a.btn.btn-3d.btn-xs.pull-right', { href: '#', onclick: oncopy }, com.icon('copy'), ' Copy to clipboard')
),
h('p', h('input.form-control', { placeholder: 'Building...' }))
),
h('.text-muted', 'Send this to your friends, so they can find you.')
)
// handlers
function oncopy (e) {
e.preventDefault()
var btn = e.target
if (btn.tagName == 'SPAN')
btn = e.path[1]
clipboard.writeText(form.querySelector('.code input').value)
btn.innerText = 'Copied!'
}
// api
form.disable = function () {
useBtn.setAttribute('disabled', true)
codeinput.setAttribute('disabled', true)
}
form.enable = function () {
useBtn.removeAttribute('disabled')
codeinput.removeAttribute('disabled')
}
form.setYourLookupCode = function (code) {
form.querySelector('.code input').value = code
}
form.setProcessingText = function (text) {
error.style.display = 'none'
processingInfoText.innerHTML = text
processingInfo.style.display = 'block'
}
form.setErrorText = function (text) {
processingInfo.style.display = 'none'
errorText.innerHTML = text
error.style.display = 'block'
}
return form
}

View File

@@ -0,0 +1,36 @@
var h = require('hyperscript')
var mlib = require('ssb-msgs')
var querystring = require('querystring')
var com = require('./index')
var u = require('../util')
var imageTypes = {
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
svg: 'image/svg+xml'
}
function isImage (link) {
if (link.type && link.type.indexOf('image/') !== -1)
return true
if (link.name && imageTypes[link.name.split('.').slice(-1)[0].toLowerCase()])
return true
}
module.exports = function (msg) {
var els = []
mlib.indexLinks(msg.value.content, { ext: true }, function (link, rel) {
// var url = 'http://localhost:7777/'+link.link
// var qs = { name: u.getExtLinkName(link) }
// if (isImage(link))
// qs.fallback = 'img'
// url += querystring.stringify(qs)
var url = '#/webview/'+encodeURI(link.link)
if (isImage(link))
els.push(h('a', { href: url }, h('.image', { 'data-bg': 'http://localhost:7777/'+encodeURI(link.link), style: 'background-image: url(http://localhost:7777/'+encodeURI(link.link)+'?fallback=img)' })))
else
els.push(h('.file', h('a', { href: url }, com.icon('file'), ' ', link.name, ' ', h('small', (('size' in link) ? u.bytesHuman(link.size) : ''), ' ', link.type||''))))
})
return els.length ? h('.attachments', els) : undefined
}

View File

@@ -0,0 +1,157 @@
'use strict'
var h = require('hyperscript')
var mlib = require('ssb-msgs')
var schemas = require('ssb-msg-schemas')
var ssbref = require('ssb-ref')
var app = require('../app')
var modals = require('../ui/modals')
var com = require('./index')
var markdown = require('../markdown')
module.exports = function (msg) {
var c = msg.value.content
function md (str) {
return h('.markdown', { innerHTML: markdown.block(str, msg) })
}
try {
var s = ({
post: function () {
if (!c.text) return
var recps = mlib.links(c.recps).map(function (r, n) {
var user = com.user(r.link, { thin: true })
user[0].querySelector('.user-link').style.color = '#777'
if (n < c.recps.length-1)
return [user, ', ']
return user
})
if (recps && recps.length)
return h('div', h('p', 'To: ', recps), md(c.text))
return md(c.text)
},
contact: function () {
var subjects = mlib.links(c.contact).map(function (l) {
if (l.link === msg.value.author)
return 'self'
return com.user(l.link)
})
if (!subjects.length) return
if (c.following === true)
return h('h4', com.icon('user'), ' Followed ', subjects)
if (c.blocking === true)
return h('h4', com.icon(''), ' Blocked ', subjects)
if (c.following === false)
return h('h4', com.icon('minus'), ' Unfollowed ', subjects)
if (c.blocking === false)
return h('h4', com.icon('erase'), ' Unblocked ', subjects)
},
about: function () {
var about = mlib.link(c.about)
if (about.link == msg.value.author) {
if (c.image && c.name)
return h('h4', 'Set their image, and changed their name to ', c.name)
if (c.image)
return h('h4', 'Set their image')
if (c.name)
return h('h4', 'Changed their name to ', c.name)
} else {
if (c.name)
return h('h4', 'Set ', com.user(about.link), '\'s name to ', c.name)
}
},
vote: function () {
var items
var vote = mlib.link(c.vote)
if (!vote)
return
if (vote.value > 0)
items = [com.icon('star'), ' Starred ']
else if (vote.value <= 0)
items = [com.icon('erase'), ' Unstarred ']
if (ssbref.isMsgId(vote.link))
items.push(fetchMsgLink(vote.link))
else if (ssbref.isFeedId(vote.link))
items.push(com.user(vote.link))
else if (ssbref.isBlobId(vote.link))
items.push(com.a('#/webiew/'+vote.link, 'this file'))
return items
},
flag: function () {
var del
var flag = mlib.link(c.flag)
if (!flag)
return
if (app.user.id == msg.value.author) {
del = h('a.text-danger', { href: '#', onclick: onunflag, title: 'Remove this flag' }, h('small', com.icon('trash')))
function onunflag (e) {
e.preventDefault()
var p = del.parentNode
p.innerHTML = '<em>Flag removed</em>'
p.classList.remove('text-danger')
p.classList.add('text-muted')
// publish unflag
app.ssb.publish(schemas.unflag(mlib.link(c.flag).link, msg.key), function (err, flagmsg) {
if (err) {
modals.error('Error While Publishing', err, 'This error occured while trying to publish an unflag.')
}
})
}
}
if (ssbref.isFeedId(flag.link)) {
var target = com.userlink(flag.link)
if (!flag.reason)
return h('h4.text-danger', com.icon('erase'), ' Unflagged ', target)
if (typeof flag.reason == 'string')
return h('h4.text-danger', com.icon('flag'), ' Flagged ', target, ' as ', h('span.label.label-danger', flag.reason))
return h('h4.text-danger', com.icon('flag'), ' Flagged ', target)
} else {
if (!flag.reason)
return h('p.text-danger', com.icon('erase'), ' Unflagged ', target)
if (typeof flag.reason == 'string')
return h('p.text-danger', com.icon('flag'), ' ', h('span.label.label-danger', flag.reason), ' ', target, ' ', del)
return h('p.text-danger', com.icon('flag'), ' Flagged ', target, ' ', del)
}
},
pub: function () {
var pub = mlib.link(c.pub)
if (pub)
return h('h4', com.icon('cloud'), ' Announced a public peer: ', com.user(pub.link), ' at ', pub.host, ':', pub.port)
}
})[c.type]()
if (!s || s.length == 0)
s = false
} catch (e) {console.log(e)}
if (!s)
s = h('table.raw', com.prettyRaw.table(msg.value.content))
return s
}
function fetchMsgLink (mid) {
var link = com.a('#/msg/'+mid, 'this post')
var linkspan = h('span', link)
app.ssb.get(mid, function (err, msg) {
if (msg) {
linkspan.insertBefore(h('span', (msg.author == app.user.id) ? 'your ' : com.userName(msg.author) + '\'s', ' post'), link)
link.style.display = 'block'
link.style.padding = '8px 0'
link.style.color = 'gray'
link.textContent = link.innerText = shorten((msg.content.type == 'post') ? msg.content.text : msg.content.type, 255)
}
})
return linkspan
}
function shorten (str, n) {
n = n || 120
if (str.length > n)
str = str.slice(0, n-3) + '...'
return str
}

124
ui/lib/com/message-feed.js Normal file
View File

@@ -0,0 +1,124 @@
'use strict'
var h = require('hyperscript')
var mlib = require('ssb-msgs')
var pull = require('pull-stream')
var multicb = require('multicb')
var app = require('../app')
var com = require('../com')
var u = require('../util')
module.exports = function (opts) {
opts = opts || {}
var botcursor
var containerEl, feedEl
var fetching = false
if (!opts.feed)
opts.feed = app.ssb.createFeedStream
if (!opts.render)
opts.render = com.message
var cursor = opts.cursor
if (!cursor) {
cursor = function (msg) {
if (msg)
return [msg.value.timestamp, msg.value.author]
}
}
// markup
feedEl = h(opts.container||'.message-feed' + com.filterClasses())
containerEl = h('.message-feed-container', feedEl)
// message fetch
fetchBottom(function (n) {
if (opts.onempty && n === 0)
opts.onempty(feedEl)
})
function fetchBottom (cb) {
if (fetching) return
fetching = true
var numRendered = 0
fetchBottomBy(opts.limit||30)
function fetchBottomBy (amt) {
var lastmsg
var renderedKeys = []
pull(
opts.feed({ reverse: true, limit: amt||30, lt: cursor(botcursor) }),
pull.drain(function (msg) {
lastmsg = msg
// filter
if (opts.filter && !opts.filter(msg))
return
// render
var el = opts.render(msg)
if (el) {
feedEl.appendChild(el)
renderedKeys.push(msg.key)
numRendered++
}
}, function (err) {
if (err)
console.warn('Error while fetching messages', err)
// nothing new? stop
if (!lastmsg || (botcursor && botcursor.key == lastmsg.key)) {
fetching = false
return (cb && cb(numRendered))
}
botcursor = lastmsg
if (opts.markread)
app.ssb.patchwork.markRead(renderedKeys)
// fetch more if needed
var remaining = amt - renderedKeys.length
if (remaining > 0 && !opts.onefetch)
return fetchBottomBy(remaining)
// we're done
fetching = false
cb && cb(numRendered)
})
)
}
}
if (opts.live) {
pull(
opts.live,
pull.drain(function (msg) {
// filter
if (opts.filter && !opts.filter(msg))
return
// render
var el = opts.render(msg)
if (el) {
feedEl.insertBefore(el, feedEl.firstChild)
if (opts.markread)
app.ssb.patchwork.markRead(msg.key)
}
})
)
}
// behaviors
if (opts.infinite) {
window.onscroll = function (e) {
if ((window.innerHeight + window.scrollY) >= document.body.offsetHeight) {
// hit bottom
fetchBottom()
}
}
}
return containerEl
}

View File

@@ -0,0 +1,138 @@
'use strict'
var h = require('hyperscript')
var mlib = require('ssb-msgs')
var app = require('../app')
var com = require('./index')
var u = require('../util')
function getSummary (msg, opts) {
var c = msg.value.content
var maxlen = (opts && opts.menuitem) ? 40 : 100
function t(text) {
if (text.length > maxlen)
return text.slice(0, maxlen-3) + '...'
return text
}
try {
var s = ({
post: function () {
if (!c.text) return
if (mlib.link(c.root, 'msg'))
return com.a('#/msg/'+mlib.link(c.root).link + '?jumpto=' + encodeURIComponent(msg.key), t(c.text))
return com.a('#/msg/'+msg.key, t(c.text))
},
mail: function () {
return com.a('#/msg/'+msg.key, [c.subject||'(No Subject)', ' ', h('span.text-muted', t(c.body))])
},
vote: function () {
if (!mlib.link(c.vote, 'msg'))
return
var link, desc = h('span', 'this message')
if (c.vote == 1)
link = h('a', { href: '#/msg/'+mlib.link(c.vote).link }, com.icon('star'), ' ', desc)
else if (c.vote <= 0)
link = h('a', { href: '#/msg/'+mlib.link(c.vote).link }, com.icon('erase'), ' ', desc)
appendMsgSummary(desc, mlib.link(c.vote).link, t)
return link
},
flag: function () {
if (!mlib.link(c.flag, 'msg'))
return
if (c.flag)
return com.a('#/msg/'+mlib.link(c.flag).link, h('span.text-danger', com.icon('flag'), ' Flagged your post ', h('span.label.label-danger', c.flag)))
else
return com.a('#/msg/'+mlib.link(c.flag).link, h('span.text-danger', com.icon('erase'), ' Unflagged your post'))
}
})[c.type]()
if (!s || s.length == 0)
s = false
} catch (e) { }
if (!s)
s = h('div', t(JSON.stringify(msg.value.content)))
return s
}
module.exports = function (msg, opts) {
// markup
var content
if (typeof msg.value.content == 'string') {
// encrypted message, try to decrypt
content = h('div')
app.ssb.private.unbox(msg.value.content, function (err, decrypted) {
if (decrypted) {
// success, render content
msg.value.content = decrypted
var col = content.parentNode
var icon = com.icon('lock')
icon.style.marginRight = '5px'
col.removeChild(content)
col.appendChild(icon)
col.appendChild(getSummary(msg, opts))
}
})
} else
content = getSummary(msg, opts)
if (!content)
return
var msgOneline
if (opts && opts.menuitem) {
// get the href for the link
var c = msg.value.content
var href = '#/msg/'
if (mlib.link(c.root, 'msg'))
href += mlib.link(c.root).link + '?jumpto=' + encodeURIComponent(msg.key)
else if (mlib.link(c.voteTopic, 'msg'))
href += mlib.link(c.voteTopic).link
else
href += msg.key
// render based on type
if (c.type == 'vote') {
msgOneline = h('a.message-oneline-menuitem', { href: href },
h('.message-oneline-column.only', h('strong', com.userName(msg.value.author)), ' ', content)
)
} else {
msgOneline = h('a.message-oneline-menuitem', { href: href },
h('.message-oneline-column', h('strong', com.userName(msg.value.author))),
h('.message-oneline-column', content),
h('.message-oneline-column', ago(msg))
)
}
} else {
msgOneline = h('.message-oneline',
h('.message-oneline-column', com.userImg(msg.value.author)),
h('.message-oneline-column', com.user(msg.value.author, { maxlength: 15 })),
h('.message-oneline-column', content),
h('.message-oneline-column', ago(msg))
)
}
app.ssb.patchwork.isRead(msg.key, function (err, isread) {
if (!err && !isread)
msgOneline.classList.add('unread')
})
return msgOneline
}
function appendMsgSummary (el, mid, shorten) {
app.ssb.get(mid, function (err, msg) {
if (!msg) return
if (msg.content.type == 'post')
el.textContent = shorten(msg.content.text)
else if (msg.content.type == 'mail')
el.textContent = shorten(msg.content.subject)
})
}
function ago (msg) {
var str = u.prettydate(new Date(msg.value.timestamp))
if (str == 'yesterday')
str = '1d'
return h('small.text-muted', str, ' ago')
}

View File

@@ -0,0 +1,92 @@
var h = require('hyperscript')
var schemas = require('ssb-msg-schemas')
var app = require('../app')
var com = require('./index')
var modals = require('../ui/modals')
var u = require('../util')
var hexagridOpts = { size: 30, nrow: 10 }
module.exports = function (msg, opts) {
var stats = (msg) ? u.calcMessageStats(msg, opts) : {}
// markup
var upvoted = (stats.uservote === 1) ? '.selected' : ''
var downvoted = (stats.uservote === -1) ? '.selected' : ''
var upvote = h('a.upvote'+upvoted, { href: '#', onclick: (opts && opts.handlers) ? onupvote : null }, com.icon('triangle-top'))
var downvote = h('a.downvote'+downvoted, { href: '#', onclick: (opts && opts.handlers) ? ondownvote : null }, com.icon('triangle-bottom'))
var voteTally = h('span.vote-tally', { 'data-amt': stats.voteTally||0 })
// up/down voter hexagrids
var upvoters = [], downvoters = []
if (stats.votes) {
for (var uid in stats.votes) {
var v = stats.votes[uid]
if (v === 1) upvoters.push(uid)
if (v === -1) downvoters.push(uid)
}
}
var upvotersGrid, downvotersGrid
if (upvoters.length) {
upvotersGrid = com.userHexagrid(upvoters, hexagridOpts)
upvotersGrid.classList.add('upvoters')
}
if (downvoters.length) {
downvotersGrid = com.userHexagrid(downvoters, hexagridOpts)
downvotersGrid.classList.add('downvoters')
}
return h('.message-stats',
h('div',
h('span.stat.votes', upvote, voteTally, downvote),
h('a.stat.comments', { href: (msg) ? '#/msg/'+msg.key : 'javascript:void(0)', 'data-amt': stats.comments||0 }, com.icon('comment'))),
upvotersGrid,
downvotersGrid
)
// handlers
function onupvote (e) {
vote(e, upvote, 1)
}
function ondownvote (e) {
vote(e, downvote, -1)
}
var voting = false
function vote (e, el, btnVote) {
e.preventDefault()
e.stopPropagation()
if (voting)
return // wait please
voting = true
// 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 = el.classList.contains('selected')
var newvote = (wasSelected) ? 0 : btnVote // toggle behavior: unset
el.classList.toggle('selected') // optimistice ui update
// :TODO: use msg-schemas
app.ssb.publish(schemas.vote(msg.key, newvote), function (err) {
voting = false
if (err) {
el.classList.toggle('selected') // undo
modals.error('Error While Publishing', err)
} else {
// update ui
var delta = newvote - (stats.uservote || 0)
voteTally.dataset.amt = stats.voteTally = stats.voteTally + delta
stats.uservote = newvote
var up = (newvote === 1) ? 'add' : 'remove'
var down = (newvote === -1) ? 'add' : 'remove'
upvote.classList[up]('selected')
downvote.classList[down]('selected')
}
})
}
}

View File

@@ -0,0 +1,141 @@
'use strict'
var h = require('hyperscript')
var pull = require('pull-stream')
var mlib = require('ssb-msgs')
var ssbref = require('ssb-ref')
var multicb = require('multicb')
var app = require('../app')
var com = require('./index')
var u = require('../util')
var markdown = require('../markdown')
function shorten (str, n) {
n = n || 120
if (str.length > n)
str = str.slice(0, n-3) + '...'
return str
}
function getSummary (msg) {
var c = msg.value.content
function md (str) {
return h('.markdown', { innerHTML: markdown.block(str, msg) })
}
try {
var s = ({
init: function () {
return [com.icon('off'), ' created account.']
},
post: function () {
if (!c.text) return
if (mlib.link(c.root, 'msg'))
return [com.icon('share-alt'), ' replied ', ago(msg), h('a.msg-link', { style: 'color: #555', href: '#/msg/'+mlib.link(c.root).link }, shorten(c.text, 255))]
if (mlib.links(c.mentions).filter(function(link) { return mlib.link(link).link == app.user.id }).length)
return [com.icon('hand-right'), ' mentioned you ', ago(msg), h('a.msg-link', { style: 'color: #555', href: '#/msg/'+msg.key }, shorten(c.text, 255))]
return md(c.text)
},
pub: function () {
return [com.icon('cloud'), ' announced a public peer at ', c.address]
},
contact: function () {
var subjects = mlib.links(c.contact).map(function (l) {
if (l.link === msg.value.author)
return 'self'
if (l.link === app.user.id)
return 'you'
return com.user(l.link)
})
if (!subjects.length) return
var items = []
if (c.following === true)
items.push(['followed ', subjects])
else if (c.blocking === true)
items.push(['blocked ', subjects])
else if (c.following === false)
items.push(['unfollowed ', subjects])
else if (c.blocking === false)
items.push(['unblocked ', subjects])
if (items.length===0)
return
items.push([' ', ago(msg)])
return items
},
vote: function () {
var items
var vote = mlib.link(c.vote)
if (!vote)
return
if (vote.value > 0)
items = [com.icon('star'), ' Starred ']
else if (vote.value <= 0)
items = [com.icon('erase'), ' Unstarred ']
if (ssbref.isMsgId(vote.link))
items.push(fetchMsgLink(vote.link))
else if (ssbref.isFeedId(vote.link))
items.push(com.user(vote.link))
else if (ssbref.isBlobId(vote.link))
items.push(com.a('#/webiew/'+vote.link, 'this file'))
return items
}
})[c.type]()
if (!s || s.length == 0)
s = false
return s
} catch (e) { console.log(e); return '' }
}
module.exports = function (msg, opts) {
// markup
var content = getSummary(msg, opts)
if (!content)
return
var msgSummary = h('.message-summary',
com.userImg(msg.value.author),
h('.message-summary-content', com.user(msg.value.author), ' ', content)
)
return msgSummary
}
module.exports.raw = function (msg, opts) {
// markup
var msgSummary = h('.message-summary',
com.userImg(msg.value.author),
h('.message-summary-content',
com.user(msg.value.author), ' ', ago(msg), ' ', h('small.pull-right', com.a('#/msg/'+msg.key, msg.key)),
h('table.raw', com.prettyRaw.table(msg.value.content)
))
)
return msgSummary
}
function ago (msg) {
var str = u.prettydate(new Date(msg.value.timestamp))
if (str == 'yesterday')
str = '1d'
return h('small.text-muted', str, ' ago')
}
function fetchMsgLink (mid) {
var link = h('a.msg-link', { href: '#/msg/'+mid }, 'this message')
app.ssb.get(mid, function (err, msg) {
if (msg) {
console.log(msg)
var str = (msg.content.type == 'post') ? msg.content.text : ('this '+msg.content.type)
link.innerHTML = markdown.block(str, { key: mid, value: msg })
}
})
return link
}

394
ui/lib/com/message.js Normal file
View File

@@ -0,0 +1,394 @@
'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 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)),
(mlib.link(msg.value.content.root)) ? h('li', h('em', h('a.text-muted', { href: '#/msg/'+mlib.link(msg.value.content.root).link }, 'replies to...'))) : '',
!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', (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.'))
}
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 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
}

171
ui/lib/com/nav.js Normal file
View File

@@ -0,0 +1,171 @@
var h = require('hyperscript')
var o = require('observable')
var ssbref = require('ssb-ref')
var com = require('./index')
var ui = require('../ui')
var modals = require('../ui/modals')
var subwindows = require('../ui/subwindows')
var app = require('../app')
var u = require('../util')
function addressbar () {
// markup
var addressInput = h('input', { value: '', onfocus: onfocus, onkeyup: onkeyup })
// handlers
function onfocus (e) {
setTimeout(function () { // shameless setTimeout to wait for default behavior (preventDefault doesnt seem to stop it)
addressInput.select() // select all on focus
}, 50)
}
function onkeyup (e) {
var v = addressInput.value
if (e.keyCode == 13 && v) {
if (v.charAt(0) == '@' && v.indexOf('.ed25519') !== -1)
window.location.hash = '#/profile/'+v
else if (v.charAt(0) == '&')
window.location.hash = '#/webview/'+v
else if (v.charAt(0) == '%')
window.location.hash = '#/msg/'+v
else
window.location.hash = '#/search/'+v
}
}
return [
addressInput,
h('.address-icon', com.icon('search'))
]
}
exports.pagenav = function () {
// markup
// dropdowns
function onmenuclick (e) {
e.preventDefault()
e.stopPropagation()
// toggle warning sign on network sync
var syncWarningIcon
if (app.observ.hasSyncIssue())
syncWarningIcon = com.icon('warning-sign.text-danger')
ui.dropdown(this, [
h('a.item', { onclick: subwindows.pm, title: 'Compose an encrypted message' }, com.icon('lock'), ' Secret Message'),
h('a.item', { href: '#/inbox', title: 'Your inbox' }, com.icon('inbox'), ' Inbox (', app.observ.indexCounts.inboxUnread, ')'),
h('a.item', { href: '#/profile/'+app.user.id, title: 'View your site' }, com.icon('user'), ' Your Profile'),
h('hr'),
h('a.item', { href: '#/sync', title: 'Review the status of your network connections' }, com.icon('circle-arrow-down'), ' Network Sync ', syncWarningIcon),
h('a.item', { onclick: modals.invite, title: 'Connect to a public node using an invite code' }, com.icon('cloud'), ' Join a Public Node'),
h('hr'),
h('a.item', { href: '#/feed', title: 'View the raw data feed' }, com.icon('th-list'), ' Behind the Scenes'),
h('a.item.noicon', { href: 'https://github.com/ssbc/patchwork/issues/new', target: '_blank', title: 'File a suggestion or issue' }, com.icon('bullhorn'), ' File an Issue')
], { right: true, offsetY: 5 })
}
function onsideview () {
app.observ.sideview(!app.observ.sideview())
}
var sideviewBtn = o.transform(app.observ.sideview, function (b) {
var enabled = (app.page.id == 'profile' && ssbref.isFeedId(app.page.param))
return h('a.button'+(enabled?'':'.disabled'),
{ onclick: ((enabled) ? onsideview : null), title: 'Toggle the about panel' },
com.icon(b&&enabled ? 'collapse-down' : 'collapse-up')
)
})
// toggle warning sign on network sync
var networkSync = o.transform(app.observ.hasSyncIssue, function (b) {
if (b)
return h('a.button', { href: '#/sync', title: 'Warning! You are not online' }, com.icon('warning-sign.text-danger'))
return h('a.button', { href: '#/sync', title: 'Review the status of your network connections' }, com.icon('circle-arrow-down'))
})
// render nav
return h('.page-nav-inner',
h('a.button.home', { href: '#/', title: 'Home page launcher' }, com.icon('home')),
h('a.button', { onclick: ui.navBack, title: 'Go back' }, com.icon('arrow-left')),
h('a.button', { onclick: ui.navForward, title: 'Go forward' }, com.icon('arrow-right')),
h('a.button', { onclick: ui.navRefresh, title: 'Refresh this page' }, com.icon('refresh')),
addressbar(),
// sideviewBtn,
networkSync,
h('a.button', { onclick: onmenuclick }, com.icon('menu-hamburger'))
)
function item (id, path, label, extra_cls) {
var selected = (id == app.page.id) ? '.selected' : ''
return h('a.pagenav-'+id+(extra_cls||'')+selected, { href: '#/'+path }, label)
}
}
exports.sidenav = function (opts) {
function onviewclick (view) {
return function (e) {
e.preventDefault()
app.homeMode.view = view
ui.refreshPage()
}
}
function view (view, label) {
if (app.homeMode.view == view)
return h('li.view', h('strong', h('a', { href: '#', onclick: onviewclick(view) }, label)))
return h('li.view', h('a', { href: '#', onclick: onviewclick(view) }, label))
}
function onoptionsclick (e) {
e.preventDefault()
e.stopPropagation()
function label(b, l) {
return [com.icon(b ? 'check' : 'unchecked'), l]
}
ui.dropdown(e.target, [
{ value: 'live', label: label(app.homeMode.live, ' Livestream'), title: 'Show new updates to the feed in realtime' },
{ value: 'nsfw', label: label(app.filters.nsfw, ' NSFW Filter'), title: 'Show/hide posts flagged as NSFW by people you follow' },
{ value: 'spam', label: label(app.filters.spam, ' Spam Filter'), title: 'Show/hide posts flagged as Spam by people you follow' },
{ value: 'abuse', label: label(app.filters.abuse, ' Abuse Filter'), title: 'Show/hide posts flagged as Abuse by people you follow' }
], function (choice) {
if (choice == 'live') {
app.homeMode.live = !app.homeMode.live
ui.refreshPage()
} else {
var hide = !app.filters[choice]
app.filters[choice] = hide
if (!hide)
document.querySelector('.message-feed').classList.add('show-'+choice)
else
document.querySelector('.message-feed').classList.remove('show-'+choice)
}
})
}
return h('ul.list-unstyled.sidenav',
h('li', h('h4', 'feed ', h('a', { href: '#', onclick: onoptionsclick, title: 'Options for this feed view', style: 'font-size: 12px; color: gray;' }, 'options'))),
view('all', ['all', h('small', ' users on your network')]),
view('friends', ['friends', h('small', ' that you have followed')]),
o.transform(app.observ.peers, function (peers) {
// :HACK: hyperscript needs us to return an Element if it's going to render
// we really shouldnt be returning a div here, but it does render correctly
// would be better to update hyperscript to correctly handle an array
return h('div', peers
.sort(function (a, b) {
if (!a.announcers) return -1
if (!b.announcers) return 1
return (a.announcers.length - b.announcers.length)
})
.map(function (peer) {
if (!peer.time || !peer.time.connect) return
return view(peer.key, [peer.host, h('small', ' members')])
})
.filter(Boolean)
)
}),
h('li', h('br')),
h('li', h('h4', 'follows and flags'))
)
}

104
ui/lib/com/network-graph.js Normal file
View File

@@ -0,0 +1,104 @@
var h = require('hyperscript')
var app = require('../app')
var com = require('./index')
if ('sigma' in window) {
sigma.canvas.nodes.square = function (node, context, settings) {
var prefix = settings('prefix') || '',
size = node[prefix + 'size']
context.strokeStyle = node.color || settings('defaultNodeColor')
context.fillStyle = 'rgba(238,238,238,0.7)'
context.beginPath()
context.rect(
node[prefix + 'x'] - size,
node[prefix + 'y'] - size,
size * 2,
size * 2
)
context.closePath()
context.fill()
context.stroke()
}
}
module.exports = function (opts) {
var container = h('.network-graph')
opts = opts || {}
opts.w = opts.w || 3
opts.h = opts.h || 1
app.ssb.friends.all(function (err, friends) {
// generate graph
var graph = { nodes: [], edges: [] }
for (var id in friends) {
// add node
var inbounds = countInbounds(friends, id)
var xr = Math.random()
var yr = Math.random()
if (xr > 0.45 && xr <= 0.5) xr -= 0.1
if (yr > 0.45 && yr <= 0.5) yr -= 0.1
if (xr < 0.55 && xr >= 0.5) xr += 0.1
if (yr < 0.55 && yr >= 0.5) yr += 0.1
graph.nodes.push({
id: id,
type: 'square',
label: com.userName(id),
x: (id == app.user.id) ? 1.5 : xr * opts.w,
y: (id == app.user.id) ? 0.5 : yr * opts.h,
size: inbounds+1,
color: (id == app.user.id) ? '#970' : (friends[app.user.id] && friends[app.user.id][id] ? '#790' : (friends[id][app.user.id] ? '#00c' : '#666'))
})
// show edges related to current user
if (id == app.user.id) {
// outbound
for (var id2 in friends[id]) {
graph.edges.push({
id: id+'->'+id2,
source: id,
target: id2,
size: 0.1,
color: '#9a3'
})
}
} else {
// inbound
if (friends[id][app.user.id]) {
graph.edges.push({
id: id+'->'+app.user.id,
source: id,
target: app.user.id,
size: 0.1,
color: '#97a'
})
}
}
}
// empty graph?
if (graph.edges.length === 0) {
// how embarrassing, plz hide it
container.style.height = '1px'
return
}
// render
var s = new sigma({
graph: graph,
renderer: { container: container, type: 'canvas' },
settings: opts
})
})
return container
}
function countInbounds (graph, id) {
var n=0
for (var id2 in graph) {
if (id in graph[id2])
n++
}
return n
}

View File

@@ -0,0 +1,30 @@
var h = require('hyperscript')
var o = require('observable')
var app = require('../app')
var com = require('./index')
module.exports = function () {
// markup
var notes = []
for (var k in app.actionItems) {
var item = app.actionItems[k]
if (item.type == 'name-conflict') {
notes.push(h('.note.warning',
h('h3', 'Heads up!'),
h('p', 'You are following more than one user named "'+item.name+'." You need to rename one of them to avoid confusion.'),
h('ul.list-inline', item.ids.map(function (id) { return h('li', com.userImg(id), ' ', com.user(id)) }))
))
}
}
return (notes.length) ? h('.notifications', notes) : null
}
module.exports.side = function () {
return o.transform(app.observ.hasSyncIssue, function (b) {
if (!b) return ''
return h('.well', { style: 'margin-top: 5px' }, h('a.text-muted', { href: '#/sync' }, com.icon('warning-sign'), ' You\'re not connected to the public mesh.'))
})
}

87
ui/lib/com/peers.js Normal file
View File

@@ -0,0 +1,87 @@
'use strict'
var h = require('hyperscript')
var app = require('../app')
var com = require('./index')
var util = require('../util')
module.exports = function (peers) {
// markup
var rows = peers.sort(sorter).map(function (peer) {
var muted = (peer.connected) ? '' : '.text-muted'
var id = '', status = '', history = ''
if (peer.id) {
id = com.userlink(peer.id, app.users.names[peer.id])
} else
id = peer.host
if (peer.connected) {
if (peer.time && peer.time.connect)
status = 'connected'
else {
if (peer.failure)
status = 'connecting (try '+(peer.failure+1)+')...'
else
status = 'connecting...'
}
}
if (peer.time) {
if (peer.time.connect > peer.time.attempt)
history = 'connected '+util.prettydate(peer.time.connect, true)
else if (peer.time.attempt) {
if (peer.connected)
history = 'started attempt '+util.prettydate(peer.time.attempt, true)
else
history = 'attempted connect '+util.prettydate(peer.time.attempt, true)
}
}
return h('tr',
h('td'+muted,
id,
(peer.connected) ? ' '+status : h('a.btn.btn-xs.btn-default', { href: '#', title: 'Syncronize now', onclick: syncronize(peer) }, com.icon('transfer')),
h('br'),
h('small.text-muted', history)
)
)
})
if (rows.length === 0)
rows.push(h('tr', h('td.text-muted', 'No known peers')))
// put connected peers at top
function sorter(a, b) {
var an = 0, bn = 0
if (a.connected) an += 100
if (b.connected) bn += 100
if (a.failure) an -= a.failure
if (b.failure) bn -= b.failure
return bn - an
}
// handlers
function syncronize (p) {
return function (e) {
e.preventDefault()
app.ssb.gossip.connect(p, function (err) {
if (err)
return console.error(err)
var node = e.target
var parent = node.parentNode
if (parent.tagName == 'A') {
node = parent
parent = node.parentNode
}
parent.insertBefore(h('span', ' connecting...'), node)
parent.removeChild(node)
})
}
}
return rows
}

201
ui/lib/com/pm-form.js Normal file
View File

@@ -0,0 +1,201 @@
'use strict'
var h = require('hyperscript')
var suggestBox = require('suggest-box')
var schemas = require('ssb-msg-schemas')
var refs = require('ssb-ref')
var createHash = require('multiblob/util').createHash
var pull = require('pull-stream')
var pushable = require('pull-pushable')
var app = require('../app')
var ui = require('../ui')
var modals = require('../ui/modals')
var com = require('./index')
var util = require('../util')
var markdown = require('../markdown')
var mentionslib = require('../mentions')
var social = require('../social-graph')
module.exports = function (opts) {
var recipients = []
var placeholder = (opts && opts.placeholder) ? opts.placeholder : ''
// make sure there are no name conflicts first
var conflicts = []
for (var k in app.actionItems) {
var item = app.actionItems[k]
if (item.type == 'name-conflict') {
conflicts.push(h('.note.warning',
h('h3', 'Heads up!'),
h('p', 'You are following more than one user named "'+item.name+'." You need to rename one of them before you send secret messages, to avoid confusion.'),
h('ul.list-inline', item.ids.map(function (id) { return h('li', com.userImg(id), ' ', com.user(id)) }))
))
}
}
if (conflicts.length)
return h('.notifications', { style: 'margin-top: 24px' }, conflicts)
// markup
var recpInput = h('input', { onsuggestselect: onSelectRecipient, onkeydown: onRecpInputKeydown })
var recipientsEl = h('.pm-form-recipients', h('span.recp-label', 'To'), recpInput)
var textarea = h('textarea', { name: 'text', placeholder: placeholder, onkeyup: onTextChange })
var postBtn = h('button.postbtn.btn', { disabled: true }, 'Send')
suggestBox(textarea, app.suggestOptions)
suggestBox(recpInput, { any: app.suggestOptions['@'] }, { cls: 'msg-recipients' })
renderRecpList()
if (opts && opts.recipients)
opts.recipients.forEach(addRecp)
var form = h('form.pm-form', { onsubmit: post },
recipientsEl,
h('.pm-form-textarea', textarea),
h('.pm-form-attachments', postBtn)
)
function disable () {
postBtn.setAttribute('disabled', true)
}
function enable () {
postBtn.removeAttribute('disabled')
}
function renderRecpList () {
// remove all .recp
Array.prototype.forEach.call(recipientsEl.querySelectorAll('.recp'), function (el) {
recipientsEl.removeChild(el)
})
// render
recipients.forEach(function (id) {
recipientsEl.insertBefore(h('.recp',
com.icon('lock'),
' ',
com.userName(id),
' ',
h('a', { href: '#', onclick: onRemoveRecipient, 'data-id': id, innerHTML: '&times;', tabIndex: '-1' })
), recpInput)
})
resizeTextarea()
}
// handlers
function onTextChange (e) {
if (recipients.length && textarea.value.trim())
enable()
else
disable()
}
function addRecp (id) {
// enforce limit
if (recipients.length >= 7) {
ui.notice('warning', 'Cannot add @'+com.userName(id)+' - You have reached the limit of 7 recipients on a Secret Message.')
recpInput.value = ''
return
}
// warn if the recipient doesnt follow the current user
if (id !== app.user.id && !social.follows(id, app.user.id))
ui.notice('warning', 'Warning: @'+com.userName(id)+' does not follow you, and may not receive your message.')
// remove if already exists (we'll push to end of list so user sees its there)
var i = recipients.indexOf(id)
if (i !== -1)
recipients.splice(i, 1)
// add, render
recipients.push(id)
recpInput.value = ''
renderRecpList()
}
function onSelectRecipient (e) {
addRecp(e.detail.id)
}
function onRemoveRecipient (e) {
e.preventDefault()
var i = recipients.indexOf(e.target.dataset.id)
if (i !== -1) {
recipients.splice(i, 1)
renderRecpList()
recpInput.focus()
}
}
function onRecpInputKeydown (e) {
// backspace on an empty field?
if (e.keyCode == 8 && recpInput.value == '' && recipients.length) {
recipients.pop()
renderRecpList()
}
}
// dynamically sizes the textarea based on available space
// (no css method, including flexbox, would really nail this one)
function resizeTextarea () {
try {
var height = 400 - 4
height -= recipientsEl.getClientRects()[0].height
height -= form.querySelector('.pm-form-attachments').getClientRects()[0].height
textarea.style.height = height + 'px'
} catch (e) {
// ignore, probably havent rendered yet
}
}
function post (e) {
e.preventDefault()
var text = textarea.value
if (!text.trim())
return
disable()
ui.pleaseWait(true)
// prep text
mentionslib.extract(text, function (err, mentions) {
if (err) {
ui.setStatus(null)
ui.pleaseWait(false)
enable()
if (err.conflict)
modals.error('Error While Publishing', 'You follow multiple people with the name "'+err.name+'." Go to the homepage to resolve this before publishing.')
else
modals.error('Error While Publishing', err, 'This error occured while trying to extract the mentions from a secret message text.')
return
}
// make sure the user is in the recipients
if (recipients.indexOf(app.user.id) === -1)
recipients.push(app.user.id)
// list recipients with their names
var recps = recipients.map(function (id) {
return { link: id, name: com.userName(id) }
})
// publish
var post = schemas.post(text, null, null, mentions, recps)
app.ssb.private.publish(post, recipients, function (err, msg) {
ui.setStatus(null)
enable()
ui.pleaseWait(false)
if (err) modals.error('Error While Publishing', err, 'This error occured while trying to private-publish a new secret message.')
else {
app.ssb.patchwork.subscribe(msg.key)
app.ssb.patchwork.markRead(msg.key)
opts && opts.onpost && opts.onpost(msg)
}
})
})
}
return form
}

185
ui/lib/com/post-form.js Normal file
View File

@@ -0,0 +1,185 @@
'use strict'
var h = require('hyperscript')
var suggestBox = require('suggest-box')
var schemas = require('ssb-msg-schemas')
var mlib = require('ssb-msgs')
var app = require('../app')
var ui = require('../ui')
var modals = require('../ui/modals')
var com = require('./index')
var markdown = require('../markdown')
var mentionslib = require('../mentions')
var social = require('../social-graph')
module.exports = function (rootMsg, branchMsg, opts) {
var isSecret = (rootMsg && rootMsg.plaintext === false)
var namesList = {} // a name->id map for the previews
for (var id in app.users.names)
if (id == app.user.id || social.follows(app.user.id, id))
namesList[app.users.names[id]] = id
var placeholder = (opts && opts.placeholder) ?
opts.placeholder :
(!rootMsg ? 'Share a message with the world...' : 'Reply...')
// markup
var previewEl = h('.post-form-preview')
var filesInput = h('input.hidden', { type: 'file', multiple: true, onchange: filesAdded })
var textarea = h('textarea.short', {
name: 'text',
placeholder: placeholder,
value: (opts && opts.initval) ? opts.initval : '',
rows: ((opts && opts.rows) ? opts.rows : 1),
onkeyup: onPostTextChange
})
var postBtn = h('button.postbtn.btn', 'Publish')
suggestBox(textarea, app.suggestOptions)
var form = h('form.post-form' + ((!!rootMsg) ? '.reply-form' : ''), { onsubmit: post },
(!opts || !opts.noheader) ? h('small.text-muted', 'Public post. Markdown, @-mentions, and emojis are supported. ', h('a', { href: '#', onclick: cancel }, 'Cancel')) : '',
h('.post-form-textarea', textarea),
previewEl,
h('.post-form-attachments.hidden',
postBtn,
(!isSecret) ? h('a', { href: '#', onclick: addFile }, 'Click here to add an attachment') : '',
filesInput
)
)
function disable () {
form.querySelector('.post-form-attachments').classList.add('hidden')
textarea.setAttribute('rows', 1)
textarea.classList.add('short')
}
function enable () {
form.querySelector('.post-form-attachments').classList.remove('hidden')
textarea.setAttribute('rows', 4)
textarea.classList.remove('short')
}
// handlers
function onPostTextChange () {
previewEl.innerHTML = (!!textarea.value) ? markdown.block(textarea.value, namesList) : ''
if (textarea.value.trim())
enable()
else
disable()
}
function post (e) {
e.preventDefault()
var text = textarea.value
if (!text.trim())
return
disable()
ui.pleaseWait(true)
// abort if the rootMsg wasnt decryptable
if (rootMsg && typeof rootMsg.value.content == 'string') {
ui.pleaseWait(false)
ui.notice('danger', 'Unable to decrypt rootMsg message')
enable()
return
}
// prep text
mentionslib.extract(text, function (err, mentions) {
if (err) {
ui.setStatus(null)
ui.pleaseWait(false)
enable()
if (err.conflict)
modals.error('Error While Publishing', 'You follow multiple people with the name "'+err.name+'." Go to the homepage to resolve this before publishing.')
else
modals.error('Error While Publishing', err, 'This error occured while trying to extract the mentions from a new post.')
return
}
// get encryption recipients from rootMsg
var recps
try {
if (Array.isArray(rootMsg.value.content.recps)) {
recps = mlib.links(rootMsg.value.content.recps)
.map(function (recp) { return recp.link })
.filter(Boolean)
}
} catch (e) {}
// post
var post = schemas.post(text, rootMsg && rootMsg.key, branchMsg && branchMsg.key, mentions, recps)
if (recps)
app.ssb.private.publish(post, recps, published)
else
app.ssb.publish(post, published)
function published (err, msg) {
ui.setStatus(null)
enable()
ui.pleaseWait(false)
if (err) modals.error('Error While Publishing', err, 'This error occurred while trying to publish a new post.')
else {
textarea.value = ''
onPostTextChange()
app.ssb.patchwork.subscribe(msg.key)
app.ssb.patchwork.markRead(msg.key)
opts && opts.onpost && opts.onpost(msg)
}
}
})
}
function cancel (e) {
e.preventDefault()
if (textarea.value && !confirm('Are you sure you want to cancel? Your message will be lost.'))
return
form.parentNode.removeChild(form)
opts && opts.oncancel && opts.oncancel()
}
function addFile (e) {
e.preventDefault()
filesInput.click() // trigger file-selector
}
function filesAdded (e) {
// hash the files
var n = filesInput.files.length
ui.setStatus('Hashing ('+n+' files left)...')
for (var i=0; i < n; i++) {
if (!add(filesInput.files[i])) {
ui.setStatus(false)
return
}
}
function add (f) {
if (f.size > 5 * (1024*1024)) {
var inMB = Math.round(f.size / (1024*1024) * 100) / 100
modals.error('Error Attaching File', f.name + ' is larger than the 5 megabyte limit (' + inMB + ' MB)')
return false
}
app.ssb.patchwork.addFileToBlobs(f.path, function (err, res) {
if (err) {
modals.error('Error Attaching File', error, 'This error occurred while trying to add a file to the blobstore for a new post.')
} else {
if (!(/(^|\s)$/.test(textarea.value)))
textarea.value += ' '
textarea.value += '['+(f.name||'untitled')+']('+res.hash+')'
onPostTextChange()
if (--n === 0)
ui.setStatus(false)
}
})
return true
}
}
return form
}

92
ui/lib/com/pretty-raw.js Normal file
View File

@@ -0,0 +1,92 @@
var h = require('hyperscript')
var ssbref = require('ssb-ref')
var com = require('./index')
var u = require('../util')
function file (link, rel) {
var name = link.name || rel
var details = (('size' in link) ? u.bytesHuman(link.size) : '') + ' ' + (link.type||'')
return h('a', { href: '/ext/'+link.ext, target: '_blank', title: name +' '+details }, name, ' ', h('small', details))
}
function message (link, rel) {
if (typeof rel == 'string')
return h('a', { href: '#/msg/'+link.msg, innerHTML: u.escapePlain(rel)+' &raquo;' })
}
var prettyRaw =
module.exports = function (obj, path) {
if (typeof obj == 'string')
return h('span.pretty-raw', h('em', 'Encrypted message'))
function col (k, v) {
k = (k) ? path+k : ''
return h('span.pretty-raw', h('small', k), v)
}
var els = []
path = (path) ? path + '.' : ''
for (var k in obj) {
if (obj[k] && typeof obj[k] == 'object') {
// :TODO: render links
// if (obj[k].ext)
// els.push(col('', file(obj[k])))
// if (obj[k].msg)
// els.push(col('', message(obj[k])))
// if (obj[k].feed)
// els.push(col(k, com.user(obj[k].feed)))
els = els.concat(prettyRaw(obj[k], path+k))
}
else
els.push(col(k, ''+obj[k]))
}
return els
}
var prettyRawTable =
module.exports.table = function (obj, path) {
if (typeof obj == 'string') {
var el = h('tr.pretty-raw', h('td'), h('td.text-muted', 'Encrypted message'))
// try to decrypt
app.ssb.private.unbox(obj, function (err, decrypted) {
if (decrypted) {
var rows = prettyRawTable(decrypted)
if (el.parentNode) {
rows.forEach(function (row) {
el.parentNode.appendChild(row)
})
}
}
})
return el
}
function row (k, v) {
if (typeof v === 'boolean')
v = com.icon(v ? 'ok' : 'remove')
return h('tr.pretty-raw', h('td', path+k), h('td', v))
}
var els = []
path = (path) ? path + '.' : ''
for (var k in obj) {
if (obj[k] && typeof obj[k] == 'object') {
els = els.concat(prettyRawTable(obj[k], path+k))
} else if (ssbref.isLink(obj[k])) {
var ref = obj[k]
if (ssbref.isMsgId(ref))
els.push(row(k, com.a('#/msg/'+ref, ref)))
else if (ssbref.isBlobId(ref))
els.push(row(k, com.a('#/webview/'+ref, obj.name || ref)))
else
els.push(row(k, com.user(ref)))
} else
els.push(row(k, obj[k]))
}
return els
}

19
ui/lib/com/rename-form.js Normal file
View File

@@ -0,0 +1,19 @@
var h = require('hyperscript')
var com = require('./index')
module.exports = function (id, opts) {
// markup
var oldname = com.userName(id)
var nameinput = h('input.form-control', { value: oldname })
var form = h('.rename-form',
h('h3', 'Rename "', oldname, '"'),
h('p.text-muted', h('small', 'You can rename anybody! Other people can see the name you choose, but it will only affect you.')),
h('form.form-inline', { onsubmit: function (e) { e.preventDefault(); opts.onsubmit(nameinput.value) } },
h('p', nameinput, h('button.btn.btn-3d', 'Save'))
)
)
return form
}

View File

@@ -0,0 +1,41 @@
'use strict'
var h = require('hyperscript')
var pull = require('pull-stream')
var app = require('../app')
var modals = require('../ui/modals')
var ui = require('../ui')
module.exports = function (id) {
var success = false, errored = false
var btn = h('a.btn.btn-primary', { onclick: onclick }, 'Download User')
function onclick () {
btn.classList.add('disabled')
btn.innerText = 'Searching...'
pull(app.ssb.patchwork.useLookupCode(id), pull.drain(onLookupEvent, onLookupDone))
}
function onLookupEvent (e) {
if (e.type == 'error' && e.message == 'Invalid lookup code')
errored = true, modals.error('Error Downloading User', 'This is not a valid user ID: '+id, 'This error occurred while trying to download a user that wasn\'t locally available.')
else if (e.type == 'connecting')
btn.innerText = 'Asking '+e.addr.host
else if (e.type == 'finished') {
btn.innerText = 'Success!'
success = true
}
}
function onLookupDone () {
btn.classList.remove('disabled')
if (success)
ui.refreshPage()
else {
if (!errored)
modals.error('User Not Found', 'None of the available mesh nodes had this user\'s data. Make sure you\'re online, and that you have the right ID, then try again.', 'Attempted ID: '+id)
btn.innerText = 'Try Download Again'
}
}
return btn
}

View File

@@ -0,0 +1,259 @@
'use strict'
var h = require('hyperscript')
var o = require('observable')
var schemas = require('ssb-msg-schemas')
var mlib = require('ssb-msgs')
var pull = require('pull-stream')
var pushable = require('pull-pushable')
var createHash = require('multiblob/util').createHash
var suggestBox = require('suggest-box')
var toBuffer = require('blob-to-buffer')
var app = require('../app')
var ui = require('../ui')
var modals = require('../ui/modals')
var com = require('./index')
var mentionslib = require('../mentions')
var videoOpts = {
optional: [
{ minHeight: 150 },
{ maxHeight: 150 },
{ minWidth: 300 },
{ maxWidth: 300 }
]
}
module.exports = function (rootMsg, branchMsg, opts) {
opts = opts || {}
var blob
var recordInterval
var encoder = new Whammy.Video(10)
var countdown = o(0)
// markup
var canvas = h('canvas')
var context = canvas.getContext('2d')
var invideo = h('video')
var outvideo = h('video.hide', { autoplay: true, loop: true })
var textarea = h('textarea.form-control', {
name: 'text',
placeholder: 'Add a message (optional)',
rows: 6
})
var publishBtn = h('button.btn.btn-primary.pull-right.hidden', { onclick: onpublish }, 'Publish')
var form = h('form.webcam-giffer-form',
h('.webcam-giffer-form-videos', { onmousedown: onmousedown },
o.transform(countdown, function (c) {
if (!c)
return ''
return h('.countdown', c)
}),
invideo,
outvideo,
h('br'),
h('a.btn.btn-3d', { onclick: onrecord(1) }, com.icon('record'), ' Record 1s'), ' ',
h('a.btn.btn-3d', { onclick: onrecord(2) }, '2s'), ' ',
h('a.btn.btn-3d', { onclick: onrecord(3), style: 'margin-right: 10px' }, '3s'),
h('a.text-muted', { href: '#', onclick: onreset }, com.icon('repeat'), ' Reset')
),
h('.webcam-giffer-form-ctrls', textarea, publishBtn)
)
suggestBox(textarea, app.suggestOptions)
function disable () {
publishBtn.classList.add('hidden')
}
function enable () {
publishBtn.classList.remove('hidden')
}
// handlers
function onmousedown (e) {
if (e.target.tagName == 'VIDEO') {
e.preventDefault()
startRecording()
document.addEventListener('mouseup', onmouseup)
}
}
function onmouseup (e) {
e.preventDefault()
stopRecording()
document.removeEventListener('mouseup', onmouseup)
}
function onrecord (seconds) {
return function (e) {
e.preventDefault()
startRecordingAfter(2, seconds)
}
}
function onreset (e) {
e && e.preventDefault()
encoder.frames = []
invideo.classList.remove('hide')
outvideo.classList.add('hide')
disable()
}
function onpublish (e) {
e.preventDefault()
var text = textarea.value || ''
if (!blob)
return
disable()
ui.pleaseWait(true)
// abort if the rootMsg wasnt decryptable
if (rootMsg && typeof rootMsg.value.content == 'string') {
ui.pleaseWait(false)
ui.notice('danger', 'Unable to decrypt rootMsg message')
enable()
return
}
function onerr (err) {
ui.setStatus(null)
ui.pleaseWait(false)
enable()
modals.error('Error While Publishing', err, 'This error occurred while trying to upload a webcam video to the blobstore.')
}
// upload blob to sbot
var hasher = createHash('sha256')
var ps = pushable()
pull(
ps,
hasher,
app.ssb.blobs.add(function (err) {
if (err) return onerr(err)
afterUpload()
})
)
toBuffer(blob, function (err, buffer) {
if (err) return onerr(err)
ps.push(buffer)
ps.end()
})
function afterUpload () {
// prepend the image-embed ot the text
text = '![webcam.webm](&'+hasher.digest+')\n\n' + text
console.log('posting', text)
// prep text
mentionslib.extract(text, function (err, mentions) {
if (err) {
ui.setStatus(null)
ui.pleaseWait(false)
enable()
if (err.conflict)
modals.error('Error While Publishing', 'You follow multiple people with the name "'+err.name+'." Go to the homepage to resolve this before publishing.')
else
modals.error('Error While Publishing', err, 'This error occurred while trying to extract the mentions from the text of a webcam post.')
return
}
// get encryption recipients from rootMsg
var recps
try {
if (Array.isArray(rootMsg.value.content.recps)) {
recps = mlib.links(rootMsg.value.content.recps)
.map(function (recp) { return recp.link })
.filter(Boolean)
}
} catch (e) {}
// post
var post = schemas.post(text, rootMsg && rootMsg.key, branchMsg && branchMsg.key, mentions, recps)
if (recps)
app.ssb.private.publish(post, recps, published)
else
app.ssb.publish(post, published)
function published (err, msg) {
ui.setStatus(null)
enable()
ui.pleaseWait(false)
if (err) modals.error('Error While Publishing', err, 'This error occurred while trying to post a webcam video.')
else {
textarea.value = ''
app.ssb.patchwork.subscribe(msg.key)
app.ssb.patchwork.markRead(msg.key)
opts && opts.onpost && opts.onpost(msg)
onreset()
}
}
})
}
}
// init webcam
navigator.webkitGetUserMedia({ video: videoOpts, audio: false }, function (stream) {
invideo.src = window.URL.createObjectURL(stream)
invideo.onloadedmetadata = function () { invideo.play() }
ui.onTeardown(function () {
stream.stop()
})
}, function (err) {
modals.error('Failed to Access Webcam', err)
})
// recording functions
function startRecordingAfter(c, seconds) {
// show input stream
invideo.classList.remove('hide')
outvideo.classList.add('hide')
// run countdown
countdown(c)
var i = setInterval(function () {
countdown(countdown() - 1)
if (countdown() === 0) {
clearInterval(i)
startRecording(seconds)
}
}, 1000)
}
function startRecording (seconds) {
// show input stream
invideo.classList.remove('hide')
outvideo.classList.add('hide')
// add 'recording' border
invideo.classList.add('recording')
// start capture
recordInterval = setInterval(captureFrame, 1000/10)
// captureFrame()
if (seconds)
setTimeout(stopRecording, seconds*1000)
}
function captureFrame () {
context.drawImage(invideo, 0, 0, 300, 150)
encoder.add(canvas)
}
function stopRecording () {
// stop capture
clearInterval(recordInterval)
// show output stream
invideo.classList.add('hide')
outvideo.classList.remove('hide')
// remove 'recording' border
invideo.classList.remove('recording')
// produce output
blob = encoder.compile()
console.log('Webm video encoded:', blob.size, 'bytes')
outvideo.src = URL.createObjectURL(blob, 'video/webm')
enable()
}
return form
}

62
ui/lib/com/webview.js Normal file
View File

@@ -0,0 +1,62 @@
var h = require('hyperscript')
var muxrpc = require('muxrpc')
var pull = require('pull-stream')
var pushable = require('pull-pushable')
var ssbref = require('ssb-ref')
var app = require('../app')
var manifest = {
'get' : 'async',
'getPublicKey' : 'async',
'whoami' : 'async',
'relatedMessages' : 'async',
'createFeedStream' : 'source',
'createUserStream' : 'source',
'createLogStream' : 'source',
'messagesByType' : 'source',
'links' : 'source'
}
module.exports = function (opts) {
if (!opts) throw "`opts` required in com.webview"
var webview = h('webview', { src: opts.url, preload: './webview-preload.js' })
// setup rpc
var ssb = muxrpc(null, manifest, serialize)(app.ssb)
function serialize (stream) { return stream }
var rpcStream = ssb.createStream()
var ipcPush = pushable()
webview.addEventListener('ipc-message', function (e) {
if (e.channel == 'navigate') {
if (e.args[0] && ssbref.isLink(e.args[0]))
window.location.hash = '#/webview/' + e.args[0]
else
console.warn('Security Error: page attempted to navigate to disallowed location,', e.args[0])
}
if (e.channel == 'muxrpc-ssb') {
var msg = e.args[0]
try { msg = JSON.parse(msg) }
catch (e) { return }
ipcPush.push(msg)
}
})
pull(ipcPush, rpcStream, pull.drain(
function (msg) { webview.send('muxrpc-ssb', JSON.stringify(msg)) },
function (err) { if (err) { console.error(err) } }
))
// sandboxing
// dont let the webview navigate away
webview.addEventListener('did-stop-loading', function (e) {
if (webview.getUrl().indexOf('http://localhost') !== 0 && webview.getUrl().indexOf('data:') !== 0) {
console.warn('Security Error. Webview circumvented navigation sandbox.')
webview.src = 'data:text/html,<strong>Security Error</strong> This page attempted to navigate out of its sandbox through explicit circumvention. Do not trust it!'
}
})
return webview
}

137
ui/lib/markdown.js Normal file
View File

@@ -0,0 +1,137 @@
'use strict'
var emojiNamedCharacters = require('emoji-named-characters')
var marked = require('ssb-marked')
var ssbref = require('ssb-ref')
var mlib = require('ssb-msgs')
var renderer = new marked.Renderer();
// override to only allow external links or hashes, and correctly link to ssb objects
renderer.urltransform = function (url) {
var c = url.charAt(0)
var hasSigil = (c == '@' || c == '&' || c == '%')
if (this.options.sanitize && !hasSigil) {
try {
var prot = decodeURIComponent(unescape(url))
.replace(/[^\w:]/g, '')
.toLowerCase();
} catch (e) {
return false;
}
if (prot.indexOf('javascript:') === 0) {
return false;
}
}
var islink = ssbref.isLink(url)
if (hasSigil && !islink && this.options.mentionNames) {
// do a name lookup
url = this.options.mentionNames[url.slice(1)]
if (!url)
return false
islink = true
}
if (islink) {
if (ssbref.isFeedId(url))
return '#/profile/'+url
else if (ssbref.isMsgId(url))
return '#/msg/'+url
else if (ssbref.isBlobId(url))
return '#/webview/'+url
}
else if (url.indexOf('http') !== 0) {
return false;
}
return url
}
// override to make http/s links external
renderer.link = function(href, title, text) {
href = this.urltransform(href)
var out
if (href !== false)
out = '<a href="' + href + '"';
else
out = '<a class="bad"'
if (title) {
out += ' title="' + title + '"';
}
// make a popup if http/s
if (href && href.indexOf('http') === 0)
out += ' target="_blank"'
out += '>' + text + '</a>';
return out;
};
// override to support <video> tags (HACK)
renderer.image = function (href, title, text) {
href = href.replace(/^&amp;/, '&')
if (ssbref.isLink(href)) {
if ((''+text).indexOf('.webm') >= 0) {
var out = '<video autoplay=1 loop=1 muted=1 src="http://localhost:7777/' + href + '?fallback=video" alt="' + text + '"'
if (title) {
out += ' title="' + title + '"'
}
out += '></video>'
} else {
var out = '<a href="#/webview/' + href + '"><img src="http://localhost:7777/' + href + '?fallback=img" alt="' + text + '"'
if (title) {
out += ' title="' + title + '"'
}
out += '></a>'
}
return out
}
return text
}
marked.setOptions({
gfm: true,
mentions: true,
tables: true,
breaks: true,
pedantic: false,
sanitize: true,
smartLists: true,
smartypants: false,
emoji: renderEmoji,
renderer: renderer
});
exports.block = function(text, mentionNames) {
if (mentionNames && mentionNames.key && mentionNames.value) {
// is a message, get the mentions links
mentionNames = mlib.links(mentionNames.value.content.mentions, 'feed')
}
if (Array.isArray(mentionNames)) {
// is an array of links, turn into an object map
var n = {}
mentionNames.forEach(function (link) {
n[link.name] = link.link
})
mentionNames = n
}
return marked(''+(text||''), { mentionNames: mentionNames })
}
var emojiRegex = /(\s|>|^)?:([A-z0-9_]+):(\s|<|$)/g;
exports.emojis = function (str) {
return str.replace(emojiRegex, function(full, $1, $2, $3) {
return ($1||'') + renderEmoji($2) + ($3||'')
})
}
function renderEmoji (emoji) {
return emoji in emojiNamedCharacters ?
'<img src="./img/emoji/' + encodeURI(emoji) + '.png"'
+ ' alt=":' + escape(emoji) + ':"'
+ ' title=":' + escape(emoji) + ':"'
+ ' class="emoji" align="absmiddle" height="20" width="20">'
: ':' + emoji + ':'
}

45
ui/lib/mentions.js Normal file
View File

@@ -0,0 +1,45 @@
var mlib = require('ssb-msgs')
var ssbref = require('ssb-ref')
var app = require('./app')
var mentionRegex =
exports.regex = /([^A-z0-9_\-\/:]|^)([@%&](amp;)?[A-z0-9\._\-+=\/]*[A-z0-9_\-+=\/])/g
function shorten (hash) {
return hash.slice(0, 8) + '..' + hash.slice(-11)
}
exports.extract = function (text, cb) {
app.ssb.patchwork.getIdsByName(function (err, idsByName) {
if (err)
return cb(err)
// collect any mentions
var match
var mentions = [], mentionedIds = {}
while ((match = mentionRegex.exec(text))) {
var ref = match[2]
var name = ref.slice(1) // lose the @
var id = idsByName[name]
// name conflict? abort
if (Array.isArray(id))
return cb({ conflict: true, name: name })
if (ssbref.isFeedId(id)) {
// mapped to a valid id?
if (!(id in mentionedIds))
mentionedIds[id] = mentions.push({ link: id, name: name }) - 1
else
mentions[mentionedIds[id]].name = name // make sure the name is set
} else if (ssbref.isLink(ref)) {
// is a valid id?
if (!(ref in mentionedIds)) {
mentionedIds[ref] = mentions.push({ link: ref }) - 1
}
}
}
cb(null, mentions)
})
}

45
ui/lib/muxrpc-ipc.js Normal file
View File

@@ -0,0 +1,45 @@
var ipc = require('ipc')
var muxrpc = require('muxrpc')
var pull = require('pull-stream')
var pullipc = require('pull-ipc')
var ui = require('./ui')
var clientApiManifest = {
navigate: 'async',
contextualToggleDevTools: 'async',
triggerFind: 'async'
}
var clientApi = {
navigate: function (path, cb) {
window.location.hash = '#/webview/'+path
cb()
},
contextualToggleDevTools: function (cb) {
ui.toggleDevTools()
cb()
},
triggerFind: function (cb) {
ui.triggerFind()
cb()
}
}
module.exports = function () {
// fetch manifest
var manifest = ipc.sendSync('fetch-manifest')
console.log('got manifest', manifest)
// create rpc object
var ssb = muxrpc(manifest, clientApiManifest, serialize)(clientApi)
function serialize (stream) { return stream }
// setup rpc stream over ipc
var rpcStream = ssb.createStream()
var ipcStream = pullipc('ssb-muxrpc', ipc, function (err) {
console.log('ipc-stream ended', err)
})
pull(ipcStream, rpcStream, ipcStream)
return ssb
}

25
ui/lib/pages/drive.js Normal file
View File

@@ -0,0 +1,25 @@
'use strict'
var h = require('hyperscript')
var app = require('../app')
var ui = require('../ui')
var com = require('../com')
module.exports = function (opts) {
// markup
ui.setPage('drive', h('.layout-onecol',
h('.layout-main',
(opts && opts.download) ?
h('.well.white', { style: 'margin-top: 5px' },
h('p', h('strong', opts.download)),
h('a.btn.btn-3d', 'Save to your files as...'), ' ', h('a.btn.btn-3d', 'Download...')) :
'',
h('.pull-right',
h('a.btn.btn-3d', 'Upload File')
),
h('h3', 'Your Drive ', h('small', 'Non-functional Mockup Interface')),
com.files(app.user.id)
)
))
}

29
ui/lib/pages/feed.js Normal file
View File

@@ -0,0 +1,29 @@
'use strict'
var h = require('hyperscript')
var mlib = require('ssb-msgs')
var pull = require('pull-stream')
var multicb = require('multicb')
var app = require('../app')
var ui = require('../ui')
var com = require('../com')
module.exports = function (pid) {
// markup
var feed = app.ssb.createFeedStream
if (pid) {
feed = function (opts) {
opts = opts || {}
opts.id = pid
return app.ssb.createUserStream(opts)
}
}
ui.setPage('feed', h('.layout-onecol',
h('.layout-main',
h('h3.text-center', 'Behind the Scenes ', h('small', 'Raw Data Feed')),
com.messageFeed({ feed: feed, render: com.messageSummary.raw, infinite: true })
)
))
}

56
ui/lib/pages/friends.js Normal file
View File

@@ -0,0 +1,56 @@
'use strict'
var h = require('hyperscript')
var ref = require('ssb-ref')
var app = require('../app')
var ui = require('../ui')
var com = require('../com')
var social = require('../social-graph')
module.exports = function () {
var queryStr = app.page.qs.q || ''
var queryRegex
// filters
function stdfilter (prof) {
if (prof.id == app.user.id) // is self
return true
if (app.users.names[prof.id] || social.follows(app.user.id, prof.id)) // has a name, or is a friend
return true
}
function isRecommended (prof) {
var nfollowers = social.followedFollowers(app.user.id, prof.id).length
var nflaggers = social.followedFlaggers(app.user.id, prof.id, true).length
if (prof.id != app.user.id && !social.follows(app.user.id, prof.id) && nfollowers && !nflaggers)
return true
}
function recommendFilterFn (prof) {
if (!stdfilter(prof))
return false
return isRecommended(prof)
}
function othersFilterFn (prof) {
if (!stdfilter(prof))
return false
return (prof.id != app.user.id && !social.follows(app.user.id, prof.id) && !isRecommended(prof))
}
// markup
var newFollowersToShow = Math.max(app.indexCounts.followsUnread, 30)
ui.setPage('followers', h('.layout-onecol',
h('.layout-main',
h('h3', 'Following'),
h('div', { style: 'width: 850px; margin: 0 auto' }, com.friendsHexagrid({ size: 80, nrow: 10 })),
h('h3', 'Activity'),
com.messageFeed({ render: com.messageSummary, feed: app.ssb.patchwork.createFollowStream, markread: true, limit: newFollowersToShow }),
h('h3', { style: 'margin-top: 40px' }, 'Recommendations'),
com.contactFeed({ filter: recommendFilterFn }),
h('h3', { style: 'margin-top: 40px' }, 'Others'),
com.contactFeed({ filter: othersFilterFn })
)
))
}

71
ui/lib/pages/home.js Normal file
View File

@@ -0,0 +1,71 @@
'use strict'
var h = require('hyperscript')
var o = require('observable')
var mlib = require('ssb-msgs')
var pull = require('pull-stream')
var app = require('../app')
var ui = require('../ui')
var com = require('../com')
var social = require('../social-graph')
module.exports = function () {
var hlf // home live feed
// filters
var p = app.user.profile
function homeFilter (m) {
var a = m.value.author
if (app.users.profiles[a] && app.users.profiles[a].flagged) // flagged by user
return false
if (app.homeMode.view == 'all')
return true
if (app.homeMode.view == 'friends')
return a == app.user.id || social.follows(app.user.id, a)
return social.follows(app.homeMode.view, a) // `view` is the id of a pub
}
// live-mode
if (app.homeMode.live)
hlf = app.ssb.patchwork.createHomeStream({ gt: [Date.now(), null], live: true })
// markup
function render (msg) {
return com.message(msg, { markread: true })
}
function notification (vo, href, title, icon) {
return o.transform(vo, function (v) {
var cls = (v>0) ? '.highlight' : ''
return h('a'+cls, { href: href, title: title }, com.icon(icon), ' ', v)
})
}
ui.setPage('home', h('.layout-twocol',
h('.layout-main',
com.notifications(),
com.composer(null, null, { placeholder: 'Share a message with the world...' }),
com.messageFeed({ feed: app.ssb.patchwork.createHomeStream, render: render, onempty: onempty, filter: homeFilter, limit: 100, infinite: true, live: hlf })
),
h('.layout-rightnav',
h('.shortcuts',
notification(app.observ.indexCounts.inboxUnread, '#/inbox', 'Your inbox', 'inbox'),
notification(app.observ.indexCounts.votesUnread, '#/stars', 'Stars on your posts, and stars by you', 'star'),
notification(app.observ.indexCounts.followsUnread, '#/friends', 'Friends, followers, and other users', 'user')
),
com.notifications.side(),
com.friendsHexagrid({ size: 80 }),
com.help.side()
)
), { onPageTeardown: function () {
// abort streams
hlf && hlf(true, function(){})
}})
function onempty (el) {
if (app.homeMode.view == 'all')
el.appendChild(com.help.welcome())
}
}

32
ui/lib/pages/inbox.js Normal file
View File

@@ -0,0 +1,32 @@
'use strict'
var h = require('hyperscript')
var mlib = require('ssb-msgs')
var pull = require('pull-stream')
var multicb = require('multicb')
var app = require('../app')
var ui = require('../ui')
var com = require('../com')
var social = require('../social-graph')
var subwindows = require('../ui/subwindows')
module.exports = function () {
// markup
ui.setPage('inbox', h('.layout-onecol',
h('.layout-main',
h('a.btn.btn-3d.pull-right', { onclick: function (e) { e.preventDefault(); subwindows.pm() } }, com.icon('envelope'), ' Secret Message'),
h('h3', 'Inbox'),
com.messageFeed({ render: com.messageOneline, feed: app.ssb.patchwork.createInboxStream, filter: filter, onempty: onempty, infinite: true })
)
))
function onempty (feedEl) {
feedEl.appendChild(h('p.text-center', { style: 'margin: 25px 0; padding: 10px; color: gray' }, 'Your inbox is empty!'))
}
function filter (msg) {
var a = msg.value.author
return a == app.user.id || social.follows(app.user.id, a)
}
}

31
ui/lib/pages/index.js Normal file
View File

@@ -0,0 +1,31 @@
'use strict'
var h = require('hyperscript')
var ui = require('../ui')
var com = require('../com')
function notfound () {
ui.setPage('notfound', [
h('img', { src: 'img/lick-the-door.gif', style: 'display: block; margin: 10px auto; border-radius: 3px;' }),
h('h2.text-center', 'Page Not Found'),
h('div.text-center', { style: 'margin-top: 20px' },
'Sorry, that page wasn\'t found. Maybe you typed the name wrong? Or maybe somebody gave you a bad link.'
)
])
}
module.exports = {
drive: require('./drive'),
feed: require('./feed'),
friends: require('./friends'),
home: require('./home'),
inbox: require('./inbox'),
msg: require('./message'),
notfound: notfound,
profile: require('./profile'),
publisher: require('./publisher'),
search: require('./search'),
setup: require('./setup'),
stars: require('./stars'),
sync: require('./sync'),
webview: require('./webview')
}

54
ui/lib/pages/message.js Normal file
View File

@@ -0,0 +1,54 @@
'use strict'
var h = require('hyperscript')
var mlib = require('ssb-msgs')
var app = require('../app')
var ui = require('../ui')
var anim = require('../ui/anim')
var com = require('../com')
var util = require('../util')
module.exports = function () {
app.ssb.get(app.page.param, function (err, msg) {
var content
var isEncrypted = false
var secretMessageLabel
if (msg) {
msg = { key: app.page.param, value: msg }
content = com.message(msg, { markread: true, fullview: true, live: true })
// if encrypted, add the animated 'secret message' label
if (typeof msg.value.content == 'string') {
isEncrypted = true
secretMessageLabel = h('span')
anim.textDecoding(secretMessageLabel, 'Secret Thread')
}
} else {
content = 'Message not found.'
}
ui.setPage('message', h('.layout-twocol',
h('.layout-main', content),
h('.layout-rightnav',
(isEncrypted) ?
h('.text-center',
h('p', h('code', { style: 'font-size: 18px' }, secretMessageLabel, ' ', com.icon('lock'))),
h('p', 'All messages in this thread are encrypted.')
) :
''
)
))
if (app.page.qs.jumpto) {
setTimeout(function (){
var el = document.querySelector('.message[data-key="'+app.page.qs.jumpto+'"]')
if (el) {
el.scrollIntoView()
if ((window.innerHeight + window.scrollY) < document.body.offsetHeight)
window.scrollBy(0, -100) // show a little above, if not at the bottom of the page
}
}, 50)
}
})
}

200
ui/lib/pages/profile.js Normal file
View File

@@ -0,0 +1,200 @@
'use strict'
var h = require('hyperscript')
var refs = require('ssb-ref')
var mlib = require('ssb-msgs')
var multicb = require('multicb')
var schemas = require('ssb-msg-schemas')
var pull = require('pull-stream')
var app = require('../app')
var ui = require('../ui')
var modals = require('../ui/modals')
var subwindows = require('../ui/subwindows')
var com = require('../com')
var u = require('../util')
var markdown = require('../markdown')
var mentions = require('../mentions')
var social = require('../social-graph')
module.exports = function () {
var pid = app.page.param
var profile = app.users.profiles[pid]
var name = com.userName(pid)
// user not found
if (!profile) {
if (refs.isFeedId(pid)) {
profile = {
assignedBy: {},
id: pid,
isEmpty: true
}
} else {
ui.setPage('profile', h('.layout-twocol',
h('.layout-main',
h('.well', { style: 'margin-top: 5px; background: #fff' },
h('h3', { style: 'margin-top: 0' }, 'Invalid user ID'),
h('p',
h('em', pid),
' is not a valid user ID. ',
h('img.emoji', { src: './img/emoji/disappointed.png', title: 'disappointed', width: 20, height: 20, style: 'vertical-align: top' })
)
)
)
))
return
}
}
var isSelf = (pid == app.user.id)
var isFollowing = social.follows(app.user.id, pid)
var followsYou = social.follows(pid, app.user.id)
var hasFlagged = social.flags(app.user.id, pid)
var hasBlocked = social.blocks(app.user.id, pid)
var followers1 = social.followedFollowers(app.user.id, pid, true)
var followers2 = social.unfollowedFollowers(app.user.id, pid)
var followeds = social.followeds(pid)
var flaggers = social.followedFlaggers(app.user.id, pid, true)
// name conflict controls
var nameConflictDlg
var nameConflicts = []
for (var id in app.users.names) {
if (id != pid && app.users.names[id] == app.users.names[pid])
nameConflicts.push(id)
}
if (nameConflicts.length) {
nameConflictDlg = h('.well.white', { style: 'margin: -10px 15px 15px' },
h('p', { style: 'margin-bottom: 10px' }, h('strong', 'Other users named "'+app.users.names[pid]+'":')),
h('ul.list-inline', nameConflicts.map(function (id) { return h('li', com.user(id)) })),
h('p', h('small', 'ProTip: You can rename users to avoid getting confused!'))
)
}
// flag controls
var flagMsgs
if (flaggers.length) {
flagMsgs = h('.profile-flags.message-feed')
flaggers.forEach(function (id) {
var flag = social.flags(id, pid)
if (flag.reason && flag.key) {
app.ssb.get(flag.key, function (err, flagMsg) {
if (err) console.error(err)
if (flagMsg) flagMsgs.appendChild(com.message({ key: flag.key, value: flagMsg }))
})
}
})
}
var hasMsgs = false
var content = com.messageFeed({ feed: feedFn, cursor: feedCursor, filter: feedFilter, infinite: true, onempty: onNoMsgs })
function feedFn (opts) {
opts = opts || {}
opts.id = pid
return app.ssb.createUserStream(opts)
}
function feedCursor (msg) {
if (msg)
return msg.value.sequence
}
function feedFilter (msg) {
hasMsgs = true
// post by this user
var c = msg.value.content
if (msg.value.author == pid && c.type == 'post')
return true
}
function onNoMsgs (feedEl) {
if (hasMsgs) {
feedEl.appendChild(h('p.text-center.text-muted', h('br'), 'No posts...yet!'))
} else {
feedEl.appendChild(h('div', { style: 'margin: 12px 1px; background: #fff; padding: 15px 15px 10px' },
h('h3', { style: 'margin-top: 0' }, 'Umm... who is this?'),
h('p', 'This user\'s data hasn\'t been fetched yet, so we don\'t know anything about them!'),
h('p', com.userDownloader(pid))
))
}
}
// render page
ui.setPage('profile', h('.layout-twocol',
h('.layout-main',
h('.profile-header',
h('h1', h('strong', name)),
h('a.btn.btn-3d', { href: '#', onclick: privateMessage, title: 'Send an encrypted message to '+name }, com.icon('envelope'), ' Secret Message')
),
flagMsgs ? h('.message-feed-container', flagMsgs) : '',
content),
h('.layout-rightnav',
h('.profile-controls',
com.contactPlaque(profile, followers1.length + followers2.length, flaggers.length),
(hasBlocked) ? h('.block', 'BLOCKED') : '',
(!isSelf) ?
[
(followsYou) ? h('.follows-you', 'Follows You') : '',
h('.btns',
h('.btns-group',
(hasBlocked) ? '' : h('a.btn.btn-3d', { href: '#', onclick: toggleFollow }, com.icon('user'), ((isFollowing) ? ' Unfollow' : ' Follow')),
' ',
h('a.btn.btn-3d', { href: '#', onclick: renameModal }, com.icon('pencil'), ' Rename'),
' ',
h('a.btn.btn-3d', { href: '#', onclick: flagModal }, com.icon('flag'), ((!!hasFlagged) ? ' Unflag' : ' Flag'))))
] :
h('.btns.text-center', { style: 'padding-right: 10px' },
h('a.btn.btn-3d', { href: '#/setup', title: 'Update your name or image' }, com.icon('pencil'), ' Edit Your Profile')),
nameConflictDlg,
(!isSelf) ?
com.connectionGraph(app.user.id, pid, { w: 5.5, drawLabels: false, touchEnabled: false, mouseEnabled: false, mouseWheelEnabled: false }) :
'',
(flaggers.length) ? h('.relations', h('h4', 'flagged by'), com.userHexagrid(flaggers, { nrow: 4 })) : '',
(followers1.length) ? h('.relations', h('h4', 'followers you follow'), com.userHexagrid(followers1, { nrow: 4 })) : '',
(followers2.length) ? h('.relations', h('h4', 'followers you don\'t follow'), com.userHexagrid(followers2, { nrow: 4 })) : '',
(followeds.length) ? h('.relations', h('h4', name, ' is following'), com.userHexagrid(followeds, { nrow: 4 })) : ''
)
)
))
// handlers
function privateMessage (e) {
e.preventDefault()
subwindows.pm({ recipients: [pid] })
}
function renameModal (e) {
e.preventDefault()
modals.setName(pid)
}
function toggleFollow (e) {
e.preventDefault()
if (isSelf)
return
ui.pleaseWait(true, 500)
if (isFollowing)
app.ssb.publish(schemas.unfollow(pid), done)
else
app.ssb.publish(schemas.follow(pid), done)
function done (err) {
ui.pleaseWait(false)
if (err) modals.error('Error While Publishing', err, 'This error occured while trying to un/follow somebody on their profile page.')
else ui.refreshPage()
}
}
function flagModal (e) {
e.preventDefault()
if (isSelf)
return
if (!hasFlagged)
modals.flag(pid)
else {
var done = multicb()
app.ssb.publish(schemas.unblock(pid), done())
app.ssb.publish(schemas.unflag(pid), done())
done(function (err) {
if (err) modals.error('Error While Publishing', err, 'This error occured while trying to un/flag somebody on their profile page.')
else ui.refreshPage()
})
}
}
}

289
ui/lib/pages/publisher.js Normal file
View File

@@ -0,0 +1,289 @@
'use strict'
var h = require('hyperscript')
var o = require('observable')
var ref = require('ssb-ref')
var mime = require('mime-types')
var pwt = require('published-working-tree')
var multicb = require('multicb')
var app = require('../app')
var ui = require('../ui')
var u = require('../util')
var modals = require('../ui/modals')
var com = require('../com')
var social = require('../social-graph')
// symbols, used to avoid collisions with filenames
var ISROOT = Symbol('isroot')
var ISOPEN = Symbol('isopen')
module.exports = function () {
var done = multicb({ pluck: 1 })
app.ssb.patchwork.getPaths(done())
app.ssb.patchwork.getSite(app.user.id, done())
done(function (err, res) {
if (err)
return modals.error('Error Loading User Info', err, 'This error occurred while loading the publisher page')
// markup
var folderPath = o(res[0].site)
var publishedTree = res[1] ? toTree(res[1]) : null
var folderData = o()
var publishBtn
var folderInput = h('input.hidden', { type: 'file', webkitdirectory: true, directory: true, onchange: onfolderchange })
ui.setPage('publisher', h('.layout-onecol',
h('.layout-main',
h('h3', h('strong', 'Your Site')),
h('form',
h('p',
folderInput,
h('a.btn.btn-3d', { onclick: folderInput.click.bind(folderInput) }, 'Select Folder'),
' ',
h('span.files-view-pathctrl', folderPath),
publishBtn = o.transform(folderData, function (d) {
var c = pwt.changes(d)
if (!c.adds.length && !c.dels.length && !c.mods.length)
return h('a.pull-right.btn.btn-primary.disabled', 'No Changes')
var changes = []
if (c.adds.length) changes.push('+'+c.adds.length)
if (c.dels.length) changes.push('-'+c.dels.length)
if (c.mods.length) changes.push('^'+c.mods.length)
changes = changes.join(' / ')
return h('a.pull-right.btn.btn-primary', { onclick: onpublish }, 'Publish ('+changes+')')
})
),
o.transform(folderData, function (fd) {
if (!fd)
return
return h('table.files-view',
h('tbody', render(-1, fd))
)
})
)
)
))
function render (depth, item) {
if (item[pwt.TYPE] == 'file') {
return h('tr',
h('td', getchange(item)),
h('td', h('input', { type: 'checkbox', checked: item[pwt.ACTIVE], onchange: oncheck(item) })),
h('td',
h('a', { style: 'padding-left: '+(+depth*20)+'px' }, com.icon('file'), item[pwt.NAME]), ' ',
getstate(item)
),
h('td', mime.lookup(item[pwt.NAME])||''),
h('td', item[pwt.STAT] && u.bytesHuman(item[pwt.STAT].size))
)
}
var rows = []
if (!item[ISROOT]) {
var col = h('td.folder',
{ onclick: ontoggle(item) },
h('span',
{ style: 'padding-left: '+(+depth*20)+'px' },
com.icon('folder-'+(item[ISOPEN]?'open':'close')), item[pwt.NAME], ' ',
getstate(item)
)
)
col.setAttribute('colspan', 3)
rows.push(h('tr',
h('td', getchange(item)),
h('td', h('input', { type: 'checkbox', checked: item[pwt.ACTIVE], onchange: oncheck(item) })),
col
))
}
// render folders, then files
if (item[ISOPEN]) {
for (var k in item)
if (item[k][pwt.TYPE] == 'directory')
rows.push(render(depth + 1, item[k]))
for (var k in item)
if (item[k][pwt.TYPE] == 'file')
rows.push(render(depth + 1, item[k]))
}
return rows
}
function setactive (item, v) {
item[pwt.ACTIVE] = v
for (var k in item)
setactive(item[k], v)
}
function getstate (item) {
if (item[pwt.DELETED])
return h('em.text-muted', 'not on disk')
if (item[pwt.MODIFIED])
return h('em.text-muted', 'modified')
if (item[pwt.PUBLISHED])
return h('em.text-muted', 'published')
}
function getchange (item) {
return ({ add: 'add', mod: 'update', del: 'remove' })[pwt.change(item)] || ''
}
// handlers
function onfolderchange () {
folderPath(folderInput.files[0].path)
}
folderPath(function (path) {
if (!path)
return
ui.pleaseWait(true, 100)
// :TEMP HACK: create the directory if it does not exist
var fs = require('fs')
if (!fs.existsSync(path))
fs.mkdirSync(path)
pwt.loadworking(path, publishedTree, function (err, data) {
ui.pleaseWait(false)
data[ISROOT] = true
data[ISOPEN] = true
folderData(data)
})
})
function oncheck (item) {
return function () {
var newcheck = !item[pwt.ACTIVE]
if (newcheck && item[pwt.TYPE] == 'directory' && !item[pwt.DELETED]) {
// read all of the directory first
ui.pleaseWait(true, 100)
pwt.readall(item[pwt.PATH], item, next)
}
else next()
function next() {
ui.pleaseWait(false)
setactive(item, newcheck)
folderData(folderData())
}
}
}
function ontoggle (item) {
return function () {
item[ISOPEN] = !item[ISOPEN]
if (item[pwt.DIRREAD] || item[pwt.DELETED])
folderData(folderData())
else {
pwt.read(item[pwt.PATH], item, true, function () {
folderData(folderData())
})
}
}
}
function onpublish () {
var m
var c = pwt.changes(folderData())
var basepathLen = folderPath().length + 1
function renderChange (type, label) {
return function (item) {
return h(type,
h('.action', label),
h('.path', item[pwt.PATH].slice(basepathLen)),
h('.size', item[pwt.STAT] && u.bytesHuman(item[pwt.STAT].size)),
h('.type', item[pwt.STAT] && mime.lookup(item[pwt.NAME])||'')
)
}
}
function onconfirmpublish () {
var done = multicb()
var msg = {
type: 'site',
includes: c.adds.concat(c.mods).map(function (item) {
// create link
var link = {
link: null,
path: item[pwt.PATH].slice(basepathLen),
mtime: item[pwt.STAT].mtime.getTime(),
size: item[pwt.STAT].size,
type: mime.lookup(item[pwt.NAME]) || undefined
}
// add blob
var cb = done()
app.ssb.patchwork.addFileToBlobs(item[pwt.PATH], function (err, res) {
if (err) {
modals.error('Failed to Publish File', err, 'This error occurred while adding files to the blobstore in the publisher interface.')
cb(err)
} else {
link.link = res.hash
if (res.width && res.height) {
link.width = res.width
link.height = res.height
}
cb()
}
})
return link
}),
excludes: c.dels.map(function (item) {
return {
link: item.link,
path: item[pwt.PATH].slice(basepathLen)
}
})
}
m.close()
ui.pleaseWait(true, 100)
done(function (err) {
ui.pleaseWait(false)
if (err) return
app.ssb.publish(msg, function (err) {
if (err)
return modals.error('Failed to Publish Files', err, 'This error occurred while publishing the `site` message in the publisher interface.')
ui.refreshPage()
})
})
}
m = modals.default(h('.modal-form',
h('h3', 'Review Changes'),
h('.files-view-changes',
c.adds.map(renderChange('.add', '+')),
c.mods.map(renderChange('.mod', '^')),
c.dels.map(renderChange('.del', '-'))
),
h('a.btn.btn-primary', { onclick: onconfirmpublish }, 'Publish')
))
}
})
}
function set (obj, path, value) {
var k
while (true) {
k = path.shift()
if (!path.length)
break
if (!obj[k])
obj[k] = {}
obj = obj[k]
}
obj[k] = value
}
function toTree (site) {
var tree = {}
for (var path in site)
set(tree, path.split('/'), site[path])
return tree
}

66
ui/lib/pages/search.js Normal file
View File

@@ -0,0 +1,66 @@
'use strict'
var h = require('hyperscript')
var mlib = require('ssb-msgs')
var pull = require('pull-stream')
var app = require('../app')
var ui = require('../ui')
var com = require('../com')
var social = require('../social-graph')
module.exports = function () {
// filters
if (!app.page.param) {
window.location.hash = '#/'
return
}
var regex = new RegExp(app.page.param.split(' ').join('|'), 'i')
function postFeed (opts) {
opts = opts || {}
opts.type = 'post'
return app.ssb.messagesByType(opts)
}
function postFilter (m) {
var a = m.value.author, c = m.value.content
if (app.users.profiles[a] && app.users.profiles[a].flagged) // flagged by user
return false
if (c.text && regex.test(c.text))
return true
// if (app.homeMode.view == 'all')
// return true
// if (app.homeMode.view == 'friends')
// return a == app.user.id || social.follows(app.user.id, a)
// return social.follows(app.homeMode.view, a) // `view` is the id of a pub
}
function contactFilter (p) {
if (p.self.name && regex.test(p.self.name))
return true
}
function cursor (msg) {
if (msg)
return msg.ts
}
// markup
ui.setPage('home', h('.layout-twocol',
h('.layout-main',
h('h3', 'Search, "', app.page.param, '"'),
com.messageFeed({ feed: postFeed, cursor: cursor, filter: postFilter, onempty: onempty, infinite: true })
),
h('.layout-rightnav',
h('h3', 'People'),
com.contactFeed({ filter: contactFilter, compact: true, onempty: onempty })
)
))
function onempty (el) {
el.appendChild(h('p', 'No results found'))
}
}

108
ui/lib/pages/setup.js Normal file
View File

@@ -0,0 +1,108 @@
'ust strict'
var h = require('hyperscript')
var schemas = require('ssb-msg-schemas')
var app = require('../app')
var ui = require('../ui')
var modals = require('../ui/modals')
var com = require('../com')
module.exports = function () {
var name = app.users.names[app.user.id] || ''
var profilePic = null
var is_new = !name
// markup
var nameInput = h('input.form-control', { type: 'text', name: 'name', placeholder: 'Nickname', value: name, onkeyup: checkInput })
var imageUploader = com.imageUploader({ onupload: onImageUpload, existing: com.profilePicUrl(app.user.id) })
var issue = h('.text-danger.hide')
var postBtn = h('button.btn.btn-primary', { onclick: post, disabled: is_new }, 'Save')
ui.setPage('setup', h('.layout-setup',
h('.layout-setup-left',
h('h2', (is_new) ? 'New account' : 'Edit Your Profile'),
h('.panel.panel-default', { style: 'border: 0' },
h('.panel-body',
h('.form-group',
h('label.control-label', 'Your nickname'),
nameInput
)
)
),
h('.panel.panel-default', { style: 'border: 0' },
h('.panel-body',
h('label.control-label', 'Your profile pic'),
imageUploader
)
)
),
h('.layout-setup-right', h('.layout-setup-right-inner',
(is_new) ?
[
h('p', 'Welcome to ', h('strong', 'Secure Scuttlebutt!')),
h('p', 'Fill out your profile and then click ', h('strong', 'Save'), ' to get started.')
] :
h('p', 'Update your profile and then click ', h('strong', 'Save'), ' to publish the changes.'),
h('.panel.panel-default', { style: 'border: 0; display: inline-block' },
h('.panel-body', issue, postBtn)),
(!is_new) ? h('div', { style: 'padding: 0 22px' }, h('a.text-muted', { href: '#', onclick: oncancel }, 'Cancel')) : ''
))
))
// handlers
var badNameCharsRegex = /[^A-z0-9\._-]/
function checkInput (e) {
if (!nameInput.value) {
postBtn.setAttribute('disabled', true)
postBtn.classList.remove('hide')
issue.classList.add('hide')
} else if (badNameCharsRegex.test(nameInput.value)) {
issue.innerHTML = 'We\'re sorry, your name can only include A-z 0-9 . _ - and cannot have spaces.'
postBtn.setAttribute('disabled', true)
postBtn.classList.add('hide')
issue.classList.remove('hide')
} else if (nameInput.value.slice(-1) == '.') {
issue.innerHTML = 'We\'re sorry, your name cannot end with a period.'
postBtn.setAttribute('disabled', true)
postBtn.classList.add('hide')
issue.classList.remove('hide')
} else {
postBtn.removeAttribute('disabled')
postBtn.classList.remove('hide')
issue.classList.add('hide')
}
}
function post (e) {
e.preventDefault()
if (!nameInput.value)
return
// close out image uploader if the user didnt
imageUploader.forceDone(function () {
// publish
ui.pleaseWait(true, 500)
app.ssb.publish(schemas.about(app.user.id, nameInput.value, profilePic), function (err) {
ui.pleaseWait(false)
if (err) modals.error('Error While Publishing', err, 'This error occurred while trying to post a new profile in setup.')
else window.location = '#/'
})
})
}
function oncancel (e) {
e.preventDefault()
window.location = '#/profile/'+app.user.id
}
function onImageUpload (hasher) {
profilePic = {
link: '&'+hasher.digest,
size: hasher.size,
type: 'image/png',
width: 275,
height: 275
}
}
}

27
ui/lib/pages/stars.js Normal file
View File

@@ -0,0 +1,27 @@
'use strict'
var h = require('hyperscript')
var app = require('../app')
var ui = require('../ui')
var com = require('../com')
module.exports = function () {
var p = app.page.param || 'onyours'
var render = (p == 'onyours') ? com.messageSummary : com.message
var feed = (p == 'onyours') ? app.ssb.patchwork.createVoteStream : app.ssb.patchwork.createMyvoteStream
// markup
ui.setPage('stars', h('.layout-onecol',
h('.layout-main',
h('h3.text-center',
h((p == 'onyours' ? 'strong' : 'a'), { href: '#/stars/onyours'}, 'Stars on Your Posts'),
' / ',
h((p == 'byyou' ? 'strong' : 'a'), { href: '#/stars/byyou'}, 'Starred by You')
),
com.messageFeed({ render: render, feed: feed, markread: true, onempty: onempty, infinite: true }))
))
function onempty (feedEl) {
feedEl.appendChild(h('p.text-center', { style: 'margin: 25px 0; padding: 10px; color: gray' }, 'No stars... yet!'))
}
}

113
ui/lib/pages/sync.js Normal file
View File

@@ -0,0 +1,113 @@
'use strict'
var h = require('hyperscript')
var o = require('observable')
var mlib = require('ssb-msgs')
var pull = require('pull-stream')
var multicb = require('multicb')
var app = require('../app')
var ui = require('../ui')
var modals = require('../ui/modals')
var com = require('../com')
var u = require('../util')
var social = require('../social-graph')
module.exports = function () {
// markup
var pubStatusEl = h('.pub-status')
var peersEl = h('.peers',
h('h3', 'Mesh Network', h('a.pull-right.btn.btn-3d.btn-sm', { onclick: addnode }, com.icon('plus'), ' Add Node...')),
o.transform(app.observ.peers, function (peers) {
return h('div', peers.map(renderPeer))
})
)
ui.setPage('sync', h('.layout-onecol',
h('.layout-main',
o.transform(app.observ.peers, function () {
var stats = u.getPubStats()
var danger1 = (stats.membersof === 0) ? '.text-danger' : ''
var danger2 = (stats.active === 0) ? '.text-danger' : ''
var warning
if (stats.membersof === 0)
warning = h('p', com.icon('warning-sign'), ' You need to join a pub if you want to communicate across the Internet!')
else if (stats.active === 0)
warning = h('p', com.icon('warning-sign'), ' None of your pubs are responding! Are you connected to the Internet?')
return h('.pub-status',
h('h3'+danger1, 'You\'re followed by ', stats.membersof,' public node', (stats.membersof==1?'':'s'), ' ', h('small'+danger2, stats.active, ' connected')),
warning,
h('p', h('a.btn.btn-3d', { href: '#', onclick: modals.invite }, com.icon('cloud'), ' Join a Public Node'))
)
}),
peersEl
)
))
function setprogress (el, p, label) {
el.querySelector('.progress-bar').style.width = p + '%'
el.querySelector('.progress-bar span').innerText = label
if (label)
el.querySelector('.progress-bar').style.minWidth = '12%'
else
el.querySelector('.progress-bar').style.minWidth = '2%'
}
function renderPeer (peer) {
function onsync (e) {
e.preventDefault()
app.ssb.gossip.connect({ host: peer.host, port: peer.port, key: peer.key }, function (){})
}
var lastConnect
if (peer.time) {
if (peer.time.connect > peer.time.attempt)
lastConnect = [h('span.text-success', com.icon('ok')), ' Synced '+(new Date(peer.time.connect).toLocaleString())]
else if (peer.time.attempt) {
lastConnect = [h('span.text-danger', com.icon('remove')), ' Attempted (but failed) to connect at '+(new Date(peer.time.attempt).toLocaleString())]
}
}
var el = h('.peer' + ((peer.connected)?'.connected':''), { 'data-id': peer.key },
com.userHexagon(peer.key, 80),
h('.details',
social.follows(peer.key, app.user.id) ? h('small.pull-right.label.label-success', 'Follows You') : '',
h('h3',
com.userName(peer.key),
' ',
((peer.connected) ?
h('a.btn.btn-3d.btn-xs.disabled', 'Syncing') :
h('a.btn.btn-3d.btn-xs', { href: '#', onclick: onsync }, 'Sync')),
' ',
h('br'), h('small', peer.host+':'+peer.port+':'+peer.key)
),
h('.progress', h('.progress-bar.progress-bar-striped.active', h('span'))),
h('p.last-connect', lastConnect)
)
)
if (peer.connected) {
if (!peer.progress)
setprogress(el, 0, ' Connecting... ')
else if (peer.progress.sync)
setprogress(el, 100, 'Live-streaming')
else
setprogress(el, Math.round(peer.progress.current / peer.progress.total * 100), 'Syncing...')
}
return el
}
// handlers
function addnode () {
modals.prompt('Nodes full address:', 'host:port@key', 'Connect', function (err, addr) {
app.ssb.gossip.connect(addr, function (err) {
if (err)
modals.error('Failed to Connect', err, 'Error occurred while trying to manually add a node to the network mesh.')
})
})
}
}

42
ui/lib/pages/webview.js Normal file
View File

@@ -0,0 +1,42 @@
'use strict'
var h = require('hyperscript')
var o = require('observable')
var com = require('../com')
var app = require('../app')
var ui = require('../ui')
var ssbref = require('ssb-ref')
module.exports = function (opts) {
var param = (opts && opts.param) ? opts.param : app.page.param
var port = (ssbref.isLink(param)) ? 7777 : 7778
var url = 'http://localhost:' + port + '/' + param
// markup
var webview = com.webview({ url: url })
ui.setPage('webview', h('.layout-grid',
h('.layout-grid-col.webview-left', webview),
(opts && opts.sideview) ? h('.layout-grid-col.webview-right', { style: showhide(app.observ.sideview) }, opts.sideview) : ''
), { onPageTeardown: function () {
window.removeEventListener('resize', resize)
}})
function showhide (input) {
return { display: o.transform(input, function (v) { return (v) ? 'block' : 'none' }) }
}
// dynamically size various controls
resize()
window.addEventListener('resize', resize)
function resize () {
[
[webview.querySelector('::shadow object'), 0],
[document.querySelector('.webview-page .layout-grid'), 0],
[document.querySelector('.webview-page .webview-left'), 0],
[document.querySelector('.webview-page .webview-right'), 0]
].forEach(function (entry) {
if (entry[0])
entry[0].style.height = (window.innerHeight - 40 - entry[1]) + 'px'
})
}
}

76
ui/lib/social-graph.js Normal file
View File

@@ -0,0 +1,76 @@
var app = require('./app')
var follows =
exports.follows = function (a, b) {
var ap = app.users.profiles[a]
if (!ap) return false
return ap.assignedTo[b] && ap.assignedTo[b].following
}
var flags =
exports.flags = function (a, b) {
var ap = app.users.profiles[a]
if (!ap) return false
return ap.assignedTo[b] && ap.assignedTo[b].flagged
}
var blocks =
exports.blocks = function (a, b) {
var ap = app.users.profiles[a]
if (!ap) return false
return ap.assignedTo[b] && ap.assignedTo[b].blocking
}
var followeds =
exports.followeds = function (a) {
var ids = []
for (var b in app.users.profiles) {
if (follows(a, b))
ids.push(b)
}
return ids
}
var followers =
exports.followers = function (b) {
var ids = []
for (var a in app.users.profiles) {
if (follows(a, b))
ids.push(a)
}
return ids
}
var followedFollowers =
exports.followedFollowers = function (a, c, includeA) {
var ids = []
for (var b in app.users.profiles) {
if (follows(a, b) && follows(b, c))
ids.push(b)
}
if (includeA && follows(a, c))
ids.push(a)
return ids
}
var unfollowedFollowers =
exports.unfollowedFollowers = function (a, c) {
var ids = []
for (var b in app.users.profiles) {
if (a != b && !follows(a, b) && follows(b, c))
ids.push(b)
}
return ids
}
var followedFlaggers =
exports.followedFlaggers = function (a, c, includeA) {
var ids = []
for (var b in app.users.profiles) {
if (follows(a, b) && flags(b, c))
ids.push(b)
}
if (includeA && flags(a, c))
ids.push(a)
return ids
}

15
ui/lib/ui/anim.js Normal file
View File

@@ -0,0 +1,15 @@
exports.textDecoding = function (el, text) {
var n = 1
var text2 = btoa(text)
el.innerText = text2.slice(0, text.length)
setTimeout(function () {
var eli = setInterval(function () {
el.innerText = text.slice(0, n) + text2.slice(n, text.length)
n++
if (n > text.length)
clearInterval(eli)
}, 33)
}, 1500)
}

340
ui/lib/ui/index.js Normal file
View File

@@ -0,0 +1,340 @@
var h = require('hyperscript')
var router = require('phoenix-router')
var remote = require('remote')
var Menu = remote.require('menu')
var MenuItem = remote.require('menu-item')
var dialog = remote.require('dialog')
var ssbref = require('ssb-ref')
var app = require('../app')
var com = require('../com')
var u = require('../util')
var pages = require('../pages')
var _onPageTeardown
var _teardownTasks = []
var _hideNav = false
// re-renders the page
var refreshPage =
module.exports.refreshPage = function (e, cb) {
e && e.preventDefault()
var starttime = Date.now()
// run the router
var route = router('#'+(location.href.split('#')[1]||''), 'home')
app.page.id = route[0]
app.page.param = route[1]
app.page.qs = route[2] || {}
// update state
app.fetchLatestState(function() {
// re-route to setup if needed
if (!app.users.names[app.user.id]) {
_hideNav = true
if (window.location.hash != '#/setup') {
window.location.hash = '#/setup'
cb && (typeof cb == 'function') && cb()
return
}
} else
_hideNav = false
// cleanup the old page
h.cleanup()
window.onscroll = null // commonly used for infinite scroll
_onPageTeardown && _onPageTeardown()
_onPageTeardown = null
_teardownTasks.forEach(function (task) { task() })
_teardownTasks.length = 0
// render the new page
var page = pages[app.page.id]
if (!page)
page = pages.notfound
page()
// clear pending messages, if home
if (app.page.id == 'home')
app.observ.newPosts(0)
// metrics
console.debug('page loaded in', (Date.now() - starttime), 'ms')
cb && (typeof cb == 'function') && cb()
})
}
var renderNav =
module.exports.renderNav = function () {
var navEl = document.getElementById('page-nav')
if (_hideNav) {
navEl.style.display = 'none'
} else {
navEl.style.display = 'block'
navEl.innerHTML = ''
navEl.appendChild(com.pagenav())
setNavAddress()
}
}
// render a new page
module.exports.setPage = function (name, page, opts) {
if (opts && opts.onPageTeardown)
_onPageTeardown = opts.onPageTeardown
// render nav
renderNav()
// render page
var pageEl = document.getElementById('page-container')
pageEl.innerHTML = ''
if (!opts || !opts.noHeader)
pageEl.appendChild(com.page(name, page))
else
pageEl.appendChild(h('#page.'+name+'-page', page))
// scroll to top
window.scrollTo(0, 0)
}
var setNavAddress =
module.exports.setNavAddress = function (location) {
if (!location) {
// pull from current page
var location = app.page.id
if (location == 'profile' || location == 'webview' || location == 'search' || location == 'msg')
location = app.page.param
}
document.body.querySelector('#page-nav input').value = location
}
module.exports.onTeardown = function (cb) {
_teardownTasks.push(cb)
}
module.exports.navBack = function (e) {
e && e.preventDefault()
e && e.stopPropagation()
window.history.back()
}
module.exports.navForward = function (e) {
e && e.preventDefault()
e && e.stopPropagation()
window.history.forward()
}
module.exports.navRefresh = function (e) {
e && e.preventDefault()
e && e.stopPropagation()
refreshPage()
}
module.exports.contextMenu = function (e) {
e.preventDefault()
e.stopPropagation()
var menu = new Menu()
menu.append(new MenuItem({ label: 'Copy', click: oncopy }))
menu.append(new MenuItem({ label: 'Select All', click: onselectall }))
menu.append(new MenuItem({ type: 'separator' }))
if (app.page.id == 'webview' && ssbref.isBlobId(app.page.param)) {
menu.append(new MenuItem({ label: 'Save As...', click: onsaveas }))
menu.append(new MenuItem({ type: 'separator' }))
}
menu.append(new MenuItem({ label: 'Open Devtools', click: function() { openDevTools() } }))
menu.popup(remote.getCurrentWindow())
function oncopy () {
var webview = document.querySelector('webview')
if (webview)
webview.copy()
else
require('clipboard').writeText(window.getSelection().toString())
}
function onselectall () {
var webview = document.querySelector('webview')
if (webview)
webview.selectAll()
else {
var selection = window.getSelection()
var range = document.createRange()
range.selectNodeContents(document.getElementById('page'))
selection.removeAllRanges()
selection.addRange(range)
}
}
function onsaveas () {
var path = dialog.showSaveDialog(remote.getCurrentWindow())
if (path) {
app.ssb.patchwork.saveBlobToFile(app.page.param, path, function (err) {
if (err) {
alert('Error: '+err.message)
console.error(err)
} else
notice('success', 'Saved to '+path, 5e3)
})
}
}
}
var openDevTools =
module.exports.openDevTools = function () {
var webview = document.querySelector('webview')
if (webview)
webview.openDevTools()
else
remote.getCurrentWindow().openDevTools()
}
var toggleDevTools =
module.exports.toggleDevTools = function () {
var webview = document.querySelector('webview')
if (webview) {
if (webview.isDevToolsOpened())
webview.closeDevTools()
else
webview.openDevTools()
} else
remote.getCurrentWindow().toggleDevTools()
}
var oldScrollTop
module.exports.disableScrolling = function () {
oldScrollTop = document.body.scrollTop
document.querySelector('html').style.overflow = 'hidden'
window.scrollTo(0, oldScrollTop)
}
module.exports.enableScrolling = function () {
document.querySelector('html').style.overflow = 'auto'
window.scrollTo(0, oldScrollTop)
}
var setStatus =
module.exports.setStatus = function (message) {
var status = document.getElementById('app-status')
status.innerHTML = ''
if (message) {
if (message.indexOf('/profile/') === 0) {
var id = message.slice('/profile/'.length)
message = [h('strong', com.userName(id)), ' ', com.userRelationship(id)]
} else if (message.indexOf('/msg/') === 0) {
message = message.slice('/msg/'.length)
}
status.appendChild(h('div', message))
}
}
var numNotices = 0
var notice =
module.exports.notice = function (type, message, duration) {
var notices = document.getElementById('app-notices')
var el = h('.alert.alert-'+type, message)
notices.appendChild(el)
function remove () {
notices.removeChild(el)
}
setTimeout(remove, duration || 15e3)
return remove
}
var pleaseWaitTimer, uhohTimer, tooLongTimer, noticeRemove
var pleaseWait =
module.exports.pleaseWait = function (enabled, after) {
function doit() {
// clear main timer
clearTimeout(pleaseWaitTimer); pleaseWaitTimer = null
noticeRemove && noticeRemove()
noticeRemove = null
if (enabled === false) {
// hide spinner
document.querySelector('#please-wait').style.display = 'none'
setStatus(false)
// clear secondary timers
clearTimeout(uhohTimer); uhohTimer = null
clearTimeout(tooLongTimer); tooLongTimer = null
}
else {
// show spinner
document.querySelector('#please-wait').style.display = 'block'
// setup secondary timers
uhohTimer = setTimeout(function () {
noticeRemove = notice('warning', 'Hmm, this seems to be taking a while...')
}, 5e3)
tooLongTimer = setTimeout(function () {
noticeRemove = notice('danger', 'I think something broke :(. Please restart Patchwork and let us know if this keeps happening!')
}, 20e3)
}
}
// disable immediately
if (!enabled)
return doit()
// enable immediately, or after a timer (if not already waiting)
if (!after)
doit()
else if (!pleaseWaitTimer)
pleaseWaitTimer = setTimeout(doit, after)
}
module.exports.dropdown = function (el, options, opts, cb) {
if (typeof opts == 'function') {
cb = opts
opts = null
}
opts = opts || {}
// render
var dropdown = h('.dropdown'+(opts.cls||'')+(opts.right?'.right':''),
{ onmouseleave: die },
options.map(function (o) {
if (o instanceof HTMLElement)
return o
if (o.separator)
return h('hr')
return h('a.item', { href: '#', onclick: onselect(o.value), title: o.title||'' }, o.label)
})
)
if (opts.width)
dropdown.style.width = opts.width + 'px'
// position off the parent element
var rect = el.getClientRects()[0]
dropdown.style.top = (rect.bottom + document.body.scrollTop + 10 + (opts.offsetY||0)) + 'px'
if (opts.right)
dropdown.style.left = (rect.right + document.body.scrollLeft - (opts.width||200) + 5 + (opts.offsetX||0)) + 'px'
else
dropdown.style.left = (rect.left + document.body.scrollLeft - 20 + (opts.offsetX||0)) + 'px'
// add to page
document.body.appendChild(dropdown)
document.body.addEventListener('click', die)
// handler
function onselect (value) {
return function (e) {
e.preventDefault()
cb(value)
die()
}
}
function die () {
document.body.removeEventListener('click', die)
if (dropdown)
document.body.removeChild(dropdown)
dropdown = null
}
}
module.exports.triggerFind = function () {
var finder = document.body.querySelector('#finder')
if (!finder) {
document.body.appendChild(finder = com.finder())
finder.querySelector('input').focus()
} else {
finder.find()
}
}

325
ui/lib/ui/modals.js Normal file
View File

@@ -0,0 +1,325 @@
var h = require('hyperscript')
var schemas = require('ssb-msg-schemas')
var clipboard = require('clipboard')
var app = require('../app')
var ui = require('./index')
var com = require('../com')
var social = require('../social-graph')
var ref = require('ssb-ref')
var modal =
module.exports.default = function (el) {
// create a context so we can release this modal on close
var h2 = h.context()
var canclose = true
// markup
var inner = h2('.modal-inner', el)
var modal = h2('.modal', { onclick: onmodalclick }, inner)
document.body.appendChild(modal)
modal.enableClose = function () { canclose = true }
modal.disableClose = function () { canclose = false }
modal.close = function () {
if (!canclose)
return
// remove
document.body.removeChild(modal)
window.removeEventListener('hashchange', modal.close)
window.removeEventListener('keyup', onkeyup)
h2.cleanup()
ui.enableScrolling()
modal = null
}
// handlers
function onmodalclick (e) {
if (e.target == modal)
modal.close()
}
function onkeyup (e) {
// close on escape
if (e.which == 27)
modal.close()
}
window.addEventListener('hashchange', modal.close)
window.addEventListener('keyup', onkeyup)
ui.disableScrolling()
return modal
}
var errorModal =
module.exports.error = function (title, err, extraIssueInfo) {
var message = err.message || err.toString()
var stack = err.stack || ''
var issueDesc = message + '\n\n' + stack + '\n\n' + (extraIssueInfo||'')
var issueUrl = 'https://github.com/ssbc/patchwork/issues/new?body='+encodeURIComponent(issueDesc)
var m = modal(h('.error-form',
h('.error-form-title', title),
h('.error-form-message', message),
h('.error-form-actions',
h('a.btn.btn-primary', { onclick: function() { m.close() } }, 'Dismiss'),
h('a.btn.btn-info.noicon', { href: issueUrl, target: '_blank' }, 'File an Issue')
),
(stack) ? h('pre.error-form-stack', h('code', stack)) : ''
))
return m
}
module.exports.prompt = function (text, placeholder, submitText, cb) {
var input = h('input.form-control', { placeholder: placeholder })
function onsubmit (e) {
e.preventDefault()
m.close()
cb(null, input.value)
}
var m = modal(h('.modal-form',
h('p', text),
h('form', { onsubmit: onsubmit }, h('p', input), h('button.btn.btn-3d', submitText))
))
input.focus()
return m
}
module.exports.invite = function (e) {
e.preventDefault()
// render
var form = com.inviteForm({ onsubmit: onsubmit })
var m = modal(form)
form.querySelector('input').focus()
// handlers
function onsubmit (code) {
form.disable()
m.disableClose()
// surrounded by quotes?
// (the scuttlebot cli ouputs invite codes with quotes, so this could happen)
if (code.charAt(0) == '"' && code.charAt(code.length - 1) == '"')
code = code.slice(1, -1) // strip em
if (ref.isInvite(code)) {
form.setProcessingText('Contacting server with invite code, this may take a few moments...')
app.ssb.invite.accept(code, addMeNext)
}
else
m.enableClose(), form.enable(), form.setErrorText('Invalid invite code')
function addMeNext (err) {
m.enableClose()
if (err) {
console.error(err)
form.setErrorText(userFriendlyInviteError(err.stack || err.message))
form.enable()
return
}
// trigger sync with the pub
app.ssb.gossip.connect(code.split('~')[0])
// nav to the newsfeed for the livestream
m.close()
if (window.location.hash != '#/home')
window.location.hash = '#/home'
else
ui.refreshPage()
}
}
}
module.exports.lookup = function (e) {
e.preventDefault()
// render
var form = com.lookupForm({ onsubmit: onsubmit })
var m = modal(form)
form.querySelector('input').focus()
// create user's code
app.ssb.gossip.peers(function (err, peers) {
if (!peers) {
m.close()
errorModal('Error Fetching Peer Information', err)
return
}
var addrs = []
peers.forEach(function (peer) {
if (social.follows(peer.key, app.user.id))
addrs.push(peer.host + ':' + peer.port + ':' + peer.key)
})
var code = app.user.id
if (addrs.length)
code += '[via]'+addrs.join(',')
form.setYourLookupCode(code)
})
// handlers
function onsubmit (code) {
var id, seq, err
form.disable()
pull(app.ssb.patchwork.useLookupCode(code), pull.drain(
function (e) {
if (e.type == 'connecting')
form.setProcessingText('Connecting...')
if (e.type == 'syncing') {
id = e.id
form.setProcessingText('Connected, syncing user data...')
}
if (e.type == 'finished')
seq = e.seq
if (e.type == 'error')
err = e
},
function () {
form.enable()
if (id && seq) {
form.setProcessingText('Profile synced, redirecting...')
setTimeout(function () {
window.location.hash = '#/profile/'+id
}, 1e3)
} else {
if (err) {
form.setErrorText('Error: '+err.message+ ' :(')
console.error(err)
} else
form.setErrorText('Error: User not found :(')
}
})
)
}
}
module.exports.getLookup = function (e) {
e.preventDefault()
// render
var codesEl = h('div')
var m = modal(h('.lookup-code-form', codesEl))
// collect codes
app.ssb.gossip.peers(function (err, peers) {
if (!peers) {
m.close()
errorModal('Error Getting Lookup Codes', err)
return
}
var addrs = []
peers.forEach(function (peer) {
if (social.follows(peer.key, app.user.id))
addrs.push(peer.host + ':' + peer.port + ':' + (peer.key || peer.link))
})
var code = app.user.id
if (addrs.length)
code += '[via]'+addrs.join(',')
codesEl.appendChild(h('.code',
h('p',
h('strong', 'Your Lookup Code'),
' ',
h('a.btn.btn-3d.btn-xs.pull-right', { href: '#', onclick: oncopy(code) }, com.icon('copy'), ' Copy to clipboard')
),
h('p', h('input.form-control', { value: code }))
))
})
// handlers
function oncopy (text) {
return function (e) {
e.preventDefault()
var btn = e.target
if (btn.tagName == 'SPAN')
btn = e.path[1]
clipboard.writeText(text)
btn.innerText = 'Copied!'
}
}
}
module.exports.setName = function (userId) {
userId = userId || app.user.id
// render
var oldname = com.userName(userId)
var form = com.renameForm(userId, { onsubmit: onsubmit })
var m = modal(form)
form.querySelector('input').focus()
// handlers
function onsubmit (name) {
if (!name)
return
if (name === oldname)
return m.close()
app.ssb.publish(schemas.name(userId, name), function (err) {
if (err)
console.error(err), errorModal('Error While Publishing', err)
else {
m.close()
ui.refreshPage()
}
})
}
}
module.exports.flag = function (userId) {
// render
var form = com.flagForm(userId, { onsubmit: onsubmit })
var m = modal(form)
// handlers
function onsubmit () {
m.close()
ui.refreshPage()
}
}
module.exports.post = function (rootMsg, branchMsg, opts) {
// render
var _onpost = opts.onpost
var _oncancel = opts.oncancel
opts = opts || {}
opts.onpost = onpost
opts.oncancel = oncancel
var m = modal(com.postForm(rootMsg, branchMsg, opts))
// handlers
function onpost (msg) {
m.close()
_onpost && _onpost(msg)
}
function oncancel () {
m.close()
_oncancel && _oncancel()
}
}

167
ui/lib/ui/subwindows.js Normal file
View File

@@ -0,0 +1,167 @@
var h = require('hyperscript')
var com = require('../com')
var u = require('../util')
var social = require('../social-graph')
var subwindows = []
var makeSubwindow =
module.exports.subwindow = function (el, title, opts) {
// create a context so we can release this window on close
opts = opts || {}
var icon = com.icon(opts.icon || 'th-large')
var h2 = h.context()
var canclose = true
// markup
var collapseToggleIcon = com.icon('chevron-down')
var subwindow = h2('.subwindow',
h2('.subwindow-toolbar',
h2('.title', icon, ' ', h('span.title-text', title)),
(opts.help) ? h2('a.help', { href: '#', onclick: onhelp }, com.icon('question-sign')) : '',
(opts.url) ? h2('a.goto', { href: '#', onclick: ongoto, title: 'Go to page for '+title }, com.icon('arrow-right')) : '',
h2('a', { href: '#', onclick: oncollapsetoggle }, collapseToggleIcon),
h2('a.close', { href: '#', onclick: onclose }, com.icon('remove'))
),
h2('.subwindow-body', el)
)
document.body.appendChild(subwindow)
subwindow.enableClose = function () { canclose = true }
subwindow.disableClose = function () { canclose = false }
subwindow.collapse = function () {
subwindow.classList.add('collapsed')
collapseToggleIcon.classList.remove('glyphicon-chevron-down')
collapseToggleIcon.classList.add('glyphicon-'+(opts.icon || 'chevron-up'))
reflow()
}
subwindow.expand = function () {
subwindow.classList.remove('collapsed')
collapseToggleIcon.classList.remove('glyphicon-'+(opts.icon || 'chevron-up'))
collapseToggleIcon.classList.add('glyphicon-chevron-down')
reflow()
}
subwindow.close = function (force) {
if (!canclose)
return
// check if there are any forms in progress
if (!force) {
var els = Array.prototype.slice.call(subwindow.querySelectorAll('textarea'))
for (var i=0; i < els.length; i++) {
if (els[i].value) {
if (!confirm('Lose changes to your draft?'))
return
break
}
}
}
// remove
document.body.removeChild(subwindow)
h2.cleanup()
subwindows.splice(subwindows.indexOf(subwindow), 1)
reflow()
subwindow = null
}
// handlers
function onclose (e) {
e.preventDefault()
subwindow.close()
}
function ongoto (e) {
e.preventDefault()
window.location = opts.url
}
function oncollapsetoggle (e) {
e.preventDefault()
if (subwindow.classList.contains('collapsed'))
subwindow.expand()
else
subwindow.collapse()
}
function onhelp (e) {
e.preventDefault()
makeSubwindow(com.help.helpBody(opts.help), [com.icon('question-sign'), ' ', com.help.helpTitle(opts.help)])
}
// manage
subwindows.push(subwindow)
reflow()
return subwindow
}
module.exports.pm = function (opts) {
// render
opts = opts || {}
opts.onpost = onpost
var form = com.pmForm(opts)
var sw = makeSubwindow(form, 'Secret Message', { icon: 'lock', help: 'secret-messages' })
try { form.querySelector('input').focus() } catch (e) {}
// handlers
function onpost () {
sw.close(true)
}
}
module.exports.help = function (topic) {
return makeSubwindow(com.help.helpBody(topic), com.help.helpTitle(topic), { icon: 'question-sign' })
}
module.exports.message = function (key) {
app.ssb.get(key, function (err, msg) {
if (err || !msg)
require('../ui/subwindows').subwindow(h('p', 'Message Not Found'), 'Message')
else {
msg = { key: key, value: msg }
var title = u.shortString(msg.value.content.text || 'Message Thread', 30)
var sw = makeSubwindow(
com.message(msg, { fullview: true, markread: true, live: true }),
title,
{ icon: 'envelope', url: '#/msg/'+msg.key }
)
sw.querySelector('.subwindow-body').style.background = '#eee'
}
})
}
module.exports.inbox = function () {
var feedEl = com.messageFeed({
feed: app.ssb.patchwork.createInboxStream,
live: app.ssb.patchwork.createInboxStream({ gt: [Date.now(), null], live: true }),
onempty: onempty
})
function onempty (feedEl) {
feedEl.appendChild(h('p.text-center', { style: 'margin: 25px 0; padding: 10px; color: gray' }, 'Your inbox is empty!'))
}
var sw = makeSubwindow(feedEl, 'Inbox', { icon: 'inbox' })
sw.querySelector('.subwindow-body').style.background = '#eee'
}
// reposition subwindows
var SPACING = 10
function reflow () {
var right = SPACING
subwindows.forEach(function (sw) {
sw.style.right = right + 'px'
if (sw.classList.contains('collapsed'))
right += 50 + SPACING
else
right += 500 + SPACING
})
}

310
ui/lib/util.js Normal file
View File

@@ -0,0 +1,310 @@
var pull = require('pull-stream')
var mlib = require('ssb-msgs')
var mime = require('mime-types')
var multicb = require('multicb')
var app = require('./app')
var social = require('./social-graph')
exports.debounce = function (fn, wait) {
var timeout
return function() {
clearTimeout(timeout)
timeout = setTimeout(fn, wait)
}
}
exports.getJson = function(path, cb) {
var xhr = new XMLHttpRequest()
xhr.open('GET', path, true)
xhr.responseType = 'json'
xhr.onreadystatechange = function() {
if (xhr.readyState == 4) {
var err
if (xhr.status < 200 || xhr.status >= 400)
err = new Error(xhr.status + ' ' + xhr.statusText)
cb(err, xhr.response)
}
}
xhr.send()
}
exports.postJson = function(path, obj, cb) {
var xhr = new XMLHttpRequest()
xhr.open('POST', path, true)
xhr.setRequestHeader('Content-Type', 'application/json')
xhr.responseType = 'json'
xhr.onreadystatechange = function() {
if (xhr.readyState == 4) {
var err
if (xhr.status < 200 || xhr.status >= 400)
err = new Error(xhr.status + ' ' + xhr.statusText)
cb(err, xhr.response)
}
}
xhr.send(JSON.stringify(obj))
}
exports.prettydate = require('nicedate')
var escapePlain =
exports.escapePlain = function(str) {
return (str||'')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
exports.shortString = function(str, len) {
len = len || 6
if (str.length - 3 > len)
return str.slice(0, len) + '...'
return str
}
var dataSizes = ['kb', 'mb', 'gb', 'tb', 'pb', 'eb', 'zb', 'yb']
exports.bytesHuman = function (nBytes) {
var str = nBytes + 'b'
for (var i = 0, nApprox = nBytes / 1024; nApprox > 1; nApprox /= 1024, i++) {
str = nApprox.toFixed(2) + dataSizes[i]
}
return str
}
// http://stackoverflow.com/a/23329386
exports.stringByteLength = function (str) {
// returns the byte length of an utf8 string
var s = str.length;
for (var i=str.length-1; i>=0; i--) {
var code = str.charCodeAt(i);
if (code > 0x7f && code <= 0x7ff) s++;
else if (code > 0x7ff && code <= 0xffff) s+=2;
}
return s;
}
// https://stackoverflow.com/questions/2541481/get-average-color-of-image-via-javascript
exports.getAverageRGB = function (imgEl) {
var blockSize = 5, // only visit every 5 pixels
canvas = document.createElement('canvas'),
context = canvas.getContext && canvas.getContext('2d'),
data, width, height,
i = -4,
length,
rgb = {r:0,g:0,b:0},
count = 0
if (!context) {
return null
}
height = canvas.height = imgEl.naturalHeight || imgEl.offsetHeight || imgEl.height
width = canvas.width = imgEl.naturalWidth || imgEl.offsetWidth || imgEl.width
context.drawImage(imgEl, 0, 0)
try {
data = context.getImageData(0, 0, width, height)
} catch(e) {
return null
}
length = data.data.length
while ( (i += blockSize * 4) < length ) {
++count
rgb.r += data.data[i]
rgb.g += data.data[i+1]
rgb.b += data.data[i+2]
}
rgb.r = (rgb.r/count)|0
rgb.g = (rgb.g/count)|0
rgb.b = (rgb.b/count)|0
return rgb
}
function votesFetcher (fetchFn) {
return function (voteTopic, cb) {
var stats = { uservote: 0, voteTally: 0, votes: {}, upvoters: [], downvoters: [] }
pull(
app.ssb[fetchFn]({ id: voteTopic, rel: 'voteTopic' }),
pull.asyncMap(function (link, cb2) { app.ssb.get(link.message, cb2) }),
pull.collect(function (err, voteMsgs) {
if (err)
return cb(err)
// collect final votes
voteMsgs.forEach(function (m) {
if (m.content.type !== 'vote')
return
if (m.content.vote === 1 || m.content.vote === 0 || m.content.vote === -1)
stats.votes[m.author] = m.content.vote
})
// tally the votes
for (var author in stats.votes) {
var v = stats.votes[author]
if (v === 1) {
stats.upvoters.push(author)
stats.voteTally++
}
else if (v === -1) {
stats.downvoters.push(author)
stats.voteTally--
}
}
stats.uservote = stats.votes[app.user.id] || 0
cb(null, stats)
})
)
}
}
// :TODO: cant do fetchMsgVotes because the messagesLinkedToMessage fetcher works differently than all the others
// see https://github.com/ssbc/secure-scuttlebutt/issues/99
exports.fetchFeedVotes = votesFetcher('messagesLinkedToFeed')
exports.fetchExtVotes = votesFetcher('feedsLinkedToExternal')
exports.calcMessageStats = function (thread, opts) {
var stats = { comments: 0, uservote: 0, voteTally: 0, votes: {} }
function process (t, depth) {
if (!t.related)
return
t.related.forEach(function (r) {
var c = r.value.content
// only process votes for immediate children
if (depth === 0 && c.type === 'vote') {
// track latest choice, dont tally yet in case multiple votes by one user
stats.votes[r.value.author] = c.vote
}
else if (c.type !== 'vote') {
// count non-votes as a comment
stats.comments++
}
// recurse
if (opts && opts.recursive)
process(r)
})
}
process(thread, 0)
// now tally the votes
for (var author in stats.votes) {
var v = stats.votes[author]
if (v === 1) {
stats.voteTally++
}
else if (v === -1) {
stats.voteTally--
}
}
stats.uservote = stats.votes[app.user.id] || 0
return stats
}
exports.getOtherNames = function (profile) {
// todo - replace with ranked names
var name = app.users.names[profile.id] || profile.id
var names = []
function add(n) {
if (n && n !== name && !~names.indexOf(n))
names.push(n)
}
// get 3 of the given or self-assigned names
add(profile.self.name)
for (var k in profile.assignedBy) {
if (names.length >= 3)
break
add(profile.assignedBy[k].name)
}
return names
}
exports.getParentThread = function (mid, cb) {
up()
function up () {
app.ssb.get(mid, function (err, msg) {
if (err)
return cb(err)
// not found? finish here
if (!msg)
return finish()
// root link? go straight to that
if (mlib.link(msg.content.root, 'msg')) {
mid = mlib.link(msg.content.root).link
return finish()
}
// branch link? ascend
if (mlib.link(msg.content.branch, 'msg')) {
mid = mlib.link(msg.content.branch).link
return up()
}
// topmost, finish
finish()
})
}
function finish () {
app.ssb.relatedMessages({ id: mid, count: true, parent: true }, cb)
}
}
exports.decryptThread = function (thread, cb) {
var done = multicb()
if (thread.related)
iterate(thread.related)
done(cb)
function iterate (msgs) {
msgs.forEach(function (msg) {
msg.plaintext = (typeof msg.value.content != 'string')
if (!msg.plaintext)
decrypt(msg)
if (msg.related)
iterate(msg.related)
})
}
function decrypt (msg) {
var cb2 = done()
app.ssb.private.unbox(msg.value.content, function (err, decrypted) {
if (decrypted)
msg.value.content = decrypted
cb2()
})
}
}
exports.getPubStats = function () {
var membersof=0, active=0
app.peers.forEach(function (peer) {
// filter out LAN peers
if (peer.host == 'localhost' || peer.host.indexOf('192.168.') === 0)
return
if (social.follows(peer.key, app.user.id)) {
membersof++
if (peer.time && peer.time.connect && (peer.time.connect > peer.time.attempt) || peer.connected)
active++
}
})
return { membersof: membersof, active: active }
}
exports.getExtLinkName = function (link) {
if (link.name && typeof link.name == 'string')
return link.name
if (link.type) {
var ext = mime.extension(link.type)
if (ext)
return 'untitled.'+ext
}
return 'untitled'
}