├── .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`
7 | Home
8 | Go Back
9 | ${
10 | state.me ?
11 | html`Me ` :
12 | html``
13 | }
14 | Publish
15 | `;
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`
10 | ${state.msgs.map(msg => {
11 | const author = getAuthor(state.authorCache, state.ssb, emit, msg.author);
12 | const blob = new Blob(getImg(state.imgCache, state.ssb, emit, msg.content.img));
13 |
14 | return html`${publication({
15 | timestamp: msg.timestamp,
16 | title: msg.content.title,
17 | description: msg.content.description,
18 | caption: msg.content.caption,
19 | size: msg.content.size,
20 | msgId: msg.msgId,
21 | author,
22 | authorId: msg.author,
23 | blob
24 | }, emit)} `;
25 | })}
26 |
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 | Confirm
26 | Cancel
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 |
44 | `
45 | // Otherwise, display the image file selector
46 | : html`
47 |
48 | Select an image to publish
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 |
--------------------------------------------------------------------------------