├── .gitignore ├── LICENSE ├── README.md ├── apis ├── avatar.js ├── backlinks.js ├── compose.js ├── identity-select.js ├── index.js ├── invites.js ├── mentions.js ├── message.js ├── more.js ├── preview.js ├── progress.js ├── publish.js ├── raw.js ├── search.js └── suggested-recipients.js ├── index.js ├── layout.js ├── mentions.js ├── message-layout.js ├── package.json ├── static ├── favicon.ico └── style.css ├── test ├── to-html.js └── util.js └── translations └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | package-lock.json 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Dominic Tarr 2 | 3 | Permission is hereby granted, free of charge, 4 | to any person obtaining a copy of this software and 5 | associated documentation files (the "Software"), to 6 | deal in the Software without restriction, including 7 | without limitation the rights to use, copy, modify, 8 | merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom 10 | the Software is furnished to do so, 11 | subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice 14 | shall be included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 18 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 20 | ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # yap (yet-another-patch*) 2 | 3 | _yet another_ approach to writing patchapps. 4 | 5 | Intended for server side html rendering (like patchfoo) 6 | with invalidation based lightweight front ends. 7 | 8 | Should also work without javascript! Please post an issue if something does not work 9 | in your preferred browser! 10 | 11 | see also: 12 | * [the state of yap](https://en.wikipedia.org/wiki/Yap) 13 | 14 | ## how to run - git clone 15 | 16 | ``` 17 | # from git-ssb 18 | git clone ssb://%s5UpM/TdwP+Qr2gMgzlAQ/TTBBkKz0gJKlyW7fxGPbk=.sha256 yap 19 | # from github 20 | git clone https://github.com/dominictarr/yap 21 | 22 | # install 23 | cd yap 24 | npm install 25 | # and run 26 | node index.js 27 | ``` 28 | 29 | Navigate to `http://localhost:8005/public` with your favorite web browser (I mostly use firefox, 30 | please post an issue if your browser doesn't work) 31 | 32 | ## cache coherence 33 | 34 | This application is based on the [coherence framework](https://github.com/dominictarr/coherence) 35 | 36 | ## plugins 37 | 38 | this code requires the following plugins 39 | 40 | * ssb-identities (allows switching identities) 41 | * ssb-names (handles avatar names) 42 | * ssb-search (full text search) 43 | 44 | ## known bugs 45 | 46 | * reupdating the page can wipe partially written responses (!!!) 47 | 48 | ## TODO 49 | 50 | implement these all as independent routes 51 | 52 | ### views 53 | 54 | * avatar - done 55 | * message - done 56 | * thread - done 57 | * public - done 58 | * private - done 59 | * channel - done 60 | * friends - done (note: ssb-names is perf bottleneck!) 61 | * set name 62 | * set images 63 | * upload/link blob 64 | * start new thread - done 65 | * follow / unfollow / block / unblock 66 | * gathering 67 | * scry (meeting) 68 | * chess 69 | * move 70 | * game 71 | * ??? 72 | * git-ssb 73 | * repo 74 | * commit 75 | * issue 76 | * pr 77 | 78 | ### forms / actions 79 | 80 | * post reply - done 81 | * recipients (include on every type, so you have instant privacy) - done 82 | * change current identity - done, on tab and on post 83 | * like "yup" button - done 84 | * post (create new thread) 85 | * translations 86 | * attach file 87 | * set name/image on avatar 88 | 89 | 90 | 91 | 92 | 93 | -------------------------------------------------------------------------------- /apis/avatar.js: -------------------------------------------------------------------------------- 1 | var ref = require('ssb-ref') 2 | var toUrl = require('yap-util').toUrl 3 | 4 | module.exports = function (sbot) { 5 | return function (opts, apply) { 6 | //accept feed directly, so you can do map(api.avatar) 7 | if(ref.isFeed(opts)) 8 | opts = {id: opts} 9 | if(ref.isFeed(opts.link)) 10 | opts = {id: opts.link} 11 | var _image = opts.image !== false //defaults to true 12 | var _name = opts.name === true //defaults to false 13 | 14 | if(!opts.id) throw new Error('missing id, had:'+JSON.stringify(opts)) 15 | 16 | return function (cb) { 17 | if(!ref.isFeed(opts.id)) 18 | return cb(new Error('expected valid feed id as id')) 19 | 20 | sbot.names.getImageFor(opts.id, function (err, blobId) { 21 | sbot.names.getSignifier(opts.id, function (err, name) { 22 | cb(null, 23 | ['a.Avatar', 24 | Object.assign( 25 | {href: opts.href || toUrl('patch/public', {author:opts.id})}, 26 | apply.cacheAttrs(toUrl('avatar', opts), opts.id) 27 | ), 28 | _image ? ['img', { 29 | className:'avatar', 30 | src: '/blobs/get/'+blobId, 31 | //getSignifier returns id as name if there isn't a name available. 32 | title: name !== opts.id ? name+'\n'+opts.id : opts.id 33 | }] : '', 34 | _image && _name ? ['br'] : '', 35 | _name ? name : '' 36 | ] 37 | ) 38 | }) 39 | }) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /apis/backlinks.js: -------------------------------------------------------------------------------- 1 | 2 | function backlinks (sbot, id, cb) { 3 | var likes = [], backlinks = [] 4 | pull( 5 | sbot.links({dest: id, values: true}), 6 | pull.drain(function (e) { 7 | var content = e.value.content 8 | var vote = content.vote 9 | if(isObject(vote) && 10 | vote.value == 1 && vote.link == id) 11 | likes.push(e) 12 | else if(content.type == 'post' && isArray(content.mentions)) { 13 | for(var i in content.mentions) { 14 | var m = content.mentions[i] 15 | if(m && m.link == id) { 16 | backlinks.push(e) 17 | return //if something links twice, don't back link it twice 18 | } 19 | } 20 | } 21 | }, function () { 22 | cb(null, likes, backlinks) 23 | }) 24 | ) 25 | } 26 | 27 | //XXX should backlinks be built into the layout? 28 | //i.e. assumed to always be a part of the thing? 29 | module.exports = function (sbot) { 30 | return function (opts) { 31 | return function (cb) { 32 | backlinks(sbot, opts.id, function (err, votes, backlinks) { 33 | 34 | }) 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /apis/compose.js: -------------------------------------------------------------------------------- 1 | var ref = require('ssb-ref') 2 | var Translations = require('../translations') 3 | 4 | var u = require('yap-util') 5 | 6 | module.exports = function (sbot) { 7 | return function (opts, apply, req) { 8 | var context = req.cookies 9 | var id = opts.id 10 | var content = opts.meta || opts.content || {} 11 | content.type = content.type || 'post' 12 | var tr = Translations(context.lang) 13 | return apply('publish', { 14 | id: opts.id, 15 | content: content, 16 | private: opts.private, 17 | inputs: ['textarea', {name: 'content[text]'}], 18 | name: tr('Preview') 19 | }) 20 | } 21 | } 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /apis/identity-select.js: -------------------------------------------------------------------------------- 1 | var cont = require('cont') 2 | var ref = require('ssb-ref') 3 | module.exports = function (sbot) { 4 | return function (opts, apply, req) { 5 | var context = req.cookies 6 | var main = context.id || sbot.id 7 | 8 | var restrict = opts.restrict 9 | //form to switch the main identity 10 | if(opts.main === true) 11 | return ['form', {method: 'POST'}, 12 | ['div.IdentitySelector._menu', 13 | ['input', {type: 'hidden', name: 'type', value: 'identitySelect'}], 14 | ['div.Menu', 15 | apply('avatar', {id: main, image: true, name: false}), 16 | ['ul', 17 | function (cb) { 18 | sbot.identities.list(function (err, ls) { 19 | if(err) return cb(err) 20 | cont.para(ls.filter(function (e) { 21 | return e != context.id 22 | }).map(function (id) { 23 | return function (cb) { 24 | sbot.names.getSignifier(id, function (err, name) { 25 | cb(null, ['li', ['button', {type: 'submit', name: 'id', value: id}, name]]) 26 | }) 27 | } 28 | }))(cb) 29 | }) 30 | } 31 | ] 32 | ] 33 | ] 34 | ] 35 | else {//an input to select id to publish a form from. 36 | 37 | if(restrict) 38 | restrict = [].concat(restrict).map(function (e) { 39 | return 'string' == typeof e ? e : e.link 40 | }).filter(ref.isFeed) 41 | 42 | return function (cb) { 43 | sbot.identities.list(function (err, ls) { 44 | //move main identity to the front 45 | ls.splice(ls.indexOf(main), 1) 46 | ls.unshift(main) 47 | 48 | cb(null, [ 49 | 'select', {name: 'id'}, 50 | ].concat(ls.map(function (id) { 51 | return function (cb) { 52 | sbot.names.getSignifier(id, function (_, name) { 53 | cb(null, ['option', {value: id, selected:id == main ? true : undefined}, name || id.substring(0, 10)]) 54 | }) 55 | } 56 | })) ) 57 | }) 58 | } 59 | } 60 | } 61 | } 62 | 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /apis/index.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = { 3 | avatar: require('./avatar'), 4 | message: require('./message'), 5 | thread: require('./thread'), 6 | public: require('./public'), 7 | private: require('./private'), 8 | preview: require('./preview'), 9 | search: require('./search'), 10 | identitySelect: require('./identity-select'), 11 | // gathering: require('yap-gathering'), 12 | // gatherings: require('yap-gathering/all'), 13 | messageLink: require('./message-link'), 14 | channelLink: require('./channel-link'), 15 | raw: require('./raw'), 16 | messages: require('./messages'), 17 | mentions: require('./mentions'), 18 | suggestedRecipients: require('./suggested-recipients'), 19 | inbox: require('./inbox'), 20 | progress: require('./progress'), 21 | friends: require('./friends'), 22 | compose: require('./compose'), 23 | publish: require('./publish'), 24 | } 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /apis/invites.js: -------------------------------------------------------------------------------- 1 | 2 | // user invites 3 | 4 | // create an invite 5 | 6 | // accept an invite 7 | 8 | // accepting an invite needs some sort of progress bar... 9 | // viewing that invite should cause polling 10 | 11 | // see who invited who 12 | 13 | 14 | -------------------------------------------------------------------------------- /apis/mentions.js: -------------------------------------------------------------------------------- 1 | var u = require('yap-util') 2 | var explain = require('explain-error') 3 | var getMentions = require('../mentions') 4 | var ref = require('ssb-ref') 5 | 6 | module.exports = function (sbot) { 7 | return function (opts, apply) { 8 | return function (cb) { 9 | getMentions(sbot, opts.text, function (err, mentions, ambigious) { 10 | if(err) return cb(explain(err, 'could not load mentions')) 11 | function toPath () { 12 | return '['+[].join.call(arguments, '][')+']' 13 | } 14 | mentions 15 | if(ambigious.length) 16 | ambigious.forEach(function (e) { 17 | mentions.push({name: e[0].name, link: e[0].id}) 18 | }) 19 | 20 | function toPath () { 21 | return '['+[].join.call(arguments, '][')+']' 22 | } 23 | cb(null, [ 24 | mentions.filter(function (e) { return ref.isFeed(e.link) }) 25 | .map(function (opts) { return apply('avatar', opts) }), 26 | u.createHiddenInputs({mentions: mentions}, 'content'), 27 | ambigious.map(function (e, i) { 28 | return [ 29 | 'div.AmbigiousMentions', 30 | e[0].name, 31 | ['input', { 32 | type: 'hidden', 33 | name: toPath('content', 'mentions', mentions.length + i), 34 | value: e[0].name 35 | }], 36 | ['select', { 37 | name: toPath('content', 'mentions', mentions.length + i), 38 | }, 39 | e.map(function (e, i) { 40 | return ['option', !i ? {selected: true, value: e.id} : {value: e.id}, e.id.substring(0, 12)+'...'] 41 | }) 42 | ] 43 | ] 44 | }) 45 | ]) 46 | }) 47 | } 48 | } 49 | } 50 | 51 | -------------------------------------------------------------------------------- /apis/message.js: -------------------------------------------------------------------------------- 1 | var ref = require('ssb-ref') 2 | var niceAgo = require('nice-ago') 3 | var htmlEscape = require('html-escape') 4 | 5 | var u = require('yap-util') 6 | var h = u.h 7 | toUrl = u.toUrl 8 | 9 | module.exports = u.createRenderer(function render (data, apply) { 10 | return apply(['messages', data.value.content.type], data) 11 | }) 12 | -------------------------------------------------------------------------------- /apis/more.js: -------------------------------------------------------------------------------- 1 | var niceAgo = require('nice-ago') 2 | var pull = require('pull-stream') 3 | var u = require('yap-util') 4 | 5 | module.exports = function (sbot) { 6 | return function (opts, apply) { 7 | var _opts = u.createQuery(opts, {limit: 1}) 8 | return function (cb) { 9 | pull( 10 | sbot.query.read(_opts), 11 | pull.collect(function (err, ary) { 12 | //check if there are more messages in this direction 13 | cb(err, ['a'+(ary.length ? '.more' : '.no-more'), 14 | Object.assign( 15 | { 16 | href: opts.href, 17 | title: opts.title 18 | }, 19 | apply.cacheAttrs(apply.toUrl('more', opts), 'more', apply.since) 20 | ), 21 | opts.label 22 | ]) 23 | }) 24 | ) 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /apis/preview.js: -------------------------------------------------------------------------------- 1 | var u = require('yap-util') 2 | var getMentions = require('../mentions') 3 | 4 | function toRecps (ary) { 5 | return ary.map(function (e) { 6 | return 'string' === typeof e ? e : e.link 7 | }).filter(Boolean).join(',') 8 | } 9 | 10 | module.exports = function (sbot) { 11 | return function (opts, apply, req) { 12 | var id = opts.id 13 | var content = opts.content 14 | var tr = require('../translations')(req.cookies.lang) 15 | 16 | //fake message, with enough fields to give to message renderer 17 | var data = { 18 | key: '%................', 19 | value: { 20 | author: id, 21 | content: content 22 | } 23 | } 24 | return ['div.MessagePreview', 25 | apply('message', data), 26 | ['form', 27 | {name: 'publish', method: 'POST'}, 28 | //TODO: enable changing the identity to publish as here 29 | 30 | // for a message already related to something, such as a yup 31 | // or a follow, it is easier to have just a couple of choices 32 | // post the message to your self, or to the poster and yourself. 33 | opts.suggestedRecps ? 34 | apply('suggestedRecipients', opts) : '', 35 | 36 | // for a private post, obviously you'd want to be able to 37 | // include anyone.... TODO that 38 | 39 | ['input', {type: 'hidden', name: 'id', value: data.value.author}], 40 | opts.private ? ['input', {type: 'hidden', name: 'private', value: data.value.author}] : '', 41 | u.createHiddenInputs(data.value.content, 'content'), 42 | apply('preview-confirm', data.value.content), 43 | ['button', {name: 'type', value: 'publish'}, tr('Publish')] 44 | ] 45 | ] 46 | 47 | //TODO: add form to set recipients, and change author. 48 | //press back to edit body of the message again... 49 | //submit takes you to back to the thread page. 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /apis/progress.js: -------------------------------------------------------------------------------- 1 | var u = require('yap-util') 2 | 3 | function round (n, p) { 4 | return Math.round(n * p) / p 5 | } 6 | 7 | function percent (n) { 8 | return (round(n, 1000)*100).toString().substring(0, 4)+'%' 9 | } 10 | 11 | function rate (prog) { 12 | if(prog.target == prog.current) return 1 13 | return (prog.current - prog.start) / (prog.target - prog.start) 14 | } 15 | 16 | module.exports = function (sbot) { 17 | return function (opts, apply) { 18 | return function (cb) { 19 | sbot.progress(function (err, prog) { 20 | var s = '', r = 1 21 | for(var k in prog) 22 | if(prog[k].current <= prog[k].target) { 23 | var _r = rate(prog[k]) 24 | r = Math.min(r, _r) 25 | s += (s ? ', ' : '') + k +': ' + percent(r) 26 | } 27 | cb(null, ['progress', Object.assign( 28 | {value: r, max: 1, title: s}, 29 | apply.cacheAttrs('/progress', 'prog') 30 | ) 31 | ]) 32 | }) 33 | } 34 | } 35 | } 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /apis/publish.js: -------------------------------------------------------------------------------- 1 | var u = require('yap-util') 2 | var Translations = require('../translations') 3 | 4 | module.exports = function (sbot) { 5 | return function (opts, apply, req) { 6 | var context = req.cookies 7 | var id = opts.id 8 | var tr = Translations(context.lang) 9 | 10 | var content = opts.content || opts.meta || {} 11 | return ['form', {name: 'publish', method: 'POST'}, 12 | //selected id to post from. this should 13 | //be a dropdown, that only defaults to context.id 14 | ( 15 | id ? 16 | ['input', { name: 'id', value: id, type: 'hidden'}] : 17 | apply('identitySelect', {restrict: content.recps, main: false}) 18 | ), 19 | ( 20 | opts.private ? ['input', { name: 'private', value: 'true', type: 'hidden'}] : '' 21 | ), 22 | // opts.suggestedRecps ? api('suggestedRecipients', {suggestedRecps: opts.suggestedRecps, content: content}) : '', 23 | //root + branch. not shown in interface. 24 | u.createHiddenInputs(content, 'content'), 25 | opts.inputs, 26 | ['button', {type: 'submit', name: 'type', value:'preview'}, opts.name || tr('Publish')], 27 | // TODO: lookup mentions before publishing. (disable for now) 28 | ] 29 | } 30 | } 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /apis/raw.js: -------------------------------------------------------------------------------- 1 | var u = require('yap-util') 2 | module.exports = u.createRenderer(function (data) { 3 | return ['pre', JSON.stringify(data, null, 2)] 4 | }) 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /apis/search.js: -------------------------------------------------------------------------------- 1 | var pull = require('pull-stream') 2 | var ref = require('ssb-ref') 3 | 4 | function isChannel (c) { 5 | return /^#\w+/.test(c) 6 | } 7 | 8 | function isName (c) { 9 | return /^@\w+/.test(c) 10 | } 11 | 12 | module.exports = function (sbot) { 13 | return function (opts, apply, req) { 14 | console.log("SEARCH", opts) 15 | var tr = require('../translations')(req.cookies.lang) 16 | var query = (opts.query || '').trim() 17 | delete opts.query 18 | opts.limit = opts.limit ? +opts.limit : 10 19 | if(ref.isMsgLink(query)) 20 | return apply('message', {id: query}) 21 | if(ref.isFeed(query)) 22 | return apply('patch/public', Object.assign(opts, {id: query})) 23 | if(isChannel(query)) 24 | return apply('patch/public', Object.assign(opts, {channel: query.substring(1)})) 25 | 26 | if(isName(query)) //is name 27 | return function (cb) { 28 | sbot.names.getSignifies(query.substring(1), function (err, ids) { 29 | if(err) return cb(err) 30 | if(ids.length == 0) 31 | cb(null, ['h1', tr('NoFeedNamed'), query]) 32 | else if(ids.length == 1) 33 | cb(null, apply('patch/public', {author: ids[0].id})) 34 | else 35 | cb(null, ['div', 36 | ['div', query, ' ', tr('MayAlsoBe')].concat(ids.slice(1).map(function (e) { 37 | return apply('avatar', {id: e.id, name: false}) 38 | })), 39 | apply('patch/public', {author: ids[0].id}) 40 | ]) 41 | }) 42 | } 43 | 44 | return pull( 45 | sbot.search.query(Object.assign({query: query}, opts)), 46 | pull.map(function (data) { 47 | console.log('result', data) 48 | return apply('message', data) 49 | }) 50 | ) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /apis/suggested-recipients.js: -------------------------------------------------------------------------------- 1 | var ref = require('ssb-ref') 2 | 3 | module.exports = function (sbot) { 4 | return function (opts, apply, req) { 5 | var content = opts.content 6 | var suggested = opts.suggestedRecps 7 | var tr = require('../translations')(req.cookies.lang) 8 | return ['select', {name: '[content][recps]'}, 9 | //default option is the same recipients. 10 | ['option', {selected: true, value: ''}, content.recps ? tr('ThreadRecipients') : tr('PublicRecpients')], 11 | ['option', {name: '[content][recps]', value: opts.id}, tr('SelfRecipents')], 12 | ref.isFeed(suggested) && [ 13 | 'option', { 14 | name: '[content][recps]', 15 | value: [opts.id, suggested].join(',') 16 | }, 17 | function (cb) { 18 | sbot.names.getSignifier(suggested, function (err, name) { 19 | cb(null, tr('SelfAndRecipients'), ' ', name) 20 | }) 21 | } 22 | ] || '' 23 | ] 24 | } 25 | } 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | 3 | //var log = console.log 4 | //console.log = function () { 5 | // var args = [].slice.call(arguments) 6 | // console.error(new Error('log trace')) 7 | // log.apply(null, args) 8 | //} 9 | 10 | var fs = require('fs') 11 | var path = require('path') 12 | var ref = require('ssb-ref') 13 | var Stack = require('stack') 14 | 15 | //refactor to ditch these things 16 | var nested = require('libnested') 17 | var pull = require('pull-stream') 18 | var URL = require('url') 19 | var QS = require('qs') 20 | var u = require('yap-util') 21 | var toHTML = u.toHTML 22 | var uniq = require('lodash.uniq') 23 | 24 | // middleware 25 | var Logger = require('morgan') 26 | var Emoji = require('emoji-server') 27 | var Static = require('ecstatic') 28 | var BodyParser = require('urlencoded-request-parser') 29 | var FavIcon = require('serve-favicon') 30 | var Coherence = require('coherence-framework') 31 | 32 | //actions may make writes to sbot, or can set things 33 | 34 | require('ssb-client')(function (err, sbot) { 35 | if(err) throw err 36 | sbot.identities.main(function (err, id) { 37 | sbot.id = id 38 | 39 | if(!sbot.id) throw new Error('sbot id missing') 40 | var coherence = Coherence(require('./layout')) 41 | 42 | //core: render an avatar, select 43 | .use('avatar', require('./apis/avatar')(sbot)) 44 | .use('identitySelect', require('./apis/identity-select')(sbot)) 45 | //called by preview (to clarify who you are mentioning) 46 | .list('preview-confirm') 47 | .use('mentions', require('./apis/mentions')(sbot)) 48 | .list('preview-confirm', 'mentions') 49 | .use('messageLayout', require('./message-layout')) 50 | 51 | // .use('messageLink', require('./apis/message-link')(sbot)) 52 | // .use('channelLink', require('./apis/channel-link')(sbot)) 53 | 54 | //render a single message 55 | .use('message', require('./apis/message')(sbot)) 56 | 57 | // .map('link', 'msg', 'message') 58 | 59 | //show how much things there are to do... 60 | .use('progress', require('./apis/progress')(sbot)) 61 | 62 | //core message writing... 63 | .use('preview', require('./apis/preview')(sbot)) 64 | .use('compose', require('./apis/compose')(sbot)) 65 | .use('publish', require('./apis/publish')(sbot)) 66 | 67 | .list('menu') //links to apps, along top of screen. 68 | .use('more', require('./apis/more')(sbot)) 69 | .use('search', require('./apis/search')(sbot)) 70 | 71 | .list('extra') //attached to each message, likes, backlinks, etc 72 | 73 | //patchthreads 74 | // .use('messages/post', require('./apis/messages/post')(sbot)) 75 | // .use('messages/vote', require('./apis/messages/vote')(sbot)) 76 | 77 | .group('patch', require('yap-patch')(sbot)) 78 | .group('gatherings', require('yap-gatherings')(sbot)) 79 | .group('tags', require('yap-tags')(sbot)) 80 | .setDefault('patch/public') 81 | 82 | var actions = { 83 | //note: opts is post body 84 | 85 | //sets id in cookie 86 | identitySelect: function (opts, req, cb) { 87 | var context = req.cookies 88 | context.id = opts.id 89 | cb(null, null, context) 90 | }, 91 | 92 | //sets id in cookie 93 | languageSelect: function (opts, req, cb) { 94 | throw new Error('not implemented yet') 95 | }, 96 | 97 | //theme, in cookie 98 | 99 | publish: function (opts, req, cb) { 100 | if(opts.content.recps === '') 101 | delete opts.content.recps 102 | else if('string' === typeof opts.content.recps) { 103 | opts.content.recps = opts.content.recps.split(',') 104 | } 105 | 106 | if(Array.isArray(opts.content.recps)) 107 | opts.private = true 108 | else if(opts.private && !opts.content.recps) { 109 | opts.content.recps = uniq( 110 | [opts.id] 111 | .concat(opts.content.mentions || []) 112 | .map(function (e) { return ref.isFeed(e) ? e : ref.isFeed(e.link) ? e.link : null }) 113 | .filter(Boolean) 114 | ) 115 | } 116 | 117 | sbot.identities.publishAs(opts, function (err, msg) { 118 | if(err) cb(err) 119 | else cb() 120 | }) 121 | } 122 | } 123 | 124 | require('http').createServer(Stack( 125 | Logger(), 126 | //everything breaks if blobs isn't first, but not sure why? 127 | require('ssb-ws/blobs')(sbot, {prefix: '/blobs'}), 128 | FavIcon(path.join(__dirname, 'static', 'favicon.ico')), 129 | BodyParser(), 130 | /* 131 | some settings we want to store in a cookie: 132 | * current identity 133 | * current language 134 | 135 | stuff that shouldn't be in links. 136 | this makes it possible to share links without including 137 | state people might not want for them. 138 | also: light/dark theme etc 139 | */ 140 | function (req, res, next) { 141 | //just do it this simple way because it works 142 | //I tried the cookie parser middleware but things got weird. 143 | req.cookies = QS.parse(req.headers.cookie||'') || {id: sbot.id} 144 | next() 145 | }, 146 | //handle posts. 147 | function (req, res, next) { 148 | if(req.method == 'GET') return next() 149 | var id = req.cookies.id || sbot.id 150 | var opts = req.body 151 | 152 | // handle preview specially, (to confirm a message) 153 | 154 | if(opts.type === 'preview') { 155 | // TODO: pass opts.id in, and wether this message 156 | // preview should allow recipient selection, or changing id. 157 | // api.preview can set the shape of the message if it likes. 158 | 159 | req.url = '/preview?'+QS.stringify(opts) 160 | return coherence(req, res, next) 161 | } 162 | actions[opts.type](opts, req, function (err, _opts, context) { 163 | if(err) return next(err) 164 | if(context) { 165 | req.cookies = context 166 | res.setHeader('set-Cookie', QS.stringify(context)) 167 | } 168 | /* 169 | After handling the post, redirect to a normal page. 170 | This is a work around for if you hit refresh 171 | and the browser wants to resubmit the POST. 172 | 173 | I think we want to do this for most types, 174 | exception is for preview - in which we return 175 | the same data rendered differently and don't write 176 | to DB at all. 177 | 178 | Should preview be implemented like this too? 179 | */ 180 | res.setHeader('location', req.url) 181 | res.writeHead(303) 182 | res.end() 183 | }) 184 | }, 185 | 186 | Emoji('/img/emoji'), 187 | Static({ 188 | root: path.join(__dirname, 'static'), baseDir: '/static' 189 | }), 190 | coherence 191 | )).listen(8005, 'localhost') 192 | 193 | /* 194 | generic ssb invalidation 195 | if a message links to another, invalidate the other key. 196 | (this will get threads, likes, etc) 197 | if a message links to a feed, invalidate the feed. 198 | 199 | that doesn't cover follows though... but maybe that can be invalidated 200 | as one thing? 201 | */ 202 | 203 | pull( 204 | sbot.createLogStream({live: true, old: false, sync: false, private: true}), 205 | pull.drain(function (data) { 206 | nested.each(data.value.content, function (v) { 207 | if(ref.isMsg(v)) 208 | coherence.invalidate(v, data.ts) 209 | else if(ref.isFeed(v)) { 210 | coherence.invalidate('in:'+v, data.ts) 211 | coherence.invalidate('out:'+data.value.author, data.ts) 212 | } 213 | }) 214 | }) 215 | ) 216 | })}) 217 | -------------------------------------------------------------------------------- /layout.js: -------------------------------------------------------------------------------- 1 | module.exports = function (opts, content, apply, req) { 2 | var tr = require('./translations')(req.cookies.lang) 3 | return ['html', 4 | ['head', {profile: "http://www.w3.org/2005/10/profile"}, 5 | ['meta', {charset: 'UTF-8'}], 6 | ['link', {href: '/static/style.css', rel: 'stylesheet'}], 7 | ['script', {src: apply.scriptUrl}], 8 | ['link', {rel: 'icon', type: 'image/png', href: '/favicon.ico'}], 9 | ], 10 | ['body', 11 | ['div#AppHeader', 12 | ['nav', 13 | ['div', {style: 'display:flex;flex-direction:row'}, 14 | ['h2', tr('AppName')], 15 | ['img', {src: '/favicon.ico'}] 16 | ], 17 | ['div.Menu', apply('menu')], 18 | // ['a', {href: '/public'}, tr('Public')], 19 | // ['a', {href: '/private'}, tr('Private')], 20 | // ['a', {href: '/gatherings'}, 'Gatherings'], 21 | ['form', {method: 'GET', action: '/search'}, 22 | ['input', {type: 'text', name: 'query', placeholder: tr('Search')}], 23 | ['input', {type: 'hidden', name: 'limit', value: 20}], 24 | ['button', {}, tr('Go')] 25 | ], 26 | apply('identitySelect', {main: true}) 27 | ], 28 | apply('progress', {}) 29 | ], 30 | ['div.main', content] 31 | ] 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /mentions.js: -------------------------------------------------------------------------------- 1 | var mentions = require('ssb-mentions') 2 | 3 | /* 4 | when javascript is turned off we can't use auto predict. 5 | this method lets you just enter the names as raw mentions, 6 | @{name} and then looks up the names you might use. 7 | * if it's unambigious it uses that key. 8 | * if there is more than one with that name, but only 9 | one that you call that name (other peers can use the same name) 10 | then it chooses the one you use. 11 | * if there are more than one feed you call that name, 12 | make a dropdown select which one you will mention. 13 | 14 | */ 15 | 16 | function simpleAt (text) { 17 | var rx = /(?:^|\s)(@[\w\-_\d\!]+)/g 18 | var a = [] 19 | var match = rx.exec(text) 20 | while(match) { 21 | a.push({name:match[1].substring(1), link: false}) 22 | match = rx.exec(text) 23 | } 24 | return a 25 | 26 | } 27 | 28 | function _mentions (text, cb) { 29 | var m = mentions(text) 30 | simpleAt(text).forEach(function (e) { 31 | for(var i = 0; i < m.length; i++) 32 | if(m[i].name == e.name) return 33 | m.push(e) 34 | }) 35 | return m 36 | } 37 | 38 | function lookup (sbot, name, cb) { 39 | var mention = {name: name, link: null} 40 | sbot.names.getSignifies(name, function (err, names) { 41 | if(names && names.length) { 42 | names = names.filter(function (e) { 43 | return e.name == name 44 | }) 45 | var first = names[0] 46 | names = names.filter(function (e, i) { 47 | return !i || first.id !== e.id 48 | }) 49 | if(names.length) { 50 | var _names = names.filter(function (e) { 51 | return e.named === name 52 | }) 53 | if(_names.length === 1) 54 | return cb(null, _names[0].id) 55 | } 56 | if(names.length === 1) 57 | cb(null, first.id) 58 | else 59 | cb(null, null, names) 60 | } 61 | }) 62 | } 63 | 64 | module.exports = function (sbot, text, cb) { 65 | var m = _mentions(text), n = 1, ambigious = [] 66 | m.forEach(function (mention, i) { 67 | if(mention.name && mention.link === false) { 68 | n++ 69 | lookup(sbot, mention.name, function (err, link, names) { 70 | if(err) return next(err) 71 | 72 | if(link) mention.link = link 73 | else ambigious.push(names) 74 | 75 | next() 76 | }) 77 | } 78 | }) 79 | 80 | next() 81 | 82 | function next (err) { 83 | if(n>=0 && err) { 84 | n = -1 85 | cb(err) 86 | } 87 | if(--n) return 88 | cb(null, m, ambigious) 89 | } 90 | } 91 | 92 | if(!module.parent) 93 | require('ssb-client')(function (err, sbot) { 94 | if(err) throw err 95 | module.exports(sbot, process.argv[2], function (err, data, ambigious) { 96 | if(err) throw err 97 | console.log(JSON.stringify(data, null, 2)) 98 | console.log(JSON.stringify(ambigious, null, 2)) 99 | sbot.close() 100 | }) 101 | }) 102 | 103 | 104 | -------------------------------------------------------------------------------- /message-layout.js: -------------------------------------------------------------------------------- 1 | var u = require('yap-util') 2 | var ref = require('ssb-ref') 3 | var niceAgo = require('nice-ago') 4 | var toUrl = u.toUrl 5 | 6 | module.exports = function (opts, apply) { 7 | return ['div.Message', 8 | apply.cacheAttrs(toUrl('message', {id: opts.key}), opts.key, apply.since), 9 | ['div.MessageSide', 10 | apply('avatar', {id: opts.author, name: false, image: true}), 11 | ['a', { 12 | href: toUrl('message', {id: opts.id || opts.key}), 13 | title: new Date(opts.ts)+'\n'+opts.key 14 | }, 15 | ''+niceAgo(Date.now(), opts.ts) 16 | ] 17 | ], 18 | ['div.MessageMain', 19 | ['div.MessageMeta', 20 | apply('avatar', {id: opts.author, name: true, image: false}), 21 | ['label.msgId', opts.id], 22 | opts.meta ? opts.meta : '' 23 | ], 24 | ['div.MessageContent', opts.content], 25 | opts.extra && ['div.MessageExtra', apply('extra', {id: opts.key || opts.id, root: opts.root, branch: opts.branch})] 26 | ] 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yap-app", 3 | "description": "Yet Another Patchwork client", 4 | "version": "2.2.1", 5 | "homepage": "https://github.com/dominictarr/yap", 6 | "repository": { 7 | "type": "git", 8 | "url": "git://github.com/dominictarr/yap.git" 9 | }, 10 | "dependencies": { 11 | "coherence-framework": "^1.0.0", 12 | "cont": "^1.0.3", 13 | "ecstatic": "^4.1.0", 14 | "emoji-server": "^1.0.0", 15 | "explain-error": "^1.0.4", 16 | "html-escape": "^2.0.0", 17 | "hyperscript": "^2.0.2", 18 | "libnested": "^1.4.0", 19 | "lodash.uniq": "^4.5.0", 20 | "markdown-summary": "^1.0.3", 21 | "morgan": "^1.9.1", 22 | "nice-ago": "^1.0.1", 23 | "pull-append": "^1.0.0", 24 | "pull-paramap": "^1.2.2", 25 | "pull-stream": "^3.6.9", 26 | "qs": "^6.6.0", 27 | "serve-favicon": "^2.5.0", 28 | "ssb-client": "^4.7.4", 29 | "ssb-markdown": "6", 30 | "ssb-mentions": "^0.5.0", 31 | "ssb-ref": "^2.13.9", 32 | "ssb-sort": "^1.1.0", 33 | "ssb-ws": "^6.0.0", 34 | "stack": "^0.1.0", 35 | "urlencoded-request-parser": "^1.0.1", 36 | "yap-util": "^1.0.0" 37 | }, 38 | "bin": "./index.js", 39 | "devDependencies": { 40 | "tape": "^4.10.1" 41 | }, 42 | "scripts": { 43 | "test": "set -e; for t in test/*.js; do node $t; done" 44 | }, 45 | "author": "Dominic Tarr (http://dominictarr.com)", 46 | "license": "MIT" 47 | } 48 | -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dominictarr/yap/19b041e332711617ff04577681e9a9ac99bbecc0/static/favicon.ico -------------------------------------------------------------------------------- /static/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0px; 3 | } 4 | 5 | .invalid { 6 | background: rgba(255, 250, 250, 0.8) 7 | } 8 | 9 | video, img { max-width: 100%; } 10 | img.avatar { 11 | width: 50px; 12 | height: 50px; 13 | overflow: hidden; /*broken images, will show alt text*/ 14 | background: lightgray; 15 | display: inline-block; /*also make broken images not go over*/ 16 | } 17 | /* avatar, when shown in recipient lists, etc */ 18 | li img.avatar { 19 | width: 30px; 20 | height: 30px; 21 | } 22 | img.emoji { width: 16px; } 23 | 24 | p { 25 | word-wrap: break-word; 26 | } 27 | 28 | div.Markdown { 29 | max-width: 600px; 30 | } 31 | 32 | div.main { 33 | /*padding at the top, to allow space for nav bar */ 34 | padding-top: 100px; 35 | max-width: 800px; margin-left: auto; margin-right: auto; 36 | } 37 | div.Message { 38 | margin: 50px; 39 | display: flex; 40 | flex-direction: row; 41 | } 42 | div.Message>.Avatar { 43 | width: 50px; 44 | height: 50px; 45 | 46 | } 47 | div.MessageSide { 48 | width: 75px; 49 | } 50 | div.MessageMeta { 51 | display: flex; 52 | flex-direction: row; 53 | align-items: flex-end; 54 | justify-content: space-between; 55 | //margin-right: 15px; 56 | margin: 10px; 57 | } 58 | 59 | .MessageContent { 60 | margin: 10px; 61 | } 62 | 63 | .MessageMeta>.Avatar { 64 | display: flex; 65 | flex-direction: column; 66 | align-items: flex-end; 67 | } 68 | .msgId { 69 | overflow: hidden; 70 | font-family: monospace; 71 | color: gray; 72 | width: 75px; 73 | word-break: keep-all; 74 | } 75 | .Recipients { 76 | display: flex; flex-direction: row; 77 | } 78 | 79 | #AppHeader { 80 | position: fixed; 81 | height; 50px; 82 | width: 100%; 83 | background: white; 84 | } 85 | #AppHeader h2 { 86 | margin: 3px; 87 | } 88 | 89 | #AppHeader>nav { 90 | display: flex; 91 | flex-direction: row; 92 | justify-content: space-between; 93 | height: 50px; 94 | } 95 | 96 | progress { 97 | width: 100%; 98 | height: 3px; 99 | position: fixed; 100 | top: 50px; 101 | } 102 | 103 | .IdentitySelector button { 104 | max-width: 100px; 105 | overflow: hidden; 106 | word-break: keep-all; 107 | } 108 | 109 | /* 110 | drop down menu, functional aspects 111 | */ 112 | 113 | ._menu { 114 | margin: 1px; 115 | } 116 | 117 | ._menu ul { 118 | padding: 0px; 119 | margin: 0px; 120 | position: absolute; 121 | /*left: -9999px; 122 | apparently this is better for screen readers, but I couldn't get it to work nicely*/ 123 | display: none; 124 | list-style: none; 125 | } 126 | ._menu li { 127 | // display: block; 128 | position: relative; 129 | } 130 | 131 | ._menu:hover ul { 132 | /* in chrome, -4px margin-top is needed. I don't understand why */ 133 | margin-top: -4px; 134 | display: block; 135 | } 136 | 137 | textarea { 138 | width: 100%; 139 | height: 100px; 140 | } 141 | 142 | .EmbeddedMessage::before { 143 | content: ""; 144 | position: absolute; 145 | background: linear-gradient(180deg, rgba(255,255,255,0) 100px, rgba(255,255,255,1) 200px); 146 | width: 800px; 147 | height: 200px; 148 | pointer-events: none; 149 | } 150 | 151 | .EmbeddedMessage { 152 | max-height: 200px; 153 | 154 | /* 155 | TODO: fade out the extra content. 156 | also make clickable expand/contract. 157 | That might need javascript. 158 | Could do hover with css though, 159 | but I don't like a UI that jumps around, 160 | better explicit interactions. 161 | 162 | background: linear-gradient(0deg, rgba(255,255,255,0), green 175px); 163 | */ 164 | overflow: hidden; 165 | 166 | } 167 | 168 | .no-more { 169 | /* 170 | disable links to previous or next sets 171 | that do not contain anything... 172 | */ 173 | color: grey; 174 | pointer-events: none; 175 | } 176 | -------------------------------------------------------------------------------- /test/to-html.js: -------------------------------------------------------------------------------- 1 | 2 | var pull = require('pull-stream') 3 | var u = require('../util') 4 | 5 | var h = u.h 6 | var toHTML = u.toHTML 7 | 8 | var hs = h('h1', function (cb) { 9 | if(cb == null) throw new Error('cb should not be null') 10 | console.log('CB?', cb) 11 | setTimeout(function () { 12 | cb(null, 'hello world') 13 | }) 14 | }, 15 | h('ol', 16 | pull.values([ 17 | h('li', 'one'), 18 | h('li', 'two'), 19 | h('li', 'three'), 20 | ]) 21 | ) 22 | ) 23 | 24 | console.error(hs) 25 | 26 | toHTML(function (cb) { cb(null, hs) })(function (err, el) { 27 | if(err) throw err 28 | console.log(el, el.outerHTML) 29 | }) 30 | 31 | -------------------------------------------------------------------------------- /test/util.js: -------------------------------------------------------------------------------- 1 | 2 | var tape = require('tape') 3 | 4 | var u = require('../util') 5 | 6 | tape('clean', function (t) { 7 | t.deepEqual(u.cleanOpts({ 8 | private: undefined, 9 | content: {channel: 'foo'} 10 | }), 11 | {content: {channel: 'foo'}} 12 | ) 13 | t.deepEqual(u.cleanOpts({one: 1}), 14 | {one: 1} 15 | ) 16 | t.deepEqual(u.cleanOpts({undef: undefined}), 17 | undefined 18 | ) 19 | 20 | t.end() 21 | }) 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /translations/index.js: -------------------------------------------------------------------------------- 1 | 2 | var langs = { 3 | en: { 4 | LangName: 'English', 5 | AppName: 'Yap', 6 | Publish: 'Publish', 7 | Preview: 'Preview', 8 | AndMore: function (limit) { 9 | return 'and ' + limit + ' more' 10 | }, 11 | FriendsOf: "Friends Of", 12 | Friends: "Friends", 13 | Follows: "Follows", 14 | Followers: "Followers", 15 | NoFeedNamed: 'no feed named:', 16 | ThreadRecipients: 'Thread Recipients', 17 | PublicRecipients: 'Public', 18 | SelfRecipients: 'Note to Self', 19 | SelfAndRecipients: 'Self and', 20 | ThreadRecipients: 'In this thread:', 21 | Like: 'Yup', 22 | Search: 'Search', 23 | Go: 'go' 24 | } 25 | } 26 | 27 | /* 28 | take the word from the given language. 29 | sometimes if a language doesn't have a word 30 | there is another language that is probably intelligible, 31 | (which may not be english!) 32 | translations should specify a preferred fallback language, 33 | (especially useful for when new words are added) 34 | */ 35 | 36 | module.exports = function (lang, default_lang) { 37 | lang = lang || default_lang || 'en' 38 | return function (word) { 39 | console.log('TRANSLATE', word, lang) 40 | var fallback = langs[lang] && langs[lang].fallback 41 | var w = ( 42 | (langs[lang] && langs[lang][word]) || 43 | (langs[fallback] && langs[fallback][word]) || 44 | langs.en[word] || 45 | word 46 | ) 47 | console.log(w) 48 | if('function' === typeof w) 49 | return w.apply(null, [].slice.call(arguments, 1)) 50 | return w 51 | } 52 | } 53 | --------------------------------------------------------------------------------