Commit 1b46fe809a87f906b19ab7dad059971d13730b4f
Merge pull request #132 from ticktackim/stats-page
stats pagemix 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.js | changed |
app/html/header.mcss | changed |
app/index.js | changed |
app/page/statsShow.js | added |
app/page/statsShow.mcss | added |
background-process.js | changed |
blog/sync/isBlog.js | changed |
package-lock.json | changed |
package.json | changed |
router/sync/routes.js | changed |
styles/mixins.js | changed |
translations/en.js | changed |
config.js | deleted |
config/chart.js | added |
config/config-custom.json | added |
config/config-ssb.json | added |
config/index.js | added |
default-config.json | deleted |
ssb-config.json | deleted |
ssb-server-blog-stats.js | added |
app/html/header.js | ||
---|---|---|
@@ -38,8 +38,11 @@ | ||
38 | 38 | }), |
39 | 39 | h('img.settings', { |
40 | 40 | src: when(isSettings, assetPath('settings_on.png'), assetPath('settings.png')), |
41 | 41 | 'ev-click': () => push({page: 'settings'}) |
42 | + }), | |
43 | + h('i.fa.fa-bell', { | |
44 | + 'ev-click': () => push({page: 'statsShow'}) | |
42 | 45 | }) |
43 | 46 | ]) |
44 | 47 | ]) |
45 | 48 | }) |
app/html/header.mcss | ||
---|---|---|
@@ -28,9 +28,9 @@ | ||
28 | 28 | display: flex |
29 | 29 | align-items: center |
30 | 30 | justify-content: center |
31 | 31 | |
32 | - img { | |
32 | + img, i { | |
33 | 33 | cursor: pointer |
34 | 34 | text-decoration: none |
35 | 35 | |
36 | 36 | margin: 0 2rem |
app/index.js | ||
---|---|---|
@@ -46,8 +46,9 @@ | ||
46 | 46 | userEdit: require('./page/userEdit'), |
47 | 47 | // userFind: require('./page/userFind'), |
48 | 48 | userShow: require('./page/userShow'), |
49 | 49 | splash: require('./page/splash'), |
50 | + statsShow: require('./page/statsShow'), | |
50 | 51 | threadNew: require('./page/threadNew'), |
51 | 52 | threadShow: require('./page/threadShow') |
52 | 53 | }, |
53 | 54 | sync: { |
app/page/statsShow.js | ||
---|---|---|
@@ -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.mcss | ||
---|---|---|
@@ -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.js | ||
---|---|---|
@@ -21,23 +21,21 @@ | ||
21 | 21 | .use(require('ssb-about')) |
22 | 22 | // .use(require('ssb-ebt')) |
23 | 23 | .use(require('ssb-ws')) |
24 | 24 | .use(require('ssb-server-channel')) |
25 | + .use(require('./ssb-server-blog-stats')) | |
25 | 26 | |
26 | 27 | Client(config.keys, config, (err, ssbServer) => { |
27 | - if (ssbServer === undefined) { | |
28 | + if (err) { | |
28 | 29 | console.log('> starting sbot') |
29 | 30 | var sbot = createSbot(config) |
30 | 31 | |
31 | 32 | console.log(' > updating updating manifest.json') |
32 | 33 | var manifest = sbot.getManifest() |
33 | 34 | fs.writeFileSync(Path.join(config.path, 'manifest.json'), JSON.stringify(manifest)) |
34 | 35 | electron.ipcRenderer.send('server-started') |
35 | - } | |
36 | - else { | |
36 | + } else { | |
37 | 37 | console.log('> sbot running elsewhere') |
38 | 38 | electron.ipcRenderer.send('server-started') |
39 | 39 | // TODO send some warning to the client side |
40 | 40 | } |
41 | 41 | }) |
42 | - | |
43 | - |
blog/sync/isBlog.js | ||
---|---|---|
@@ -1,6 +1,7 @@ | ||
1 | 1 | const nest = require('depnest') |
2 | 2 | const get = require('lodash/get') |
3 | +const isBlog = require('scuttle-blog/isBlog') | |
3 | 4 | |
4 | 5 | exports.gives = nest({ |
5 | 6 | 'blog.sync.isBlog': true, |
6 | 7 | }) |
@@ -8,14 +9,15 @@ | ||
8 | 9 | const MIN_LENGTH_FOR_BLOG_POST = 800 |
9 | 10 | |
10 | 11 | exports.create = function (api) { |
11 | 12 | return nest({ |
12 | - 'blog.sync.isBlog': isBlog | |
13 | + 'blog.sync.isBlog': isBloggy | |
13 | 14 | }) |
14 | 15 | |
15 | - function isBlog (msg) { | |
16 | + function isBloggy (msg) { | |
17 | + if (isBlog(msg)) return true | |
18 | + | |
16 | 19 | const type = msg.value.content.type |
17 | - if (type === 'blog') return true | |
18 | 20 | if (type === 'post' && get(msg, 'value.content.text', '').length > MIN_LENGTH_FOR_BLOG_POST) return true |
19 | 21 | return false |
20 | 22 | } |
21 | 23 | } |
package-lock.json | ||
---|---|---|
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.json | ||
---|---|---|
@@ -20,13 +20,15 @@ | ||
20 | 20 | }, |
21 | 21 | "author": "", |
22 | 22 | "license": "GPL-3.0", |
23 | 23 | "dependencies": { |
24 | + "chart.js": "^2.7.2", | |
24 | 25 | "cross-script": "^1.0.5", |
25 | 26 | "depject": "^4.1.1", |
26 | 27 | "depnest": "^1.3.0", |
27 | 28 | "electron-default-menu": "^1.0.1", |
28 | 29 | "electron-window-state": "^4.1.1", |
30 | + "flumeview-level": "^3.0.2", | |
29 | 31 | "font-awesome": "^4.7.0", |
30 | 32 | "html-escape": "^2.0.0", |
31 | 33 | "human-time": "0.0.1", |
32 | 34 | "hyper-nav": "^2.0.0", |
@@ -53,8 +55,9 @@ | ||
53 | 55 | "pull-obv": "^1.3.0", |
54 | 56 | "pull-stream": "^3.6.0", |
55 | 57 | "read-directory": "^2.1.0", |
56 | 58 | "require-style": "^1.0.1", |
59 | + "scuttle-blog": "^1.0.0", | |
57 | 60 | "scuttlebot": "10.4.10", |
58 | 61 | "secret-stack": "4.0.1", |
59 | 62 | "setimmediate": "^1.0.5", |
60 | 63 | "ssb-about": "^0.1.0", |
router/sync/routes.js | ||
---|---|---|
@@ -23,8 +23,9 @@ | ||
23 | 23 | 'app.page.userEdit': 'first', |
24 | 24 | // 'app.page.userFind': 'first', |
25 | 25 | 'app.page.userShow': 'first', |
26 | 26 | 'app.page.splash': 'first', |
27 | + 'app.page.statsShow': 'first', | |
27 | 28 | 'app.page.threadNew': 'first', |
28 | 29 | 'app.page.threadShow': 'first', |
29 | 30 | // 'app.page.image': 'first', |
30 | 31 | 'blob.sync.url': 'first' |
@@ -50,12 +51,15 @@ | ||
50 | 51 | !get(location, 'value.private') // treats public posts as 'blogs' |
51 | 52 | }, pages.blogShow ], |
52 | 53 | |
53 | 54 | // Channel related pages |
54 | - [ location => location.page === 'channelSubscriptions', pages.channelSubscriptions], | |
55 | + [ location => location.page === 'channelSubscriptions', pages.channelSubscriptions ], | |
55 | 56 | [ location => location.page === 'channelShow', pages.channelShow ], |
56 | 57 | [ location => location.channel, pages.channelShow ], |
57 | 58 | |
59 | + // Stats pages | |
60 | + [ location => location.page === 'statsShow', pages.statsShow ], | |
61 | + | |
58 | 62 | // AddressBook pages |
59 | 63 | [ location => location.page === 'addressBook', pages.addressBook ], |
60 | 64 | |
61 | 65 | // Private Thread pages |
styles/mixins.js | ||
---|---|---|
@@ -63,9 +63,9 @@ | ||
63 | 63 | color: #2f63ad |
64 | 64 | } |
65 | 65 | |
66 | 66 | $colorFontSubtle { |
67 | - color: #999 | |
67 | + color: hsla(0, 0%, 52%, 1) | |
68 | 68 | } |
69 | 69 | |
70 | 70 | $backgroundPrimary { |
71 | 71 | background-color: #f5f6f7 |
translations/en.js | ||
---|---|---|
@@ -1,5 +1,8 @@ | ||
1 | 1 | module.exports = { |
2 | + comments: 'Comments', | |
3 | + likes: 'Likes', | |
4 | + shares: 'Shares', | |
2 | 5 | splash: { |
3 | 6 | about: [ |
4 | 7 | 'A social network that values openness, equality, and freedom.', |
5 | 8 | '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 @@ | ||
157 | 160 | state: { |
158 | 161 | noSubscriptions: 'You have no subscriptions yet' |
159 | 162 | } |
160 | 163 | }, |
164 | + statsShow: { | |
165 | + title: 'Stats', | |
166 | + prevMonth: 'Prev 30 days', | |
167 | + nextMonth: 'Next 30 days', | |
168 | + thirtyDays: '30 days', | |
169 | + }, | |
161 | 170 | languages: { |
162 | 171 | en: 'English', |
163 | 172 | zh: '中文' |
164 | 173 | } |
config.js | ||
---|---|---|
@@ -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.js | ||
---|---|---|
@@ -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.json | ||
---|---|---|
@@ -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.json | ||
---|---|---|
@@ -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.js | ||
---|---|---|
@@ -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.json | ||
---|---|---|
@@ -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.json | ||
---|---|---|
@@ -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.js | ||
---|---|---|
@@ -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