git ssb

2+

mixmix / ticktack



Commit 1b46fe809a87f906b19ab7dad059971d13730b4f

Merge pull request #132 from ticktackim/stats-page

stats page
mix irving authored on 5/1/2018, 5:11:05 AM
GitHub committed on 5/1/2018, 5:11:05 AM
Parent: 0a4073c00a2da5c212e6f7ecf45ad96cc8818d14
Parent: fe2b96982fed8ff1598c65dfccc21dc2c5d79b9f

Files changed

app/html/header.jschanged
app/html/header.mcsschanged
app/index.jschanged
app/page/statsShow.jsadded
app/page/statsShow.mcssadded
background-process.jschanged
blog/sync/isBlog.jschanged
package-lock.jsonchanged
package.jsonchanged
router/sync/routes.jschanged
styles/mixins.jschanged
translations/en.jschanged
config.jsdeleted
config/chart.jsadded
config/config-custom.jsonadded
config/config-ssb.jsonadded
config/index.jsadded
default-config.jsondeleted
ssb-config.jsondeleted
ssb-server-blog-stats.jsadded
app/html/header.jsView
@@ -38,8 +38,11 @@
3838 }),
3939 h('img.settings', {
4040 src: when(isSettings, assetPath('settings_on.png'), assetPath('settings.png')),
4141 'ev-click': () => push({page: 'settings'})
42+ }),
43+ h('i.fa.fa-bell', {
44+ 'ev-click': () => push({page: 'statsShow'})
4245 })
4346 ])
4447 ])
4548 })
app/html/header.mcssView
@@ -28,9 +28,9 @@
2828 display: flex
2929 align-items: center
3030 justify-content: center
3131
32- img {
32+ img, i {
3333 cursor: pointer
3434 text-decoration: none
3535
3636 margin: 0 2rem
app/index.jsView
@@ -46,8 +46,9 @@
4646 userEdit: require('./page/userEdit'),
4747 // userFind: require('./page/userFind'),
4848 userShow: require('./page/userShow'),
4949 splash: require('./page/splash'),
50+ statsShow: require('./page/statsShow'),
5051 threadNew: require('./page/threadNew'),
5152 threadShow: require('./page/threadShow')
5253 },
5354 sync: {
app/page/statsShow.jsView
@@ -1,0 +1,328 @@
1+const nest = require('depnest')
2+const { h, when, Value, Struct, Array: MutantArray, Dict, onceTrue, map, computed, throttle, watchAll } = require('mutant')
3+const pull = require('pull-stream')
4+const marksum = require('markdown-summary')
5+const Chart = require('chart.js')
6+const groupBy = require('lodash/groupBy')
7+const flatMap = require('lodash/flatMap')
8+const get = require('lodash/get')
9+
10+const chartConfig = require('../../config/chart')
11+
12+exports.gives = nest('app.page.statsShow')
13+
14+exports.needs = nest({
15+ 'sbot.obs.connection': 'first',
16+ 'history.sync.push': 'first',
17+ 'message.html.markdown': 'first',
18+ 'translations.sync.strings': 'first'
19+})
20+
21+const COMMENTS = 'comments'
22+const LIKES = 'likes'
23+const SHARES = 'shares'
24+const DAY = 24 * 60 * 60 * 1000
25+
26+const getRoot = {
27+ [COMMENTS]: (msg) => get(msg, 'value.content.root'),
28+ [LIKES]: (msg) => get(msg, 'value.content.vote.link')
29+}
30+
31+exports.create = (api) => {
32+ return nest('app.page.statsShow', statsShow)
33+
34+ function statsShow (location) {
35+ const strings = api.translations.sync.strings()
36+ const t = strings.statsShow
37+
38+ var store = Struct({
39+ blogs: MutantArray([]),
40+ comments: Dict(),
41+ likes: Dict(),
42+ shares: Dict()
43+ })
44+ onceTrue(api.sbot.obs.connection, server => fetchBlogData({ server, store }))
45+
46+ var foci = Struct({
47+ [COMMENTS]: computed([throttle(store.comments, 1000)], (msgs) => {
48+ return flatMap(msgs, (val, key) => val)
49+ }),
50+ [LIKES]: computed([throttle(store.likes, 1000)], (msgs) => {
51+ return flatMap(msgs, (val, key) => val)
52+ }),
53+ [SHARES]: []
54+ })
55+
56+ var howFarBack = Value(0)
57+ // stats show a moving window of 30 days
58+ var context = Struct({
59+ focus: Value(COMMENTS),
60+ blog: Value(),
61+ range: computed([howFarBack], howFarBack => {
62+ const now = Date.now()
63+ const endOfDay = (Math.floor(now / DAY) + 1) * DAY
64+
65+ return {
66+ upper: endOfDay - howFarBack * 30 * DAY,
67+ lower: endOfDay - (howFarBack + 1) * 30 * DAY
68+ }
69+ })
70+ })
71+
72+ function totalOnscreenData (focus) {
73+ return computed([foci[focus], context], (msgs, context) => {
74+ // NOTE this filter logic is repeated in chartData
75+ return msgs
76+ .filter(msg => {
77+ if (!context.blog) return true
78+ // if context.blog is set, filter down to only msgs about that blog
79+ return getRoot[focus](msg) === context.blog
80+ })
81+ .filter(msg => {
82+ // don't count unlikes
83+ if (focus === LIKES) return get(msg, 'value.content.vote.value') > 0
84+ else return true
85+ })
86+ .filter(msg => {
87+ const ts = msg.value.timestamp
88+ return ts > context.range.lower && ts <= context.range.upper
89+ })
90+ .length
91+ })
92+ }
93+
94+ const canvas = h('canvas', { height: 200, width: 600, style: { height: '200px', width: '600px' } })
95+
96+ const page = h('Page -statsShow', [
97+ h('Scroller.content', [
98+ h('div.content', [
99+ h('h1', t.title),
100+ h('section.totals', [COMMENTS, LIKES, SHARES].map(focus => {
101+ return h('div',
102+ {
103+ classList: computed(context.focus, f => f === focus ? [focus, '-selected'] : [focus]),
104+ 'ev-click': () => context.focus.set(focus)
105+ }, [
106+ h('div.count', totalOnscreenData(focus)),
107+ h('strong', strings[focus]),
108+ '(',
109+ t.thirtyDays,
110+ ')'
111+ ])
112+ })),
113+ h('section.graph', [
114+ canvas,
115+ h('div.changeRange', [
116+ '< ',
117+ h('a', { 'ev-click': () => howFarBack.set(howFarBack() + 1) }, t.prevMonth),
118+ ' | ',
119+ when(howFarBack,
120+ h('a', { 'ev-click': () => howFarBack.set(howFarBack() - 1) }, t.nextMonth),
121+ h('span', t.nextMonth)
122+ ),
123+ ' >'
124+ ])
125+ ]),
126+ h('table.blogs', [
127+ h('thead', [
128+ h('tr', [
129+ h('th.details'),
130+ h('th.comments', strings.comments),
131+ h('th.likes', strings.likes),
132+ h('th.shares', strings.shares)
133+ ])
134+ ]),
135+ h('tbody', map(store.blogs, BlogRow))
136+ ])
137+ ])
138+ ])
139+ ])
140+
141+ function BlogRow (blog) {
142+ const className = computed(context.blog, b => {
143+ if (!b) return ''
144+ if (b !== blog.key) return '-background'
145+ })
146+
147+ return h('tr.blog', { id: blog.key, className }, [
148+ h('td.details', [
149+ h('div.title', {
150+ 'ev-click': () => {
151+ if (context.blog() === blog.key) context.blog.set('')
152+ else context.blog.set(blog.key)
153+ }
154+ }, getTitle({ blog, mdRenderer: api.message.html.markdown })),
155+ h('a', {
156+ href: '#',
157+ 'ev-click': ev => {
158+ ev.stopPropagation() // stop the click catcher!
159+ api.history.sync.push(blog)
160+ }
161+ }, 'View blog')
162+ ]),
163+ h('td.comments', computed(store.comments.get(blog.key), msgs => msgs ? msgs.length : 0)),
164+ h('td.likes', computed(store.likes.get(blog.key), msgs => msgs ? msgs.length : 0)),
165+ h('td.shares', computed(store.shares.get(blog.key), msgs => msgs ? msgs.length : 0))
166+ ])
167+ }
168+
169+ initialiseChart({ canvas, context, foci })
170+
171+ return page
172+ }
173+}
174+
175+function getTitle ({ blog, mdRenderer }) {
176+ if (blog.value.content.title) return blog.value.content.title
177+ else if (blog.value.content.text) {
178+ var md = mdRenderer(marksum.title(blog.value.content.text))
179+ if (md && md.innerText) return md.innerText
180+ }
181+
182+ return blog.key
183+}
184+
185+function fetchBlogData ({ server, store }) {
186+ const myKey = server.id
187+
188+ server.blogStats.getBlogs({}, (err, blogs) => {
189+ if (err) console.error(err)
190+
191+ // TODO - change this once merge in the new notifications-hanger work
192+ // i.e. do one query for ALL comments on my blogs as opposed to N queries
193+ blogs.forEach(blog => {
194+ fetchComments({ server, store, blog })
195+ fetchLikes({ server, store, blog })
196+ })
197+
198+ blogs = blogs
199+ .sort((a, b) => a.value.timestamp > b.value.timestamp ? -1 : +1)
200+ store.blogs.set(blogs)
201+ })
202+
203+ function fetchComments ({ server, store, blog }) {
204+ if (!store.comments.has(blog.key)) store.comments.put(blog.key, MutantArray())
205+
206+ pull(
207+ server.blogStats.readComments(blog),
208+ pull.drain(msg => {
209+ if (msg.value.author === myKey) return
210+ store.comments.get(blog.key).push(msg)
211+ })
212+ )
213+ }
214+
215+ function fetchLikes ({ server, store, blog }) {
216+ if (!store.likes.has(blog.key)) store.likes.put(blog.key, MutantArray())
217+
218+ pull(
219+ server.blogStats.readLikes(blog),
220+ pull.drain(msg => {
221+ if (msg.value.author === myKey) return
222+
223+ const isUnlike = get(msg, 'value.content.vote.value', 1) < 1
224+
225+ var likes = store.likes.get(blog.key)
226+ var extantLike = likes.find(m => m.value.author === msg.value.author)
227+ // extant means existing
228+
229+ if (!extantLike) return likes.push(msg)
230+ else {
231+ if (msg.value.timestamp < extantLike.value.timestamp) return
232+ else {
233+ // case: we have a new like/ unlike value
234+ if (isUnlike) likes.delete(extantLike)
235+ else likes.put(likes.indexOf(extantLike), msg)
236+ }
237+ }
238+ })
239+ )
240+ }
241+}
242+
243+function initialiseChart ({ canvas, context, foci }) {
244+ var chart = new Chart(canvas.getContext('2d'), chartConfig({ context }))
245+
246+ const chartData = computed([context, foci], (context, foci) => {
247+ fixAnimationWhenNeeded(context)
248+
249+ const { focus } = context
250+ // NOTE this filter logic is repeated in totalOnscreenData
251+ const msgs = foci[focus]
252+ .filter(msg => {
253+ if (!context.blog) return true
254+ // if context.blog is set, filter down to only msgs about that blog
255+ return getRoot[focus](msg) === context.blog
256+ })
257+ .filter(msg => {
258+ // don't count unlikes
259+ if (focus === LIKES) return get(msg, 'value.content.vote.value') > 0
260+ else return true
261+ })
262+
263+ const grouped = groupBy(msgs, m => toDay(m.value.timestamp))
264+
265+ return Object.keys(grouped)
266+ .map(day => {
267+ return {
268+ t: day * DAY + DAY / 2,
269+ y: grouped[day].length
270+ }
271+ // NOTE - this collects the data points for a day at t = 10ms into the day
272+ // this is necessary for getting counts to line up (bars, and daily count)
273+ // I think because total counts for totalOnscreenData don't collect data in the same way?
274+ // TODO - refactor this, to be tidier
275+ })
276+ })
277+
278+ chartData(data => {
279+ chart.data.datasets[0].data = data
280+
281+ chart.update()
282+ })
283+
284+ // Scales the height of the graph (to the visible data)!
285+ watchAll([chartData, context.range], (data, range) => {
286+ const { lower, upper } = range
287+ const slice = data
288+ .filter(d => d.t > lower && d.t <= upper)
289+ .map(d => d.y)
290+ .sort((a, b) => a > b ? -1 : +1)
291+
292+ var h = slice[0]
293+ if (!h || h < 10) h = 10
294+ else h = h + (5 - h % 5)
295+ // set the height of the graph to a minimum or 10,
296+ // or some multiple of 5 above the max height
297+
298+ chart.options.scales.yAxes[0].ticks.max = h
299+
300+ chart.update()
301+ })
302+
303+ // Update the x-axes bounds of the graph!
304+ context.range(range => {
305+ const { lower, upper } = range
306+
307+ chart.options.scales.xAxes[0].time.min = lower
308+ chart.options.scales.xAxes[0].time.max = upper
309+
310+ chart.update()
311+ })
312+
313+ // ///// HELPERS /////
314+
315+ // HACK - if the focus has changed, then zero the data
316+ // this prevents the graph from showing some confusing animations when transforming between foci / selecting blog
317+ var prevFocus = context.focus()
318+ var prevBlog = context.blog()
319+ function fixAnimationWhenNeeded (context) {
320+ if (context.focus !== prevFocus || context.blog !== prevBlog) {
321+ chart.data.datasets[0].data = []
322+ chart.update()
323+ prevFocus = context.focus
324+ prevBlog = context.blog
325+ }
326+ }
327+ function toDay (ts) { return Math.floor(ts / DAY) }
328+}
app/page/statsShow.mcssView
@@ -1,0 +1,141 @@
1+Page -statsShow {
2+ div.Scroller {
3+ display: flex
4+ flex-direction: column
5+ align-items: center
6+
7+ div.content {
8+ flex-grow: 0
9+ $backgroundPrimaryText
10+ padding: 1rem
11+ width: 1000px
12+
13+ h1 {
14+ font-size: .8rem
15+ letter-spacing: 4px
16+ }
17+
18+ section.totals {
19+ display: flex
20+
21+ div {
22+ flex-basis: 33%
23+ flex-grow: 1
24+
25+ cursor: pointer
26+ $colorFontSubtle
27+ padding: 0 0 .5rem .8rem
28+ border-bottom: 1px solid gainsboro
29+ border-right: 1px solid gainsboro
30+
31+ transition: all ease-out .5s
32+ :hover {
33+ $colorFontBasic
34+ transition: all ease-out .5s
35+ }
36+
37+ -selected {
38+ $colorFontBasic
39+ border-bottom: 1px solid #fff
40+ }
41+
42+ div.count {
43+ font-size: 3rem
44+ font-weight: 600
45+ margin-right: .5rem
46+ }
47+ strong {
48+ margin-right: .5rem
49+ }
50+ }
51+
52+
53+ div.shares {
54+ border-right: none
55+ }
56+ }
57+
58+ section.graph {
59+ display: flex
60+ flex-wrap: wrap
61+ justify-content: center
62+
63+ margin: 2rem 0
64+
65+ canvas {
66+ margin-bottom: 1rem
67+ }
68+ div.changeRange {
69+ a {
70+ cursor: pointer
71+ :hover { text-decoration: underline }
72+ }
73+ span {
74+ $colorFontSubtle
75+ }
76+ }
77+ }
78+
79+ table.blogs {
80+ width: 100%
81+ margin: 1rem 0 4rem
82+
83+ thead {
84+ tr {
85+ margin-bottom: 1rem
86+ color: hsl(0, 0%, 25%)
87+ th.details {
88+ width: 70%
89+ padding: 0 2rem 0 0
90+ }
91+ th.comments, th.likes, th.shares {
92+ $colorFontSubtle
93+ width: 10%
94+ }
95+ }
96+ }
97+ tbody {
98+ tr.blog {
99+ margin-bottom: 1rem
100+ td {
101+ border-bottom: 1px solid rgba(0, 0, 0, .05)
102+ }
103+
104+ td.details {
105+ width: 70%
106+ padding: .8rem 2rem .8rem 0
107+
108+ div.title {
109+ font-size: 1.3rem
110+ font-weight: 600
111+ cursor: pointer
112+ }
113+
114+ a {
115+ $colorFontSubtle
116+ letter-spacing: .8px
117+ font-size: .7rem
118+ text-decoration: none
119+
120+ :hover {
121+ text-decoration: underline
122+ }
123+ }
124+ }
125+ td.comments, td.likes, td.shares {
126+ width: 10%
127+ /* padding: 0 2.5rem */
128+ font-size: 1.3rem
129+ font-weight: 600
130+ text-align: center
131+ }
132+
133+ -background {
134+ filter: opacity(40%)
135+ }
136+ }
137+ }
138+ }
139+ }
140+ }
141+}
background-process.jsView
@@ -21,23 +21,21 @@
2121 .use(require('ssb-about'))
2222 // .use(require('ssb-ebt'))
2323 .use(require('ssb-ws'))
2424 .use(require('ssb-server-channel'))
25+ .use(require('./ssb-server-blog-stats'))
2526
2627 Client(config.keys, config, (err, ssbServer) => {
27- if (ssbServer === undefined) {
28+ if (err) {
2829 console.log('> starting sbot')
2930 var sbot = createSbot(config)
3031
3132 console.log(' > updating updating manifest.json')
3233 var manifest = sbot.getManifest()
3334 fs.writeFileSync(Path.join(config.path, 'manifest.json'), JSON.stringify(manifest))
3435 electron.ipcRenderer.send('server-started')
35- }
36- else {
36+ } else {
3737 console.log('> sbot running elsewhere')
3838 electron.ipcRenderer.send('server-started')
3939 // TODO send some warning to the client side
4040 }
4141 })
42-
43-
blog/sync/isBlog.jsView
@@ -1,6 +1,7 @@
11 const nest = require('depnest')
22 const get = require('lodash/get')
3+const isBlog = require('scuttle-blog/isBlog')
34
45 exports.gives = nest({
56 'blog.sync.isBlog': true,
67 })
@@ -8,14 +9,15 @@
89 const MIN_LENGTH_FOR_BLOG_POST = 800
910
1011 exports.create = function (api) {
1112 return nest({
12- 'blog.sync.isBlog': isBlog
13+ 'blog.sync.isBlog': isBloggy
1314 })
1415
15- function isBlog (msg) {
16+ function isBloggy (msg) {
17+ if (isBlog(msg)) return true
18+
1619 const type = msg.value.content.type
17- if (type === 'blog') return true
1820 if (type === 'post' && get(msg, 'value.content.text', '').length > MIN_LENGTH_FOR_BLOG_POST) return true
1921 return false
2022 }
2123 }
package-lock.jsonView
The diff is too large to show. Use a local git client to view these changes.
Old file size: 239110 bytes
New file size: 250843 bytes
package.jsonView
@@ -20,13 +20,15 @@
2020 },
2121 "author": "",
2222 "license": "GPL-3.0",
2323 "dependencies": {
24+ "chart.js": "^2.7.2",
2425 "cross-script": "^1.0.5",
2526 "depject": "^4.1.1",
2627 "depnest": "^1.3.0",
2728 "electron-default-menu": "^1.0.1",
2829 "electron-window-state": "^4.1.1",
30+ "flumeview-level": "^3.0.2",
2931 "font-awesome": "^4.7.0",
3032 "html-escape": "^2.0.0",
3133 "human-time": "0.0.1",
3234 "hyper-nav": "^2.0.0",
@@ -53,8 +55,9 @@
5355 "pull-obv": "^1.3.0",
5456 "pull-stream": "^3.6.0",
5557 "read-directory": "^2.1.0",
5658 "require-style": "^1.0.1",
59+ "scuttle-blog": "^1.0.0",
5760 "scuttlebot": "10.4.10",
5861 "secret-stack": "4.0.1",
5962 "setimmediate": "^1.0.5",
6063 "ssb-about": "^0.1.0",
router/sync/routes.jsView
@@ -23,8 +23,9 @@
2323 'app.page.userEdit': 'first',
2424 // 'app.page.userFind': 'first',
2525 'app.page.userShow': 'first',
2626 'app.page.splash': 'first',
27+ 'app.page.statsShow': 'first',
2728 'app.page.threadNew': 'first',
2829 'app.page.threadShow': 'first',
2930 // 'app.page.image': 'first',
3031 'blob.sync.url': 'first'
@@ -50,12 +51,15 @@
5051 !get(location, 'value.private') // treats public posts as 'blogs'
5152 }, pages.blogShow ],
5253
5354 // Channel related pages
54- [ location => location.page === 'channelSubscriptions', pages.channelSubscriptions],
55+ [ location => location.page === 'channelSubscriptions', pages.channelSubscriptions ],
5556 [ location => location.page === 'channelShow', pages.channelShow ],
5657 [ location => location.channel, pages.channelShow ],
5758
59+ // Stats pages
60+ [ location => location.page === 'statsShow', pages.statsShow ],
61+
5862 // AddressBook pages
5963 [ location => location.page === 'addressBook', pages.addressBook ],
6064
6165 // Private Thread pages
styles/mixins.jsView
@@ -63,9 +63,9 @@
6363 color: #2f63ad
6464 }
6565
6666 $colorFontSubtle {
67- color: #999
67+ color: hsla(0, 0%, 52%, 1)
6868 }
6969
7070 $backgroundPrimary {
7171 background-color: #f5f6f7
translations/en.jsView
@@ -1,5 +1,8 @@
11 module.exports = {
2+ comments: 'Comments',
3+ likes: 'Likes',
4+ shares: 'Shares',
25 splash: {
36 about: [
47 'A social network that values openness, equality, and freedom.',
58 'A new social network for people seeking an equitable world that values the value people create and successfully balances freedom, solidarity, privacy, and openness.',
@@ -157,8 +160,14 @@
157160 state: {
158161 noSubscriptions: 'You have no subscriptions yet'
159162 }
160163 },
164+ statsShow: {
165+ title: 'Stats',
166+ prevMonth: 'Prev 30 days',
167+ nextMonth: 'Next 30 days',
168+ thirtyDays: '30 days',
169+ },
161170 languages: {
162171 en: 'English',
163172 zh: '中文'
164173 }
config.jsView
@@ -1,23 +1,0 @@
1-const Config = require('ssb-config/inject')
2-const nest = require('depnest')
3-const ssbKeys = require('ssb-keys')
4-const Path = require('path')
5-
6-// const appName = process.env.ssb_appname || 'ticktack' //'ticktack' TEMP: this is for the windowsSSB installer only
7-const appName = process.env.ssb_appname || 'ssb'
8-var opts = appName === 'ssb' ? require('./ssb-config.json') : require('./default-config')
9-
10-exports.gives = nest('config.sync.load')
11-exports.create = (api) => {
12- var config
13- return nest('config.sync.load', () => {
14- if (!config) {
15- config = Config(appName, opts)
16- config.keys = ssbKeys.loadOrCreateSync(Path.join(config.path, 'secret'))
17-
18- // HACK: fix offline on windows by specifying 127.0.0.1 instead of localhost (default)
19- config.remote = `net:127.0.0.1:${config.port}~shs:${config.keys.id.slice(1).replace('.ed25519', '')}`
20- }
21- return config
22- })
23-}
config/chart.jsView
@@ -1,0 +1,59 @@
1+const { resolve } = require('mutant')
2+
3+const DAY = 24 * 60 * 60 * 1000
4+
5+module.exports = function chartConfig ({ context }) {
6+ const { lower, upper } = resolve(context.range)
7+
8+ // Ticktack Primary color:'hsla(215, 57%, 43%, 1)',
9+ const barColor = 'hsla(215, 57%, 60%, 1)'
10+
11+ return {
12+ type: 'bar',
13+ data: {
14+ datasets: [{
15+ backgroundColor: barColor,
16+ borderColor: barColor,
17+ data: []
18+ }]
19+ },
20+ options: {
21+ legend: {
22+ display: false
23+ },
24+ scales: {
25+ xAxes: [{
26+ type: 'time',
27+ distribution: 'linear',
28+ time: {
29+ unit: 'day',
30+ min: lower,
31+ max: upper,
32+ tooltipFormat: 'MMMM D',
33+ stepSize: 7
34+ },
35+ bounds: 'ticks',
36+ ticks: {
37+ // maxTicksLimit: 4
38+ },
39+ gridLines: {
40+ display: false
41+ },
42+ maxBarThickness: 20
43+ }],
44+
45+ yAxes: [{
46+ ticks: {
47+ min: 0,
48+ suggestedMax: 10,
49+ // max: Math.max(localMax, 10),
50+ stepSize: 5
51+ }
52+ }]
53+ },
54+ animation: {
55+ // duration: 300
56+ }
57+ }
58+ }
59+}
config/config-custom.jsonView
@@ -1,0 +1,12 @@
1+{
2+ "_port": 43750,
3+ "_blobsPort": 43751,
4+ "_ws": { "port": 43751 },
5+ "_caps": {"shs": "ErgQF85hFQpUXp69IXtLW+nXDgFIOKKDOWFX/st2aWk="},
6+ "autoinvites": [
7+ "net:128.199.76.241:8008~shs:7xMrWP8708+LDvaJrRMRQJEixWYp4Oipa9ohqY7+NyQ=:oxWZicO67cnXBRyL/VorYknQK8BHkBnj6IRQFXgjGoA=",
8+
9+ "138.68.27.255:8008:@MflVZCcOBOUe6BLrm/8TyirkTu9/JtdnIJALcd8v5bc=.ed25519~Mfz6xcajHDtH3Z2Dp4I7HT7K1l0MWxJxOftlEBct8jU="
10+ ]
11+}
12+
config/config-ssb.jsonView
@@ -1,0 +1,7 @@
1+{
2+ "autoinvites": [
3+ "net:128.199.76.241:8008~shs:7xMrWP8708+LDvaJrRMRQJEixWYp4Oipa9ohqY7+NyQ=:oxWZicO67cnXBRyL/VorYknQK8BHkBnj6IRQFXgjGoA=",
4+ "138.68.27.255:8008:@MflVZCcOBOUe6BLrm/8TyirkTu9/JtdnIJALcd8v5bc=.ed25519~Mfz6xcajHDtH3Z2Dp4I7HT7K1l0MWxJxOftlEBct8jU="
5+ ]
6+}
7+
config/index.jsView
@@ -1,0 +1,23 @@
1+const Config = require('ssb-config/inject')
2+const nest = require('depnest')
3+const ssbKeys = require('ssb-keys')
4+const Path = require('path')
5+
6+// const appName = process.env.ssb_appname || 'ticktack' //'ticktack' TEMP: this is for the windowsSSB installer only
7+const appName = process.env.ssb_appname || 'ssb'
8+var opts = appName === 'ssb' ? require('./config-ssb.json') : require('./config-custom.json')
9+
10+exports.gives = nest('config.sync.load')
11+exports.create = (api) => {
12+ var config
13+ return nest('config.sync.load', () => {
14+ if (!config) {
15+ config = Config(appName, opts)
16+ config.keys = ssbKeys.loadOrCreateSync(Path.join(config.path, 'secret'))
17+
18+ // HACK: fix offline on windows by specifying 127.0.0.1 instead of localhost (default)
19+ config.remote = `net:127.0.0.1:${config.port}~shs:${config.keys.id.slice(1).replace('.ed25519', '')}`
20+ }
21+ return config
22+ })
23+}
default-config.jsonView
@@ -1,12 +1,0 @@
1-{
2- "_port": 43750,
3- "_blobsPort": 43751,
4- "_ws": { "port": 43751 },
5- "_caps": {"shs": "ErgQF85hFQpUXp69IXtLW+nXDgFIOKKDOWFX/st2aWk="},
6- "autoinvites": [
7- "net:128.199.76.241:8008~shs:7xMrWP8708+LDvaJrRMRQJEixWYp4Oipa9ohqY7+NyQ=:oxWZicO67cnXBRyL/VorYknQK8BHkBnj6IRQFXgjGoA=",
8-
9- "138.68.27.255:8008:@MflVZCcOBOUe6BLrm/8TyirkTu9/JtdnIJALcd8v5bc=.ed25519~Mfz6xcajHDtH3Z2Dp4I7HT7K1l0MWxJxOftlEBct8jU="
10- ]
11-}
12-
ssb-config.jsonView
@@ -1,7 +1,0 @@
1-{
2- "autoinvites": [
3- "net:128.199.76.241:8008~shs:7xMrWP8708+LDvaJrRMRQJEixWYp4Oipa9ohqY7+NyQ=:oxWZicO67cnXBRyL/VorYknQK8BHkBnj6IRQFXgjGoA=",
4- "138.68.27.255:8008:@MflVZCcOBOUe6BLrm/8TyirkTu9/JtdnIJALcd8v5bc=.ed25519~Mfz6xcajHDtH3Z2Dp4I7HT7K1l0MWxJxOftlEBct8jU="
5- ]
6-}
7-
ssb-server-blog-stats.jsView
@@ -1,0 +1,146 @@
1+const FlumeView = require('flumeview-level')
2+const get = require('lodash/get')
3+const pull = require('pull-stream')
4+const isBlog = require('scuttle-blog/isBlog')
5+const { isMsg: isMsgRef } = require('ssb-ref')
6+
7+const getType = (msg) => get(msg, 'value.content.type')
8+const getAuthor = (msg) => get(msg, 'value.author')
9+const getCommentRoot = (msg) => get(msg, 'value.content.root')
10+const getLikeRoot = (msg) => get(msg, 'value.content.vote.link')
11+const getTimestamp = (msg) => get(msg, 'value.timestamp')
12+
13+const FLUME_VIEW_VERSION = 1
14+
15+module.exports = {
16+ name: 'blogStats',
17+ version: 1,
18+ manifest: {
19+ get: 'async',
20+ read: 'source',
21+ readBlogs: 'source',
22+ getBlogs: 'async',
23+ readComments: 'source',
24+ readLikes: 'source'
25+ },
26+ init: (server, config) => {
27+ console.log('initialising blog-stats plugin')
28+ const myKey = server.keys.id
29+
30+ const view = server._flumeUse(
31+ 'internalblogStats',
32+ FlumeView(FLUME_VIEW_VERSION, map)
33+ )
34+
35+ return {
36+ get: view.get,
37+ read: view.read,
38+ readBlogs,
39+ getBlogs,
40+ readComments,
41+ readLikes
42+ // readShares
43+ }
44+
45+ function map (msg, seq) {
46+ var root
47+
48+ switch (getType(msg)) {
49+ case 'blog':
50+ if (isBlog(msg) && isMyMsg(msg)) return [['B', msg.key, getTimestamp(msg)]]
51+ else return []
52+
53+ case 'vote':
54+ root = getLikeRoot(msg)
55+ // TODO figure out how to only store likes I care about
56+ if (root) return [['L', root, getTimestamp(msg)]]
57+ else return []
58+
59+ // Note this catches:
60+ // - all likes, on all things D:
61+ // - likes AND unlikes
62+
63+ case 'post':
64+ root = getCommentRoot(msg)
65+ // TODO figure out how to only store comments I care about
66+ if (!root && isMyMsg(msg) && isPlog(msg)) return [['B', msg.key, getTimestamp(msg)]]
67+ else if (root) return [['C', root, getTimestamp(msg)]]
68+ else return []
69+
70+ // Note this catches:
71+ // - all comments, on all things D:
72+
73+ default:
74+ return []
75+ }
76+ }
77+
78+ // a Plog is a Blog shaped Post!
79+ function isPlog (msg) {
80+ // if (get(msg, 'value.content.text', '').length >= 2500) console.log(get(msg, 'value.content.text', '').length)
81+ return get(msg, 'value.content.text', '').length >= 2500
82+ }
83+
84+ function readBlogs (options = {}) {
85+ const query = Object.assign({}, {
86+ gte: ['B', null, null],
87+ // null is the 'minimum' structure in bytewise ordering
88+ lte: ['B', undefined, undefined],
89+ reverse: true,
90+ values: true,
91+ keys: false,
92+ seqs: false
93+ }, options)
94+
95+ return view.read(query)
96+ }
97+
98+ function getBlogs (options, cb) {
99+ pull(
100+ readBlogs(options),
101+ pull.collect(cb)
102+ )
103+ }
104+
105+ function readComments (blog, options = {}) {
106+ var key = getBlogKey(blog)
107+
108+ const query = Object.assign({}, {
109+ gt: ['C', key, null],
110+ lt: ['C', key, undefined],
111+ // undefined is the 'maximum' structure in bytewise ordering https://www.npmjs.com/package/bytewise#order-of-supported-structures
112+ reverse: true,
113+ values: true,
114+ keys: false,
115+ seqs: false
116+ }, options)
117+
118+ return view.read(query)
119+ }
120+
121+ function readLikes (blog, options = {}) {
122+ var key = getBlogKey(blog)
123+
124+ const query = Object.assign({}, {
125+ gt: ['L', key, null],
126+ lt: ['L', key, undefined],
127+ reverse: true,
128+ values: true,
129+ keys: false,
130+ seqs: false
131+ }, options)
132+
133+ return view.read(query)
134+ }
135+
136+ function getBlogKey (blog) {
137+ if (isMsgRef(blog)) return blog
138+ // else if (isMsgRef(blog.key) && isBlog(blog)) return blog.key
139+ else if (isMsgRef(blog.key) && (isBlog(blog) || isPlog(blog))) return blog.key
140+ }
141+
142+ function isMyMsg (msg) {
143+ return getAuthor(msg) === myKey
144+ }
145+ }
146+}

Built with git-ssb-web