Commit f282bf4a9ac0e60ba0de219dad5704df86000ec5
Merge branch 'master' into async_router (and fix undefined / null
problem)mix irving committed on 2/18/2018, 9:33:17 PM
Parent: 9c48d54a35357e3a8ff864b699903b843a0b13d3
Parent: 930e266beb35562806c1081330831a203ab75c66
Files changed
README.md | changed |
app/html/app.js | changed |
app/html/tabs.js | changed |
app/page/notifications.js | changed |
app/page/thread.js | changed |
app/sync/initialise/electronState.js | added |
app/sync/initialise/errorCatcher.js | added |
app/sync/initialise/styles.js | added |
app/sync/initialise/userActionListeners.js | added |
index.js | changed |
message/html/render/blog.js | added |
message/html/render/blog.mcss | added |
package-lock.json | changed |
package.json | changed |
README.md | ||
---|---|---|
@@ -7,9 +7,8 @@ | ||
7 | 7 … | Patchbay is built using [patchcore](https://github.com/ssbc/patchcore) + [depject](https://github.com/dominictarr/depject). The goal is to make it easier to develop new features, and enable or disable features. This has so far been quite successful! |
8 | 8 … | |
9 | 9 … | This makes in very easy to create say, a renderer for a new message type, or switch to a different method for choosing user names. |
10 | 10 … | |
11 | - | |
12 | 11 … | ## Setup |
13 | 12 … | |
14 | 13 … | Libsodium has some build dependencies. On ubuntu systems the following might help: |
15 | 14 … | |
@@ -99,8 +98,16 @@ | ||
99 | 98 … | # from the patchbay repo folder |
100 | 99 … | npm run dev |
101 | 100 … | ``` |
102 | 101 … | |
102 … | +## Keyboard shortcuts | |
103 … | +`CmdOrCtrl` is the `command` key on Apple keyboards or the `ctrl` key on PC keyboards. | |
104 … | + | |
105 … | +### Tabs and Window | |
106 … | +- `CmdOrCtrl+Shift+]` and `CmdOrCtrl+Shift+[` will cycle the tabs left and right | |
107 … | +- `CmdOrCtrl+w` will close the current tab | |
108 … | +- `CmdOrCtrl+Shift+w` will close the current window | |
109 … | + | |
103 | 110 … | ## How to add a feature |
104 | 111 … | |
105 | 112 … | To add a new message type, add add a js to `./modules/` that exports a function named `message_content` (it should return an HTML element). To add a new tab, export a function named `screen_view` (returns an html element). |
106 | 113 … |
app/html/app.js | ||
---|---|---|
@@ -1,24 +1,17 @@ | ||
1 | 1 … | const nest = require('depnest') |
2 | 2 … | const { h } = require('mutant') |
3 | -const insertCss = require('insert-css') | |
4 | -const electron = require('electron') | |
5 | 3 … | |
6 | 4 … | exports.gives = nest('app.html.app') |
7 | 5 … | |
8 | 6 … | exports.needs = nest({ |
9 | - 'app.async.catchLinkClick': 'first', | |
10 | 7 … | 'app.html.tabs': 'first', |
11 | 8 … | 'app.page.errors': 'first', |
12 | - 'app.sync.catchKeyboardShortcut': 'first', | |
13 | 9 … | 'app.sync.goTo': 'first', |
14 | 10 … | 'app.sync.initialise': 'first', |
15 | 11 … | 'app.sync.window': 'reduce', |
16 | 12 … | 'history.obs.location': 'first', |
17 | 13 … | 'history.sync.push': 'first', |
18 | - 'router.async.router': 'first', | |
19 | - 'router.sync.router': 'first', // TODO rm | |
20 | - 'styles.css': 'reduce', | |
21 | 14 … | 'settings.sync.get': 'first', |
22 | 15 … | 'settings.sync.set': 'first' |
23 | 16 … | }) |
24 | 17 … | |
@@ -27,62 +20,18 @@ | ||
27 | 20 … | |
28 | 21 … | function app () { |
29 | 22 … | console.log('STARTING app') |
30 | 23 … | |
31 | - api.app.sync.initialise() | |
32 | - | |
33 | 24 … | window = api.app.sync.window(window) |
34 | 25 … | |
35 | - const css = values(api.styles.css()).join('\n') | |
36 | - insertCss(css) | |
26 … | + const initialTabs = ['/public', '/inbox', '/notifications'] // NB router converts these to { page: '/public' } | |
27 … | + const App = h('App', api.app.html.tabs(initialTabs)) | |
37 | 28 … | |
38 | - const initialTabs = ['/public', '/inbox', '/notifications'] | |
39 | - // NB router converts these to { page: '/public' } | |
40 | - const tabs = api.app.html.tabs(initialTabs) | |
29 … | + api.app.sync.initialise(App) | |
30 … | + // runs all the functions in app/sync/initialise | |
41 | 31 … | |
42 | - const App = h('App', tabs) | |
43 | - | |
44 | - // Catch user actions | |
45 | - api.app.sync.catchKeyboardShortcut(window, { tabs }) | |
46 | - api.app.async.catchLinkClick(App) | |
47 | - | |
48 | 32 … | api.history.obs.location()(loc => api.app.sync.goTo(loc || {})) |
49 | 33 … | |
50 | - // Catch errors | |
51 | - // TODO - change this error handler error page annomaly | |
52 | - // Note I'm still using a sync router just to ge this working (mix) | |
53 | - var { container: errorPage, addError } = api.router.sync.router('/errors') | |
54 | - window.addEventListener('error', ev => { | |
55 | - if (!tabs.has('/errors')) tabs.add(errorPage, true) | |
56 | - | |
57 | - addError(ev.error || ev) | |
58 | - }) | |
59 | - | |
60 | - /// /// TODO - extract this to keep patch-lite isolated from electron | |
61 | - const { getCurrentWebContents, getCurrentWindow } = electron.remote | |
62 | - window.addEventListener('resize', () => { | |
63 | - var wc = getCurrentWebContents() | |
64 | - wc && wc.getZoomFactor((zf) => { | |
65 | - api.settings.sync.set({ | |
66 | - electron: { | |
67 | - zoomFactor: zf, | |
68 | - windowBounds: getCurrentWindow().getBounds() | |
69 | - } | |
70 | - }) | |
71 | - }) | |
72 | - }) | |
73 | - | |
74 | - var zoomFactor = api.settings.sync.get('electron.zoomFactor') | |
75 | - if (zoomFactor) { getCurrentWebContents().setZoomFactor(zoomFactor) } | |
76 | - | |
77 | - var bounds = api.settings.sync.get('electron.windowBounds') | |
78 | - if (bounds) { getCurrentWindow().setBounds(bounds) } | |
79 | - /// /// | |
80 | - | |
81 | 34 … | return App |
82 | 35 … | } |
83 | 36 … | } |
84 | 37 … | |
85 | -function values (object) { | |
86 | - const keys = Object.keys(object) | |
87 | - return keys.map(k => object[k]) | |
88 | -} |
app/html/tabs.js | ||
---|---|---|
@@ -52,9 +52,15 @@ | ||
52 | 52 … | onSelect, |
53 | 53 … | onClose, |
54 | 54 … | append: h('div.navExtra', [ search, menu ]) |
55 | 55 … | }) |
56 | - _tabs.currentPage = () => _tabs.get(_tabs.selected[0]).firstChild | |
56 … | + _tabs.currentPage = () => { | |
57 … | + const currentPage = _tabs.get(_tabs.selected[0]) | |
58 … | + return currentPage && currentPage.firstChild | |
59 … | + } | |
60 … | + _tabs.nextTab = () => _tabs.currentPage() && _tabs.selectRelative(1) | |
61 … | + _tabs.previousTab = () => _tabs.currentPage() && _tabs.selectRelative(-1) | |
62 … | + _tabs.closeCurrentTab = () => _tabs.currentPage() && _tabs.remove(_tabs.selected[0]) | |
57 | 63 … | |
58 | 64 … | // # TODO: review - this works but is strange |
59 | 65 … | initialTabs.forEach(p => api.app.sync.goTo(p)) |
60 | 66 … | api.app.sync.goTo(initialTabs[0]) |
app/page/notifications.js | ||
---|---|---|
@@ -37,20 +37,26 @@ | ||
37 | 37 … | const id = api.keys.sync.id() |
38 | 38 … | |
39 | 39 … | const { filterMenu, filterDownThrough, filterUpThrough, resetFeed } = api.app.html.filter(draw) |
40 | 40 … | const { container, content } = api.app.html.scroller({ prepend: [ filterMenu ] }) |
41 … | + const removeMyMessages = () => pull.filter(msg => msg.value.author !== id) | |
42 … | + const removePrivateMessages = () => pull.filter(msg => msg.value.private !== true) | |
41 | 43 … | |
42 | 44 … | function draw () { |
43 | 45 … | resetFeed({ container, content }) |
44 | 46 … | |
45 | 47 … | pull( |
46 | 48 … | next(api.feed.pull.mentions(id), {old: false, limit: 100}), |
49 … | + removeMyMessages(), | |
50 … | + removePrivateMessages(), | |
47 | 51 … | filterDownThrough(), |
48 | 52 … | Scroller(container, content, api.message.html.render, true, false) |
49 | 53 … | ) |
50 | 54 … | |
51 | 55 … | pull( |
52 | 56 … | next(api.feed.pull.mentions(id), {reverse: true, limit: 100, live: false}), |
57 … | + removeMyMessages(), | |
58 … | + removePrivateMessages(), | |
53 | 59 … | filterUpThrough(), |
54 | 60 … | Scroller(container, content, api.message.html.render, false, false) |
55 | 61 … | ) |
56 | 62 … | } |
app/page/thread.js | ||
---|---|---|
@@ -101,9 +101,9 @@ | ||
101 | 101 … | if (!tabs.currentPage().scroll) return setTimeout(locateKey, 200) |
102 | 102 … | |
103 | 103 … | tabs.currentPage().scroll('first') |
104 | 104 … | const msg = tabs.currentPage().querySelector(`[data-id='${id}']`) |
105 | - if (msg === null) return setTimeout(locateKey, 200) | |
105 … | + if (msg === undefined) return setTimeout(locateKey, 200) | |
106 | 106 … | |
107 | 107 … | ;(msg.scrollIntoViewIfNeeded || msg.scrollIntoView).call(msg) |
108 | 108 … | msg.focus() |
109 | 109 … | } |
app/sync/initialise/electronState.js | ||
---|---|---|
@@ -1,0 +1,38 @@ | ||
1 … | +const nest = require('depnest') | |
2 … | +const electron = require('electron') | |
3 … | + | |
4 … | +exports.gives = nest('app.sync.initialise') | |
5 … | + | |
6 … | +exports.needs = nest({ | |
7 … | + 'settings.sync.get': 'first', | |
8 … | + 'settings.sync.set': 'first', | |
9 … | +}) | |
10 … | + | |
11 … | +exports.create = function (api) { | |
12 … | + return nest('app.sync.initialise', errorCatcher) | |
13 … | + | |
14 … | + function errorCatcher () { | |
15 … | + /// /// TODO - extract this to keep patch-lite isolated from electron | |
16 … | + const { getCurrentWebContents, getCurrentWindow } = electron.remote | |
17 … | + window.addEventListener('resize', () => { | |
18 … | + var wc = getCurrentWebContents() | |
19 … | + wc && wc.getZoomFactor((zf) => { | |
20 … | + api.settings.sync.set({ | |
21 … | + electron: { | |
22 … | + zoomFactor: zf, | |
23 … | + windowBounds: getCurrentWindow().getBounds() | |
24 … | + } | |
25 … | + }) | |
26 … | + }) | |
27 … | + }) | |
28 … | + | |
29 … | + var zoomFactor = api.settings.sync.get('electron.zoomFactor') | |
30 … | + if (zoomFactor) { getCurrentWebContents().setZoomFactor(zoomFactor) } | |
31 … | + | |
32 … | + var bounds = api.settings.sync.get('electron.windowBounds') | |
33 … | + if (bounds) { getCurrentWindow().setBounds(bounds) } | |
34 … | + /// /// | |
35 … | + } | |
36 … | +} | |
37 … | + | |
38 … | + |
app/sync/initialise/errorCatcher.js | ||
---|---|---|
@@ -1,0 +1,24 @@ | ||
1 … | +const nest = require('depnest') | |
2 … | + | |
3 … | +exports.gives = nest('app.sync.initialise') | |
4 … | + | |
5 … | +exports.needs = nest({ | |
6 … | + 'router.sync.router': 'first', | |
7 … | + 'app.html.tabs': 'first' | |
8 … | +}) | |
9 … | + | |
10 … | +exports.create = function (api) { | |
11 … | + return nest('app.sync.initialise', errorCatcher) | |
12 … | + | |
13 … | + function errorCatcher () { | |
14 … | + const tabs = api.app.html.tabs() | |
15 … | + | |
16 … | + var { container: errorPage, addError } = api.router.sync.router('/errors') | |
17 … | + window.addEventListener('error', ev => { | |
18 … | + if (!tabs.has('/errors')) tabs.add(errorPage, true) | |
19 … | + | |
20 … | + addError(ev.error || ev) | |
21 … | + }) | |
22 … | + } | |
23 … | +} | |
24 … | + |
app/sync/initialise/styles.js | ||
---|---|---|
@@ -1,0 +1,24 @@ | ||
1 … | +const nest = require('depnest') | |
2 … | +const insertCss = require('insert-css') | |
3 … | + | |
4 … | +exports.gives = nest('app.sync.initialise') | |
5 … | + | |
6 … | +exports.needs = nest({ | |
7 … | + 'styles.css': 'reduce', | |
8 … | +}) | |
9 … | + | |
10 … | + | |
11 … | +exports.create = function (api) { | |
12 … | + return nest('app.sync.initialise', styles) | |
13 … | + | |
14 … | + function styles () { | |
15 … | + const css = values(api.styles.css()).join('\n') | |
16 … | + insertCss(css) | |
17 … | + } | |
18 … | +} | |
19 … | + | |
20 … | +function values (object) { | |
21 … | + const keys = Object.keys(object) | |
22 … | + return keys.map(k => object[k]) | |
23 … | +} | |
24 … | + |
app/sync/initialise/userActionListeners.js | ||
---|---|---|
@@ -1,0 +1,35 @@ | ||
1 … | +const nest = require('depnest') | |
2 … | + | |
3 … | +exports.gives = nest('app.sync.initialise') | |
4 … | + | |
5 … | +exports.needs = nest({ | |
6 … | + 'app.async.catchLinkClick': 'first', | |
7 … | + 'app.sync.catchKeyboardShortcut': 'first', | |
8 … | + 'app.html.tabs': 'first', | |
9 … | +}) | |
10 … | + | |
11 … | + | |
12 … | +exports.create = function (api) { | |
13 … | + return nest('app.sync.initialise', userActionListeners) | |
14 … | + | |
15 … | + function userActionListeners (App) { | |
16 … | + const tabs = api.app.html.tabs() | |
17 … | + | |
18 … | + api.app.sync.catchKeyboardShortcut(window) | |
19 … | + api.app.async.catchLinkClick(App) | |
20 … | + | |
21 … | + electron.ipcRenderer.on('nextTab', () => { | |
22 … | + tabs.nextTab() | |
23 … | + }) | |
24 … | + | |
25 … | + electron.ipcRenderer.on('previousTab', () => { | |
26 … | + tabs.previousTab() | |
27 … | + }) | |
28 … | + | |
29 … | + electron.ipcRenderer.on('closeTab', () => { | |
30 … | + tabs.closeCurrentTab() | |
31 … | + }) | |
32 … | + | |
33 … | + } | |
34 … | +} | |
35 … | + |
index.js | ||
---|---|---|
@@ -21,18 +21,38 @@ | ||
21 | 21 … | { role: 'zoomout' }, |
22 | 22 … | { type: 'separator' }, |
23 | 23 … | { role: 'togglefullscreen' } |
24 | 24 … | ] |
25 | - if (process.platform === 'darwin') { | |
26 | - var win = menu.find(x => x.label === 'Window') | |
27 | - win.submenu = [ | |
28 | - { role: 'minimize' }, | |
29 | - { role: 'zoom' }, | |
30 | - { role: 'close', label: 'Close' }, | |
31 | - { type: 'separator' }, | |
32 | - { role: 'front' } | |
33 | - ] | |
34 | - } | |
25 … | + var win = menu.find(x => x.label === 'Window') | |
26 … | + win.submenu = [ | |
27 … | + { role: 'minimize' }, | |
28 … | + { role: 'zoom' }, | |
29 … | + { role: 'close', label: 'Close Window', accelerator: 'CmdOrCtrl+Shift+W' }, | |
30 … | + { type: 'separator' }, | |
31 … | + { | |
32 … | + label: 'Close Tab', | |
33 … | + accelerator: 'CmdOrCtrl+W', | |
34 … | + click() { | |
35 … | + windows.main.webContents.send('closeTab') | |
36 … | + } | |
37 … | + }, | |
38 … | + { | |
39 … | + label: 'Select Next Tab', | |
40 … | + accelerator: 'CmdOrCtrl+Shift+]', | |
41 … | + click() { | |
42 … | + windows.main.webContents.send('nextTab') | |
43 … | + } | |
44 … | + }, | |
45 … | + { | |
46 … | + label: 'Select Previous Tab', | |
47 … | + accelerator: 'CmdOrCtrl+Shift+[', | |
48 … | + click() { | |
49 … | + windows.main.webContents.send('previousTab') | |
50 … | + } | |
51 … | + }, | |
52 … | + { type: 'separator' }, | |
53 … | + { role: 'front' } | |
54 … | + ] | |
35 | 55 … | |
36 | 56 … | Menu.setApplicationMenu(Menu.buildFromTemplate(menu)) |
37 | 57 … | |
38 | 58 … | startBackgroundProcess() |
message/html/render/blog.js | ||
---|---|---|
@@ -1,0 +1,99 @@ | ||
1 … | +const nest = require('depnest') | |
2 … | +const Blog = require('scuttle-blog') | |
3 … | +const isBlog = require('scuttle-blog/isBlog') | |
4 … | +const { h, Value, computed, when, resolve, onceTrue } = require('mutant') | |
5 … | +const isEmpty = require('lodash/isEmpty') | |
6 … | + | |
7 … | +exports.gives = nest('message.html.render') | |
8 … | + | |
9 … | +exports.needs = nest({ | |
10 … | + 'about.obs.color': 'first', | |
11 … | + 'blob.sync.url': 'first', | |
12 … | + 'message.html.decorate': 'reduce', | |
13 … | + 'message.html.layout': 'first', | |
14 … | + 'message.html.markdown': 'first', | |
15 … | + 'sbot.obs.connection': 'first', | |
16 … | + // 'history.sync.push': 'first', | |
17 … | +}) | |
18 … | + | |
19 … | +exports.create = function (api) { | |
20 … | + return nest('message.html.render', blogRenderer) | |
21 … | + | |
22 … | + function blogRenderer (msg, opts) { | |
23 … | + if (!isBlog(msg)) return | |
24 … | + | |
25 … | + var blog = Blog(api.sbot.obs.connection).obs.get(msg) | |
26 … | + var showBlog = Value(false) | |
27 … | + | |
28 … | + const element = api.message.html.layout(msg, Object.assign({}, { | |
29 … | + content: when(showBlog, | |
30 … | + BlogFull(blog, api.message.html.markdown), | |
31 … | + BlogCard({ | |
32 … | + blog, | |
33 … | + onClick: () => showBlog.set(true), | |
34 … | + color: api.about.obs.color, | |
35 … | + blobUrl: api.blob.sync.url | |
36 … | + }) | |
37 … | + // Sample(blog, api.blob.sync.url, showBlog) | |
38 … | + ), | |
39 … | + layout: 'default' | |
40 … | + }, opts)) | |
41 … | + | |
42 … | + return api.message.html.decorate(element, { msg }) | |
43 … | + | |
44 … | + } | |
45 … | +} | |
46 … | + | |
47 … | +function BlogFull (blog, renderMd) { | |
48 … | + return computed(blog.body, body => { | |
49 … | + if (!isEmpty(body)) { | |
50 … | + return h('BlogFull.Markdown', [ | |
51 … | + h('h1', blog.title), | |
52 … | + renderMd(body) | |
53 … | + ]) | |
54 … | + } | |
55 … | + | |
56 … | + return h('BlogFull.Markdown', [ | |
57 … | + h('h1', blog.title), | |
58 … | + blog.summary, | |
59 … | + h('p', 'loading...') | |
60 … | + ]) | |
61 … | + }) | |
62 … | +} | |
63 … | + | |
64 … | +function BlogCard ({ blog, blobUrl, onClick, color }) { | |
65 … | + const thumbnail = when(blog.thumbnail, | |
66 … | + h('Thumbnail', { | |
67 … | + style: { | |
68 … | + 'background-image': `url("${blobUrl(resolve(blog.thumbnail))}")`, | |
69 … | + 'background-position': 'center', | |
70 … | + 'background-size': 'cover' | |
71 … | + } | |
72 … | + }), | |
73 … | + h('Thumbnail -empty', { | |
74 … | + style: { 'background-color': color(blog.title) } | |
75 … | + }, [ | |
76 … | + h('i.fa.fa-file-text-o') | |
77 … | + ]) | |
78 … | + ) | |
79 … | + | |
80 … | + var b = h('BlogCard', { 'ev-click': onClick }, [ | |
81 … | + // h('div.context', [ | |
82 … | + // api.about.html.avatar(author, 'tiny'), | |
83 … | + // h('div.name', api.about.obs.name(author)), | |
84 … | + // api.message.html.timeago(blog) | |
85 … | + // ]), | |
86 … | + h('div.content', [ | |
87 … | + thumbnail, | |
88 … | + h('div.text.Markdown', [ | |
89 … | + h('h1', blog.title), | |
90 … | + // when(blog.channel, api.message.html.channel(blog.channel)) | |
91 … | + h('div.summary', blog.summary), | |
92 … | + h('div.read', 'Read blog') | |
93 … | + ]) | |
94 … | + ]) | |
95 … | + ]) | |
96 … | + | |
97 … | + return b | |
98 … | +} | |
99 … | + |
message/html/render/blog.mcss | ||
---|---|---|
@@ -1,0 +1,51 @@ | ||
1 … | +BlogCard { | |
2 … | + box-shadow: rgba(0, 0, 0, 0.1) 0px 0px 10px | |
3 … | + margin: 2rem 0 | |
4 … | + border: 1px solid rgba(0, 0, 0, 0.1) | |
5 … | + | |
6 … | + display: flex | |
7 … | + flex-direction: column | |
8 … | + | |
9 … | + div.content { | |
10 … | + display: flex | |
11 … | + flex-direction: row | |
12 … | + flex-grow: 1 | |
13 … | + | |
14 … | + cursor: pointer | |
15 … | + | |
16 … | + | |
17 … | + div.Thumbnail { | |
18 … | + margin-right: 1rem | |
19 … | + } | |
20 … | + | |
21 … | + div.text { | |
22 … | + padding: .5rem | |
23 … | + div.summary { | |
24 … | + } | |
25 … | + div.read { | |
26 … | + margin-top: 1rem | |
27 … | + text-decoration: underline | |
28 … | + } | |
29 … | + } | |
30 … | + | |
31 … | + } | |
32 … | + background-color: #fff | |
33 … | +} | |
34 … | + | |
35 … | +Thumbnail { | |
36 … | + min-width: 20rem | |
37 … | + width: 20rem | |
38 … | + min-height: 10rem | |
39 … | + /* height: 10rem */ | |
40 … | + | |
41 … | + -empty { | |
42 … | + color: #fff | |
43 … | + font-size: 1.8rem | |
44 … | + opacity: .8 | |
45 … | + | |
46 … | + display: flex | |
47 … | + justify-content: center | |
48 … | + align-items: center | |
49 … | + } | |
50 … | +} | |
51 … | + |
package-lock.json | ||
---|---|---|
The diff is too large to show. Use a local git client to view these changes. Old file size: 305638 bytes New file size: 307075 bytes |
package.json | ||
---|---|---|
@@ -63,16 +63,17 @@ | ||
63 | 63 … | "patch-settings": "^1.0.1", |
64 | 64 … | "patch-suggest": "^1.1.0", |
65 | 65 … | "patchbay-book": "^1.0.4", |
66 | 66 … | "patchbay-gatherings": "^2.0.0", |
67 | - "patchcore": "^1.23.0", | |
67 … | + "patchcore": "^1.23.3", | |
68 | 68 … | "pull-abortable": "^4.1.1", |
69 | 69 … | "pull-cat": "^1.1.11", |
70 | 70 … | "pull-next": "^1.0.0", |
71 | 71 … | "pull-scroll": "^1.0.9", |
72 | 72 … | "pull-stream": "^3.6.1", |
73 | 73 … | "read-directory": "^2.1.1", |
74 | 74 … | "require-style": "^1.0.1", |
75 … | + "scuttle-blog": "^1.0.0", | |
75 | 76 … | "scuttlebot": "^10.4.10", |
76 | 77 … | "setimmediate": "^1.0.5", |
77 | 78 … | "ssb-about": "^0.1.1", |
78 | 79 … | "ssb-backlinks": "^0.6.1", |
Built with git-ssb-web