├── .npmignore ├── .gitignore ├── assets ├── fonts │ ├── quattro │ │ ├── iAWriterQuattroS-Bold.woff │ │ ├── iAWriterQuattroS-Italic.woff │ │ ├── iAWriterQuattroS-Regular.woff │ │ └── iAWriterQuattroS-BoldItalic.woff │ └── IBM-Plex-Mono │ │ ├── IBMPlexMono-Text.woff │ │ └── IBMPlexMono-TextItalic.woff ├── base.html └── styles.css ├── views ├── user-info.js ├── nav.js ├── pagination.js ├── publications.js └── publication.js ├── README.md ├── helpers ├── escape.js ├── caching.js └── ssb-util.js ├── pages ├── error.js ├── connection.js ├── feed.js ├── user.js ├── publication.js ├── preview.js └── publish.js ├── stores ├── publish.js ├── connection.js └── feed.js ├── main.js ├── config.js ├── server.js ├── package.json └── index.js /.npmignore: -------------------------------------------------------------------------------- 1 | index.html 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | index.html 3 | -------------------------------------------------------------------------------- /assets/fonts/quattro/iAWriterQuattroS-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AljoschaMeyer/hacky-art/HEAD/assets/fonts/quattro/iAWriterQuattroS-Bold.woff -------------------------------------------------------------------------------- /assets/fonts/IBM-Plex-Mono/IBMPlexMono-Text.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AljoschaMeyer/hacky-art/HEAD/assets/fonts/IBM-Plex-Mono/IBMPlexMono-Text.woff -------------------------------------------------------------------------------- /assets/fonts/quattro/iAWriterQuattroS-Italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AljoschaMeyer/hacky-art/HEAD/assets/fonts/quattro/iAWriterQuattroS-Italic.woff -------------------------------------------------------------------------------- /assets/fonts/quattro/iAWriterQuattroS-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AljoschaMeyer/hacky-art/HEAD/assets/fonts/quattro/iAWriterQuattroS-Regular.woff -------------------------------------------------------------------------------- /assets/fonts/IBM-Plex-Mono/IBMPlexMono-TextItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AljoschaMeyer/hacky-art/HEAD/assets/fonts/IBM-Plex-Mono/IBMPlexMono-TextItalic.woff -------------------------------------------------------------------------------- /assets/fonts/quattro/iAWriterQuattroS-BoldItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AljoschaMeyer/hacky-art/HEAD/assets/fonts/quattro/iAWriterQuattroS-BoldItalic.woff -------------------------------------------------------------------------------- /views/user-info.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html'); 2 | 3 | module.exports = (state, emit) => { 4 | return html`
5 |
${state.author}
6 | ${state.id} 7 |
`; 8 | }; 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hacky-Art 2 | 3 | I think this counts as an ssb client by now. 4 | 5 | Usage: 6 | 7 | - one-time setup: 8 | - clone this repository 9 | - run `npm install` 10 | - starting the client 11 | - run `npm start` 12 | - enjoy the gallery of images 13 | - click on `Publish` to write your own image to the scuttleverse 14 | -------------------------------------------------------------------------------- /helpers/escape.js: -------------------------------------------------------------------------------- 1 | const escapeKey = key => key.replace(/\//g, '_'); 2 | const deescapeKey = esc => esc.replace(/_/g, '/'); 3 | const escapeMsg = key => key.replace(/\//g, '_').replace('%', '~'); 4 | const deescapeMsg = esc => esc.replace('~', '%').replace(/_/g, '/'); 5 | 6 | module.exports = { 7 | escapeKey, 8 | deescapeKey, 9 | escapeMsg, 10 | deescapeMsg, 11 | }; 12 | -------------------------------------------------------------------------------- /views/nav.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html'); 2 | 3 | const { escapeKey } = require('../helpers/escape'); 4 | 5 | module.exports = (state, emit) => { 6 | return html``; 16 | }; 17 | -------------------------------------------------------------------------------- /pages/error.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html'); 2 | 3 | module.exports = (state, emit) => { 4 | return html` 5 |
6 |

An error occured. There's no need to panic. You can refresh the page (ctrl + r) to try to connect to the scuttleverse again.

7 |

Here's the error in all its glory:

8 |
9 | ${state.connection.err} 10 |
11 |
12 | `; 13 | }; 14 | -------------------------------------------------------------------------------- /pages/connection.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html'); 2 | 3 | const feedPage = require('./feed'); 4 | const errorPage = require('./error'); 5 | 6 | module.exports = (state, emit) => { 7 | switch (state.connection.status) { 8 | case 'connecting': 9 | return html`
Connecting to the scuttleverse...
`; 10 | case 'connected': 11 | return feedPage(state, emit); 12 | case 'error': 13 | return errorPage(state, emit); 14 | default: 15 | return html`You should not see this, the app devs made an error.`; 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /views/pagination.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html'); 2 | 3 | module.exports = (state, emit) => { 4 | return html``; 9 | 10 | function onclickFirst() { 11 | emit('paginate:first', state.author); 12 | } 13 | 14 | function onclickPrev() { 15 | emit('paginate:prev', state.author); 16 | } 17 | 18 | function onclickNext() { 19 | emit('paginate:next', state.author); 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /pages/feed.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html'); 2 | 3 | const nav = require('../views/nav'); 4 | const publications = require('../views/publications'); 5 | 6 | module.exports = (state, emit) => { 7 | if (state.main.mainFeed.loading) { 8 | emit('feed:load', {}); 9 | 10 | return html` 11 | ${nav({ me: state.ssb ? state.ssb.id : undefined })} 12 | Loading... 13 | `; 14 | } else { 15 | return html` 16 | ${nav({ me: state.ssb.id })} 17 | ${publications({ 18 | msgs: state.main.mainFeed.msgs, 19 | authorCache: state.main.authorCache, 20 | imgCache: state.main.imgCache, 21 | ssb: state.ssb, 22 | author: undefined, 23 | }, emit)} 24 | `; 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /assets/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /stores/publish.js: -------------------------------------------------------------------------------- 1 | const client = require('ssb-client'); 2 | 3 | module.exports = (state, emitter, app) => { 4 | state.publishData = {}; 5 | state.publishInput = {}; 6 | 7 | emitter.on('DOMContentLoaded', () => { 8 | emitter.on('preview', data => { 9 | state.preview = data; 10 | emitter.emit('pushState', '/preview'); 11 | }); 12 | 13 | emitter.on('preview image added', data => { 14 | state.publishInput.blob = data; 15 | emitter.emit('pushState', '/publish'); 16 | }); 17 | 18 | emitter.on('preview image removed', data => { 19 | state.publishInput.blob = null; 20 | emitter.emit('pushState', '/publish'); 21 | }); 22 | 23 | emitter.on('publishData', data => { 24 | state.publishInput= {}; 25 | state.publishData = data; 26 | }); 27 | 28 | emitter.on('publishError', err => { 29 | state.publishError = err; 30 | }); 31 | }); 32 | }; 33 | -------------------------------------------------------------------------------- /views/publications.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html'); 2 | 3 | const publication = require('./publication'); 4 | const pagination = require('./pagination'); 5 | 6 | const { getAuthor, getImg } = require('../helpers/caching'); 7 | 8 | module.exports = (state, emit) => { 9 | return html` 27 | ${pagination({ author: state.author }, emit)} 28 | `; 29 | }; 30 | -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | const choo = require('choo'); 2 | const html = require('choo/html'); 3 | 4 | // Pages are the top-level views that are associated with routes. 5 | const connectionPage = require('./pages/connection'); 6 | const feedPage = require('./pages/feed'); 7 | const publishPage = require('./pages/publish'); 8 | const previewPage = require('./pages/preview'); 9 | const userPage = require('./pages/user'); 10 | const publicationPage = require('./pages/publication'); 11 | 12 | const connectionStore = require('./stores/connection'); 13 | const feedStore = require('./stores/feed'); 14 | const publishStore = require('./stores/publish'); 15 | 16 | const app = choo(); 17 | 18 | app.use(connectionStore); 19 | app.use(feedStore); 20 | app.use(publishStore); 21 | 22 | app.route('/', feedPage); 23 | app.route('/connection', connectionPage); 24 | app.route('/publish', publishPage); 25 | app.route('/preview', previewPage); 26 | app.route('/user/:user', userPage); 27 | app.route('/publication/:publication', publicationPage); 28 | 29 | app.mount('body'); 30 | -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | const Config = require('ssb-config/inject'); 2 | const Path = require('path'); 3 | const merge = require('lodash.merge'); 4 | 5 | config = Config(process.env.ssb_appname || 'ssb'); 6 | 7 | addSockets(config); 8 | fixLocalhost(config); 9 | 10 | module.exports = config; 11 | 12 | function addSockets (config) { 13 | if (process.platform === 'win32') { 14 | return; 15 | } 16 | 17 | const pubkey = config.keys.id.slice(1).replace(`.${config.keys.curve}`, ''); 18 | merge( 19 | config, 20 | { 21 | connections: { 22 | incoming: { unix: [{ scope: 'device', transform: 'noauth', server: true }] } 23 | }, 24 | remote: `unix:${Path.join(config.path, 'socket')}:~noauth:${pubkey}` // overwrites 25 | } 26 | ); 27 | } 28 | 29 | function fixLocalhost (config) { 30 | if (process.platform !== 'win32') { 31 | return; 32 | } 33 | 34 | // without this host defaults to :: which doesn't work on windows 10? 35 | config.connections.incoming.net[0].host = '127.0.0.1'; 36 | config.connections.incoming.ws[0].host = '127.0.0.1'; 37 | config.host = '127.0.0.1'; 38 | } 39 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const Path = require('path'); 3 | const electron = require('electron'); 4 | 5 | const createSbot = require('ssb-server') 6 | .use(require('ssb-server/plugins/master')) 7 | .use(require('ssb-server/plugins/logging')) 8 | .use(require('ssb-server/plugins/unix-socket')) 9 | .use(require('ssb-server/plugins/no-auth')) 10 | .use(require('ssb-server/plugins/onion')) 11 | .use(require('ssb-server/plugins/local')) 12 | 13 | .use(require('ssb-gossip')) 14 | .use(require('ssb-replicate')) 15 | .use(require('ssb-friends')) 16 | .use(require('ssb-ebt')) 17 | 18 | .use(require('ssb-blobs')) 19 | .use(require('ssb-ws')) 20 | 21 | .use(require('ssb-social-index')({ 22 | namespace: 'about', 23 | type: 'about', 24 | destField: 'about' 25 | })) 26 | .use(require('ssb-backlinks')) 27 | .use(require('ssb-query')); 28 | 29 | const config = require('./config'); 30 | 31 | const sbot = createSbot(config); 32 | const manifest = sbot.getManifest(); 33 | fs.writeFileSync(Path.join(config.path, 'manifest.json'), JSON.stringify(manifest)); 34 | electron.ipcRenderer.send('server-started'); 35 | -------------------------------------------------------------------------------- /stores/connection.js: -------------------------------------------------------------------------------- 1 | const client = require('ssb-client'); 2 | 3 | module.exports = (state, emitter, app) => { 4 | emitter.on('DOMContentLoaded', () => { 5 | state.connection = {}; 6 | 7 | emitter.on('connection:connecting', () => { 8 | state.connection.status = 'connecting'; 9 | emitter.emit('pushState', '/connection'); 10 | 11 | const config = require('../config'); 12 | client(config.keys, config, (err, server) => { 13 | if (err) { 14 | emitter.emit('connection:error', err); 15 | } else { 16 | emitter.emit('connection:connected', server); 17 | } 18 | }); 19 | }); 20 | 21 | emitter.on('connection:connected', ssb => { 22 | state.ssb = ssb; // give the app access to the ssb client connection 23 | state.connection.status = 'connected'; 24 | emitter.emit('pushState', '/'); // navigate to main page 25 | }); 26 | 27 | emitter.on('connection:error', err => { 28 | state.connection.status = 'error'; 29 | state.connection.err = err; 30 | emitter.emit('pushState', '/connection') 31 | }); 32 | 33 | emitter.emit('connection:connecting'); 34 | }); 35 | }; 36 | -------------------------------------------------------------------------------- /pages/user.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html'); 2 | 3 | const nav = require('../views/nav'); 4 | const userInfo = require('../views/user-info'); 5 | const publications = require('../views/publications'); 6 | 7 | const { pageSize, getOrCreateFeed, getAuthor } = require('../helpers/caching'); 8 | const { deescapeKey } = require('../helpers/escape'); 9 | 10 | module.exports = (state, emit) => { 11 | const user = deescapeKey(state.params.user); 12 | const feedState = getOrCreateFeed(pageSize, state, user); 13 | const author = getAuthor(state.main.authorCache, state.ssb, emit, user); 14 | 15 | if (feedState.loading) { 16 | emit('feed:load', { author: user }); 17 | 18 | return html` 19 | ${nav({ me: state.ssb ? state.ssb.id : undefined })} 20 | ${userInfo({ id: user }, emit)} 21 |

${user}

22 | Loading... 23 | `; 24 | } else { 25 | return html` 26 | ${nav({ me: state.ssb.id })} 27 | ${userInfo({ id: user, author }, emit)} 28 | ${publications({ 29 | msgs: feedState.msgs, 30 | authorCache: state.main.authorCache, 31 | imgCache: state.main.imgCache, 32 | ssb: state.ssb, 33 | author: user, 34 | }, emit)} 35 | `; 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /views/publication.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html'); 2 | 3 | const { escapeKey, escapeMsg } = require('../helpers/escape'); 4 | 5 | module.exports = (state, emit) => { 6 | return html`
7 | ${ 8 | state.blob ? 9 | html` 14 | 15 | ` : 16 | html`
Image loading...
` 17 | } 18 | 19 |
20 | ${ 21 | typeof state.caption === 'string' && state.caption.length > 0 ? 22 | html`
${state.caption}
` : 23 | '' 24 | } 25 |
26 |
27 |
28 | ${ 29 | typeof state.title === 'string' && state.title.length > 0 ? 30 | html`

${state.title}

` : 31 | '' 32 | } 33 | ${state.author} 34 | ${ 35 | typeof state.description === 'string' && state.description.length > 0 ? 36 | html`
${state.description}
` : 37 | '' 38 | } 39 |
40 | ` 41 | }; 42 | -------------------------------------------------------------------------------- /pages/publication.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html'); 2 | 3 | const nav = require('../views/nav'); 4 | const publication = require('../views/publication'); 5 | 6 | const { pageSize, getOrCreateFeed, getAuthor, getMsg, getImg } = require('../helpers/caching'); 7 | const { deescapeMsg } = require('../helpers/escape'); 8 | 9 | module.exports = (state, emit) => { 10 | const msgId = deescapeMsg(state.params.publication); 11 | const msg = getMsg(state.main.msgCache, state.ssb, emit, msgId); 12 | 13 | if (!msg) { 14 | return html` 15 | ${nav({ me: state.ssb ? state.ssb.id : undefined })} 16 |

${msgId}

17 | Loading... 18 | `; 19 | } else { 20 | const author = getAuthor(state.main.authorCache, state.ssb, emit, msg.author);; 21 | const blob = new Blob(getImg(state.main.imgCache, state.ssb, emit, msg.content.img)); 22 | 23 | return html` 24 | ${nav({ me: state.ssb.id })} 25 |
${publication({ 26 | timestamp: msg.timestamp, 27 | title: msg.content.title, 28 | description: msg.content.description, 29 | caption: msg.content.caption, 30 | size: msg.content.size, 31 | msgId, 32 | author, 33 | authorId: msg.author, 34 | blob 35 | }, emit)}
36 | `; 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hacky-art", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "electron index.js -- --title HackyArt", 9 | "postinstall": "npm run rebuild", 10 | "rebuild": "cross-script npm rebuild --runtime=electron \"--target=$(electron -v)\" \"--abi=$(electron --abi)\" --disturl=https://atom.io/download/atom-shell" 11 | }, 12 | "author": "Aljoscha Meyer", 13 | "license": "AGPL-3.0", 14 | "dependencies": { 15 | "choo": "^6.13.1", 16 | "electron-default-menu": "^1.0.1", 17 | "electron-window-state": "^5.0.3", 18 | "lodash.merge": "^4.6.1", 19 | "node-abi": "^2.7.1", 20 | "pull-filereader": "^1.0.1", 21 | "pull-stream": "^3.6.9", 22 | "quick-lru": "^2.0.0", 23 | "ssb-backlinks": "^0.7.3", 24 | "ssb-blobs": "^1.1.13", 25 | "ssb-client": "^4.6.0", 26 | "ssb-config": "^3.2.5", 27 | "ssb-ebt": "^5.4.1", 28 | "ssb-friends": "^4.0.0", 29 | "ssb-gossip": "^1.0.6", 30 | "ssb-keys": "^7.1.5", 31 | "ssb-query": "^2.3.0", 32 | "ssb-replicate": "^1.1.0", 33 | "ssb-server": "^14.1.6", 34 | "ssb-social-index": "^1.0.0", 35 | "ssb-ws": "^6.0.0" 36 | }, 37 | "devDependencies": { 38 | "cross-script": "^1.0.5", 39 | "electron": "^2.0.17" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /pages/preview.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html'); 2 | const pull = require('pull-stream'); 3 | const once = require('pull-stream/sources/once'); 4 | const pullFileReader = require('pull-filereader'); 5 | 6 | const nav = require('../views/nav'); 7 | const publication = require('../views/publication'); 8 | 9 | const { getAuthor } = require('../helpers/caching'); 10 | 11 | module.exports = (state, emit) => { 12 | const preview = state.preview; 13 | return html` 14 |
${publication({ 15 | timestamp: Date.now(), 16 | title: preview.title, 17 | description: preview.description, 18 | caption: preview.caption, 19 | size: preview.imgFile.size, 20 | author: getAuthor(preview.authorCache, preview.ssb, emit, preview.ssb.id), 21 | authorId: preview.ssb.id, 22 | blob: preview.imgFile, 23 | }, emit)}
24 |
25 | 26 | 27 |
28 | `; 29 | 30 | function oncancel() { 31 | emit('publishData', { 32 | title: preview.title, 33 | description: preview.description, 34 | caption: preview.caption, 35 | }); 36 | emit('pushState', '/publish'); 37 | } 38 | 39 | function onconfirm() { 40 | pull( 41 | pullFileReader(preview.imgFile), 42 | state.ssb.blobs.add((err, hash) => { 43 | if (err) { 44 | throw err; 45 | } 46 | 47 | state.ssb.publish({ 48 | type: 'tamaki:publication', 49 | img: hash, 50 | title: preview.title, 51 | description: preview.description, 52 | caption: preview.caption, 53 | imgSize: preview.imgSize, 54 | }, err => { 55 | if (err) { 56 | emit('publishData', { 57 | title: preview.title, 58 | description: preview.description, 59 | caption: preview.caption, 60 | }); 61 | emit('publishError', err) 62 | emit('pushState', '/publish'); 63 | } else { 64 | emit('publishError'); // clears the error from the state 65 | emit('publishData', {}); 66 | emit('pushState', '/'); 67 | } 68 | }); 69 | }) 70 | ); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /helpers/caching.js: -------------------------------------------------------------------------------- 1 | const { getBlob, newPaginatedQuery } = require('./ssb-util'); 2 | 3 | const pageSize = 30; 4 | 5 | const newFeed = (pageSize, author) => ({ 6 | loading: true, 7 | msgs: undefined, 8 | author: author, 9 | paginatedQuery: newPaginatedQuery(pageSize, author), 10 | }); 11 | 12 | const getFeed = (state, author) => { 13 | if (author) { 14 | return state.main.authorFeeds.get(author); 15 | } else { 16 | return state.main.mainFeed; 17 | } 18 | }; 19 | 20 | const getOrCreateFeed = (pageSize, state, author) => { 21 | const cached = getFeed(state, author); 22 | 23 | if (cached) { 24 | return cached; 25 | } else { 26 | const feed = newFeed(pageSize, author); 27 | state.main.authorFeeds.set(author, feed); 28 | return feed; 29 | } 30 | }; 31 | 32 | const getAuthor = (cache, ssb, emit, id) => { 33 | const cached = cache.get(id); 34 | if (cached) { 35 | return cached; 36 | } else { 37 | ssb.about.socialValue({ key: 'name', dest: id }, (err, name) => { 38 | if (err) { 39 | throw err; 40 | } 41 | 42 | // tell the app when a name has been loaded 43 | emit('author:loaded', { 44 | id, 45 | name, 46 | }); 47 | }); 48 | 49 | return id; 50 | } 51 | }; 52 | 53 | const getImg = (cache, ssb, emit, id) => { 54 | const cached = cache.get(id); 55 | if (cached) { 56 | return cached; 57 | } else { 58 | getBlob(ssb, id, (err, blob) => { 59 | if (err) { 60 | throw err; 61 | } 62 | 63 | // tell the app when a blob has been loaded 64 | emit('img:loaded', { 65 | id, 66 | blob, 67 | }); 68 | }); 69 | 70 | return undefined; 71 | } 72 | }; 73 | 74 | const getMsg = (cache, ssb, emit, id) => { 75 | const cached = cache.get(id); 76 | if (cached) { 77 | return cached; 78 | } else { 79 | ssb.get(id, (err, msg) => { 80 | if (err) { 81 | throw err; 82 | } 83 | 84 | // tell the app when a msg has been loaded 85 | emit('msg:loaded', { 86 | id, 87 | msg, 88 | }); 89 | }); 90 | 91 | return undefined; 92 | } 93 | }; 94 | 95 | module.exports = { 96 | pageSize, 97 | newFeed, 98 | getFeed, 99 | getOrCreateFeed, 100 | getAuthor, 101 | getImg, 102 | getMsg, 103 | }; 104 | -------------------------------------------------------------------------------- /stores/feed.js: -------------------------------------------------------------------------------- 1 | const pull = require('pull-stream'); 2 | const Lru = require('quick-lru'); 3 | 4 | const { pageSize, newFeed, getFeed, getOrCreateFeed } = require('../helpers/caching'); 5 | 6 | module.exports = (state, emitter) => { 7 | state.main = { 8 | imgCache: new Lru({maxSize: 120}), 9 | authorCache: new Lru({maxSize: 120}), 10 | msgCache: new Lru({maxSize: 24}), 11 | mainFeed: newFeed(pageSize), 12 | authorFeeds: new Lru({maxSize: 3}), // map from author ids to objects like `mainFeed` 13 | }; 14 | 15 | emitter.on('DOMContentLoaded', () => { 16 | emitter.on('feed:load', opts => { 17 | const feed = getOrCreateFeed(pageSize, state, opts.author); 18 | 19 | feed.loading = true; 20 | feed.paginatedQuery.run(state.ssb, (err, msgs) => { 21 | if (err) { 22 | throw err; 23 | } 24 | 25 | // tell the app when loading is done 26 | emitter.emit('feed:loaded', { 27 | msgs, 28 | author: opts.author, 29 | }); 30 | }); 31 | }); 32 | 33 | emitter.on('paginate:first', author => { 34 | getFeed(state, author).paginatedQuery.reset(); 35 | emitter.emit('feed:load', { author }); 36 | }); 37 | 38 | emitter.on('paginate:prev', author => { 39 | getFeed(state, author).paginatedQuery.prev(); 40 | emitter.emit('feed:load', { author }); 41 | }); 42 | 43 | emitter.on('paginate:next', author => { 44 | getFeed(state, author).paginatedQuery.next(); 45 | emitter.emit('feed:load', { author }); 46 | }); 47 | 48 | // transition from loading screen to displaying the feed 49 | emitter.on('feed:loaded', ({ msgs, author }) => { 50 | const feed = getOrCreateFeed(pageSize, state, author); 51 | 52 | feed.loading = false; 53 | feed.msgs = msgs; 54 | emitter.emit('render'); 55 | }); 56 | 57 | // display images as their blobs become available 58 | emitter.on('img:loaded', ({id, blob}) => { 59 | state.main.imgCache.set(id, blob); 60 | emitter.emit('render'); 61 | }); 62 | 63 | // display human-readable author names as they become available 64 | emitter.on('author:loaded', ({id, name}) => { 65 | state.main.authorCache.set(id, `@${name}`); 66 | emitter.emit('render'); 67 | }); 68 | 69 | emitter.on('msg:loaded', ({id, msg}) => { 70 | state.main.msgCache.set(id, msg); 71 | emitter.emit('render'); 72 | }); 73 | }); 74 | }; 75 | -------------------------------------------------------------------------------- /helpers/ssb-util.js: -------------------------------------------------------------------------------- 1 | const pull = require('pull-stream'); 2 | 3 | const getBlob = (ssb, id, cb) => { 4 | ssb.blobs.want(id, err => { 5 | if (err) { 6 | return cb(err); 7 | } 8 | 9 | pull( 10 | ssb.blobs.get(id), 11 | pull.collect((err, blob) => { 12 | if (err) { 13 | return cb(err); 14 | } 15 | 16 | return cb(null, blob); 17 | }) 18 | ); 19 | }); 20 | }; 21 | 22 | // Return an object that allows paginated queries for `tamaki:post` messages, 23 | // sorted by claimed timestamp. If `author` not undefined, only messages by that 24 | // author (public key) are returned. 25 | // 26 | // This returns an object with four methods: 27 | // - `run`: takes a callback and calls it with the result of the current query 28 | // - `next`: advance the query that will be executed by `run` by one page 29 | // - `prev`: decrease the query that will be executed by `run` by one page 30 | // - `reset`: move the query back to the beginning of the log 31 | const newPaginatedQuery = (pageSize, author) => { 32 | const defaultQuery = () => ({ 33 | limit: pageSize, 34 | reverse: true, 35 | query: [ 36 | { 37 | $filter: { 38 | value: { 39 | content: { type: 'tamaki:publication' }, 40 | author, 41 | timestamp: { $gte: 0 } 42 | } 43 | } 44 | } 45 | ] 46 | }); 47 | 48 | let q; // the ssb-query query object 49 | let currentTimestamp; // ts of the first msg on the page 50 | let nextTimestamp; // ts of the last msg on the page 51 | let prevTimestampStack; // stack of previous `currentTimestamp`s 52 | reset(); 53 | 54 | function run(ssb, cb) { 55 | return pull( 56 | ssb.query.read(q), 57 | pull.collect((err, msgs) => { 58 | if (err) { 59 | cb(err); 60 | } 61 | 62 | if (msgs.length > 0) { 63 | currentTimestamp = msgs[0].timestamp; 64 | nextTimestamp = msgs[msgs.length - 1].timestamp; 65 | return cb(null, msgs.map(msg => { 66 | const ret = msg.value; 67 | ret.msgId = msg.key; 68 | return ret; 69 | })); 70 | } else { 71 | // query returned no results 72 | if (q.query[0].$filter.value.timestamp.$gte === 0) { 73 | // There are no query results at all. 74 | return cb(null, msgs); 75 | } else { 76 | // We reached the end of pagination, paginate back and try again. 77 | prev(); 78 | return run(ssb, cb); 79 | } 80 | } 81 | }) 82 | ); 83 | } 84 | 85 | function next() { 86 | q.query[0].$filter.value.timestamp = { $lt: nextTimestamp }; 87 | prevTimestampStack.push(currentTimestamp); 88 | } 89 | 90 | function prev() { 91 | const ts = prevTimestampStack.pop(); 92 | 93 | if (prevTimestampStack.length === 0) { 94 | reset(); 95 | } else { 96 | q.query[0].$filter.value.timestamp = { $lte: ts }; 97 | } 98 | } 99 | 100 | function reset() { 101 | q = defaultQuery(); 102 | currentTimestamp = undefined; 103 | nextTimestamp = 0; 104 | prevTimestampStack = []; 105 | } 106 | 107 | return { 108 | run, 109 | next, 110 | prev, 111 | reset, 112 | }; 113 | }; 114 | 115 | module.exports = { 116 | getBlob, 117 | newPaginatedQuery, 118 | }; 119 | -------------------------------------------------------------------------------- /pages/publish.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html'); 2 | const pull = require('pull-stream'); 3 | const once = require('pull-stream/sources/once'); 4 | const pullFileReader = require('pull-filereader'); 5 | 6 | const nav = require('../views/nav'); 7 | 8 | module.exports = (state, emit) => { 9 | return html` 10 | ${nav({ me: state.ssb.id })} 11 | ${ 12 | state.publishError 13 | ? html`
14 | ${ 15 | state.publishError.message === 'encoded message must not be larger than 8192 bytes' ? 16 | html`The description, caption or title is too large, you'll need to cut some words.` : 17 | html`An unexpected error occured.${JSON.stringify(state.publishError)}` 18 | } 19 |
` 20 | : html`` 21 | } 22 | ${state.publishInput.blob 23 | // When an image file has been selected, display the add'l details form 24 | ? html` 25 |
26 |

Add Additional Details

27 |
28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 |
43 |
44 | ` 45 | // Otherwise, display the image file selector 46 | : html` 47 |
48 | 49 | 56 |
57 | ` 58 | } 59 | `; 60 | 61 | function onChange (event) { 62 | event.preventDefault(); 63 | const imgInput = document.querySelector('#imgInput'); 64 | const imgFile = imgInput.files[0]; 65 | emit('preview image added', imgFile); 66 | } 67 | 68 | function removeImage () { 69 | event.preventDefault(); 70 | emit('preview image removed'); 71 | } 72 | 73 | function onsubmit(event) { 74 | event.preventDefault(); 75 | 76 | const imgInput = document.querySelector('#imgInput'); 77 | let imgFile = imgInput.files[0]; 78 | 79 | const imgTitle = document.querySelector('#imgTitle'); 80 | const title = imgTitle.value; 81 | 82 | const imgDesc = document.querySelector('#imgDesc'); 83 | const description = imgDesc.value; 84 | 85 | const imgCaption = document.querySelector('#imgCaption'); 86 | const caption = imgCaption.value; 87 | 88 | if (!imgFile) { 89 | imgFile = state.publishInput.blob; 90 | } 91 | 92 | emit('preview', { 93 | imgFile, 94 | title, 95 | description, 96 | caption, 97 | authorCache: state.main.authorCache, 98 | ssb: state.ssb, 99 | }); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const defaultMenu = require('electron-default-menu'); 2 | const WindowState = require('electron-window-state'); 3 | const electron = require('electron'); 4 | const Menu = electron.Menu; 5 | const Path = require('path'); 6 | 7 | const windows = {}; 8 | let quitting = false; 9 | 10 | electron.app.on('ready', () => { 11 | startMenus(); 12 | 13 | startBackgroundProcess(); 14 | // wait until server has started before opening main window 15 | electron.ipcMain.once('server-started', function (ev, config) { 16 | openMainWindow(); 17 | }); 18 | 19 | electron.app.on('before-quit', function () { 20 | quitting = true; 21 | }); 22 | 23 | electron.app.on('activate', function (e) { 24 | // reopen the app when dock icon clicked on macOS 25 | if (windows.main) { 26 | windows.main.show(); 27 | } 28 | }); 29 | 30 | // allow inspecting of background process 31 | electron.ipcMain.on('open-background-devtools', function (ev, config) { 32 | if (windows.background) { 33 | windows.background.webContents.openDevTools({ detach: true }); 34 | } 35 | }); 36 | }) 37 | 38 | function startBackgroundProcess () { 39 | if (windows.background) { 40 | return; 41 | } 42 | 43 | windows.background = openWindow(Path.join(__dirname, 'server.js'), { 44 | title: 'hacky-art-server', 45 | show: false, 46 | connect: false, 47 | width: 150, 48 | height: 150, 49 | center: true, 50 | fullscreen: false, 51 | fullscreenable: false, 52 | maximizable: false, 53 | minimizable: false, 54 | resizable: false, 55 | skipTaskbar: true, 56 | useContentSize: true 57 | }); 58 | } 59 | 60 | function openMainWindow () { 61 | if (windows.main) { 62 | return; 63 | } 64 | 65 | const windowState = WindowState({ 66 | defaultWidth: 1024, 67 | defaultHeight: 768, 68 | }); 69 | windows.main = openWindow(Path.join(__dirname, 'main.js'), { 70 | title: 'HackyArt', 71 | show: true, 72 | x: windowState.x, 73 | y: windowState.y, 74 | minWidth: 800, 75 | width: windowState.width, 76 | height: windowState.height, 77 | autoHideMenuBar: true, 78 | frame: !process.env.FRAME, 79 | backgroundColor: '#FFF', 80 | }); 81 | windowState.manage(windows.main); 82 | windows.main.setSheetOffset(40); 83 | windows.main.on('close', function (e) { 84 | if (!quitting && process.platform === 'darwin') { 85 | e.preventDefault(); 86 | windows.main.hide(); 87 | } 88 | }); 89 | windows.main.on('closed', function () { 90 | windows.main = null; 91 | if (process.platform !== 'darwin') { 92 | electron.app.quit(); 93 | } 94 | }); 95 | } 96 | 97 | function openWindow (path, opts) { 98 | const window = new electron.BrowserWindow(opts); 99 | 100 | window.webContents.on('dom-ready', function () { 101 | window.webContents.executeJavaScript(` 102 | require(${JSON.stringify(path)}) 103 | `); 104 | }) 105 | 106 | window.webContents.on('will-navigate', function (e, url) { 107 | e.preventDefault(); 108 | electron.shell.openExternal(url); 109 | }); 110 | 111 | window.webContents.on('new-window', function (e, url) { 112 | e.preventDefault(); 113 | electron.shell.openExternal(url); 114 | }); 115 | 116 | window.loadURL('file://' + Path.join(__dirname, 'assets', 'base.html')); 117 | return window; 118 | } 119 | 120 | function startMenus () { 121 | const menu = defaultMenu(electron.app, electron.shell); 122 | const view = menu.find(x => x.label === 'View'); 123 | view.submenu = [ 124 | { role: 'reload' }, 125 | { role: 'toggledevtools' }, 126 | { type: 'separator' }, 127 | { role: 'resetzoom' }, 128 | { role: 'zoomin' }, 129 | { role: 'zoomout' }, 130 | { type: 'separator' }, 131 | { role: 'togglefullscreen' } 132 | ]; 133 | const win = menu.find(x => x.label === 'Window'); 134 | win.submenu = [ 135 | { role: 'minimize' }, 136 | { role: 'zoom' }, 137 | { role: 'close', label: 'Close Window', accelerator: 'CmdOrCtrl+Shift+W' }, 138 | { type: 'separator' }, 139 | { 140 | label: 'Close Tab', 141 | accelerator: 'CmdOrCtrl+W', 142 | click () { 143 | windows.main.webContents.send('closeTab') 144 | } 145 | }, 146 | { type: 'separator' }, 147 | { role: 'front' } 148 | ]; 149 | 150 | Menu.setApplicationMenu(Menu.buildFromTemplate(menu)); 151 | } 152 | -------------------------------------------------------------------------------- /assets/styles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --primary-color: HSL(83, 76%, 90%); 3 | --secondary-color: HSL(208, 25%, 22%); 4 | --accent-color: HSL(40, 96%, 80%); 5 | --primary_text: 'ia Writer Quattro'; 6 | --secondary_text: 'IBM-Plex-Mono'; 7 | } 8 | @font-face { 9 | font-family: 'iA Writer Quattro'; 10 | src: url('fonts/quattro/iAWriterQuattroS-Regular.woff'); 11 | font-weight: normal; 12 | } 13 | 14 | @font-face { 15 | font-family: 'iA Writer Quattro'; 16 | src: url(fonts/quattro/iAWriterQuattroS-Bold.woff); 17 | font-weight: 700; 18 | } 19 | 20 | @font-face { 21 | font-family: 'iA Writer Quattro'; 22 | src: url(fonts/quattro/iAWriterQuattroS-Italic.woff); 23 | font-weight: normal; 24 | font-style: italic; 25 | } 26 | 27 | @font-face { 28 | font-family: 'IBM-Plex-Mono'; 29 | src: url(fonts/IBM-Plex-Mono/IBMPlexMono-TextItalic.woff); 30 | font-weight: normal; 31 | font-style: italic; 32 | } 33 | 34 | 35 | body, html { 36 | margin: 0; 37 | padding: 0; 38 | box-sizing: border-box; 39 | } 40 | 41 | body { 42 | /* This grid pattern taken from tachyon's debug-grid class. Tachyons is great, see them at tachyons.io */ 43 | background: var(--primary-color) url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMklEQVR4AWOgCLz/b0epAa6UGuBOqQHOQHLUgFEDnAbcBZ4UGwDOkiCnkIhdgNgNxAYAiYlD+8sEuo8AAAAASUVORK5CYII=) repeat top left; 44 | color: var(--secondary-color); 45 | font-family: var(--primary_text); 46 | font-size: 20px; 47 | min-height: 100vh; 48 | } 49 | 50 | nav.mainNav { 51 | padding: 1.5em; 52 | padding-bottom: 0; 53 | } 54 | nav.mainNav a { 55 | color: #136151; 56 | text-decoration: none; 57 | font-size: 1.3em; 58 | padding-right: 0.5em; 59 | } 60 | 61 | nav.mainNav a:before { 62 | content: "⬨"; 63 | padding-right: 0.1em; 64 | } 65 | 66 | nav.mainNav a:hover:before , 67 | nav.mainNav a:active { 68 | content: "⬧" ; 69 | } 70 | 71 | ul.publications { 72 | margin: 0; 73 | padding: 0; 74 | } 75 | 76 | li.publication , 77 | div.publication , 78 | div.preview , 79 | #publish-form { 80 | margin: 0 auto 3em auto; 81 | padding: 1.5em; 82 | display: flex; 83 | flex-direction: column; 84 | align-items: center; 85 | } 86 | 87 | li.publication { 88 | border-bottom: 7px solid var(--accent-color); 89 | } 90 | 91 | li.publication figure , 92 | div.preview figure , 93 | div.publication figure , 94 | span.publish-figure { 95 | display: flex; 96 | flex-direction: column; 97 | justify-content: center; 98 | align-items: center; 99 | margin-bottom: 0; 100 | } 101 | 102 | li.publication figure img , 103 | div.preview figure img , 104 | div.publication figure img , 105 | span.publish-figure img { 106 | max-width: 100%; 107 | } 108 | 109 | li.publication figcaption , 110 | div.preview figcaption , 111 | div.publication figcaption { 112 | max-width: 50vw; 113 | margin: 1em auto 1em auto; 114 | font-size: 0.75em; 115 | font-family: var(--secondary_text); 116 | font-style: italic; 117 | } 118 | 119 | li.publication div.description , 120 | div.preview div.description , 121 | div.publication div.description { 122 | max-width: 80%; 123 | margin: 1em auto auto auto; 124 | } 125 | #publish-form span.description { 126 | max-width: 80%; 127 | } 128 | 129 | #publish-form textarea#imgDesc { 130 | background: none; 131 | border: none; 132 | width: 70vw; 133 | min-height: 150px; 134 | font-size: 20px; 135 | } 136 | 137 | #publish-form textarea#imgDesc::placeholder { 138 | font-size: 20px; 139 | } 140 | 141 | div.description , 142 | div.publicationDescription , 143 | #publish-form textarea#imgDesc { 144 | margin: 1em auto 1.5em auto; 145 | } 146 | 147 | h2.publicationTitle , 148 | form#publish-form input#imgTitle { 149 | font-size: 1.3em; 150 | font-weight: 700; 151 | margin-bottom: 0; 152 | } 153 | 154 | form#publish-form input#imgTitle { 155 | background: none; 156 | border: none; 157 | width: 70vw; 158 | } 159 | 160 | form#publish-form ::placeholder { 161 | opacity: 1; 162 | color: var(--primary_color); 163 | } 164 | 165 | a.author { 166 | text-decoration: none; 167 | margin-bottom: 1em; 168 | font-style: italic; 169 | 170 | } 171 | 172 | /* If there is no title given, don't indent our author */ 173 | .author:first-child { 174 | text-align: center; 175 | } 176 | 177 | time.pubTime { 178 | display: none; 179 | } 180 | 181 | nav.pagination { 182 | width: 100%; 183 | display: flex; 184 | justify-content: center; 185 | margin-bottom: 1.5em; 186 | } 187 | 188 | nav.pagination button { 189 | margin: 0.5em; 190 | } 191 | 192 | @media (max-width: 667px) { 193 | li.publication , div.publication , figure { 194 | padding: 0.25em; 195 | margin-left: 0; 196 | margin-right: 0; 197 | margin-bottom: 3em; 198 | } 199 | li.publication figcaption { 200 | max-width: 100vw; 201 | padding: 0.25em; 202 | font-size: 16px; 203 | } 204 | } 205 | 206 | #img-input-form { 207 | height: 80vh; 208 | width: 80%; 209 | margin: auto; 210 | display: flex; 211 | flex-direction: column; 212 | justify-content: center; 213 | align-items: center; 214 | } 215 | 216 | #img-input-form label[for="imgInput"], 217 | div.publish-form-addl-details h1 { 218 | font-size: 2em; 219 | margin-bottom: 1em; 220 | } 221 | 222 | div.publish-form-addl-details h1 { 223 | text-align: center; 224 | } 225 | 226 | #publish-form label { 227 | display: none; 228 | } 229 | 230 | #publish-form #imgCaption { 231 | width: 50vw; 232 | height: 150px; 233 | border: none; 234 | background: transparent; 235 | margin: 1em auto 2em auto; 236 | font-family: var(--secondary_text); 237 | font-style: italic; 238 | } 239 | 240 | #publish-form #imgCaption::placeholder , 241 | #imgCaption { 242 | font-size: 18px; 243 | } 244 | 245 | #publish-form input[type='submit'] { 246 | margin-top: 0; 247 | width: 50vw; 248 | height: 40px; 249 | background: var(--accent-color); 250 | } 251 | 252 | #publish-form button#removeImg , 253 | #publish-preview button { 254 | margin-top: 0.5em; 255 | margin-bottom: 0.5em; 256 | height: 40px; 257 | background: var(--accent-color); 258 | } 259 | 260 | #publish-preview button { 261 | margin-bottom: 0.5em; 262 | font-size: 24px; 263 | background: var(--accent-color); 264 | } 265 | 266 | textarea#imgCaption:focus, 267 | input#imgTitle:focus, 268 | textarea#imgDesc:focus { 269 | background: var(--accent-color) !important; 270 | opacity: 0.5; 271 | } 272 | 273 | #publish-form input[name="imgInput"]{ 274 | display: none !important; 275 | } 276 | 277 | #publish-preview { 278 | display: flex; 279 | flex-direction: column; 280 | justify-content: center; 281 | align-items: center; 282 | width: 80%; 283 | margin: auto; 284 | } 285 | 286 | 287 | --------------------------------------------------------------------------------