Files: ed0d0518717fe16a686399588766fb35b3e6e37a / app / page / posts.js
13030 bytesRaw
1 | const nest = require('depnest') |
2 | const { h, Value, Array: MutantArray, Struct, computed, when, map } = require('mutant') |
3 | const pull = require('pull-stream') |
4 | const pullAbortable = require('pull-abortable') |
5 | const next = require('pull-next-query') |
6 | const Scroller = require('mutant-scroll') |
7 | const merge = require('lodash/merge') |
8 | const get = require('lodash/get') |
9 | const sort = require('ssb-sort') |
10 | |
11 | exports.gives = nest({ |
12 | 'app.html.menuItem': true, |
13 | 'app.page.posts': true |
14 | }) |
15 | |
16 | exports.needs = nest({ |
17 | 'about.obs.name': 'first', |
18 | 'about.html.avatar': 'first', |
19 | 'about.html.link': 'first', |
20 | 'app.sync.goTo': 'first', |
21 | 'app.sync.locationId': 'first', |
22 | 'keys.sync.id': 'first', |
23 | 'message.html.compose': 'first', |
24 | 'message.html.markdown': 'first', |
25 | 'message.html.timestamp': 'first', |
26 | 'message.obs.backlinks': 'first', |
27 | 'sbot.async.get': 'first', |
28 | 'sbot.pull.stream': 'first' |
29 | }) |
30 | |
31 | exports.create = function (api) { |
32 | return nest({ |
33 | 'app.html.menuItem': menuItem, |
34 | 'app.page.posts': postsPage |
35 | }) |
36 | |
37 | function menuItem () { |
38 | return h('a', { |
39 | 'ev-click': () => api.app.sync.goTo({ page: 'posts' }) |
40 | }, '/posts') |
41 | } |
42 | |
43 | function postsPage (location) { |
44 | const BY_UPDATE = 'Update' |
45 | const BY_START = 'Start' |
46 | |
47 | const state = Struct({ |
48 | sort: Value(BY_UPDATE), |
49 | show: Struct({ |
50 | feedId: Value(api.keys.sync.id()), |
51 | started: Value(true), |
52 | participated: Value(true), |
53 | other: Value(true) |
54 | }) |
55 | }) |
56 | // const feedRoots = getFeedRoots(state.show.feedId) |
57 | |
58 | const viewSettings = h('section.viewSettings', [ |
59 | h('div.show', [ |
60 | h('span', 'Show threads:'), |
61 | h('div.toggle', |
62 | { className: when(state.show.started, '-active'), 'ev-click': () => state.show.started.set(!state.show.started()) }, |
63 | [ h('i.fa.fa-eye'), 'started' ] |
64 | ), |
65 | h('div.toggle', |
66 | { className: when(state.show.participated, '-active'), 'ev-click': () => state.show.participated.set(!state.show.participated()) }, |
67 | [ h('i.fa.fa-eye'), 'participated' ] |
68 | ), |
69 | h('div.toggle', |
70 | { className: when(state.show.other, '-active'), 'ev-click': () => state.show.other.set(!state.show.other()) }, |
71 | [ h('i.fa.fa-eye', {}), 'other' ] |
72 | ) |
73 | ]), |
74 | h('div.sort', [ |
75 | h('span', 'Sort by:'), |
76 | h('button', { |
77 | className: computed(state.sort, s => s === BY_UPDATE ? '-primary' : ''), |
78 | 'ev-click': () => state.sort.set(BY_UPDATE) |
79 | }, BY_UPDATE), |
80 | h('button', { |
81 | className: computed(state.sort, s => s === BY_START ? '-primary' : ''), |
82 | 'ev-click': () => state.sort.set(BY_START) |
83 | }, BY_START) |
84 | ]) |
85 | ]) |
86 | |
87 | var abortLive = pullAbortable() |
88 | var abortReverse = pullAbortable() |
89 | |
90 | return computed(state, state => { |
91 | abortLive.abort() |
92 | abortLive = pullAbortable() |
93 | abortReverse.abort() |
94 | abortReverse = pullAbortable() |
95 | |
96 | var page |
97 | if (state.sort === BY_UPDATE) page = PageByUpdate(state) |
98 | if (state.sort === BY_START) page = PageByStart(state) |
99 | |
100 | page.title = '/posts' |
101 | page.id = api.app.sync.locationId({ page: 'posts' }) // this is needed because our page is a computed |
102 | page.keyboardScroll = keyscroll(page.querySelector('section.content')) |
103 | return page |
104 | }) |
105 | |
106 | function PageByUpdate (state) { |
107 | const createStream = (opts) => { |
108 | const { started, participated, other } = state.show |
109 | if (!started && !participated && !other) return pull.empty() |
110 | |
111 | return api.sbot.pull.stream(server => { |
112 | const $filter = { |
113 | timestamp: { $gt: 0 }, |
114 | value: { |
115 | content: { |
116 | type: 'post', |
117 | recps: { $not: true } |
118 | } |
119 | } |
120 | } |
121 | const defaults = { limit: 100, query: [{ $filter }] } |
122 | return pull( |
123 | next(server.query.read, merge({}, defaults, opts), ['timestamp']), |
124 | opts.live ? abortLive : abortReverse |
125 | ) |
126 | }) |
127 | } |
128 | |
129 | return Scroller({ |
130 | classList: ['Posts'], |
131 | prepend: [ |
132 | viewSettings, |
133 | Composer(location) |
134 | ], |
135 | streamToTop: createStream({ live: true, old: false }), |
136 | streamToBottom: createStream({ reverse: true }), |
137 | updateTop: (soFar, msg) => { |
138 | const root = getRoot(msg) |
139 | if (soFar.includes(root)) soFar.delete(root) |
140 | soFar.insert(root) |
141 | }, |
142 | updateBottom: (soFar, msg) => { |
143 | const root = getRoot(msg) |
144 | if (!soFar.includes(root)) soFar.push(root) |
145 | }, |
146 | render: key => render(state, key) |
147 | }) |
148 | } |
149 | |
150 | function PageByStart (state) { |
151 | const createStream = (opts) => { |
152 | const { feedId, started, participated, other } = state.show |
153 | if (!started && !participated && !other) return pull.empty() |
154 | |
155 | return api.sbot.pull.stream(server => { |
156 | const defaults = { |
157 | limit: 200, |
158 | query: [{ |
159 | $filter: { |
160 | value: { |
161 | timestamp: { $gt: 0 }, |
162 | content: { |
163 | type: 'post', |
164 | root: { $not: true }, // is a root (as doesn't name a root) |
165 | recps: { $not: true } // is public |
166 | } |
167 | } |
168 | } |
169 | }, { |
170 | $map: { |
171 | key: 'key', // this means this stream behvaues same as PageByUpdate (only keys in store) |
172 | value: { |
173 | timestamp: ['value', 'timestamp'] |
174 | } |
175 | } |
176 | }] |
177 | } |
178 | if (started && !participated && !other) { |
179 | defaults.query[0].$filter.value.author = feedId |
180 | } |
181 | |
182 | // server.query.explain(merge({}, defaults, opts), console.log) |
183 | return pull( |
184 | next(server.query.read, merge({}, defaults, opts), ['value', 'timestamp']), |
185 | opts.live ? abortLive : abortReverse, |
186 | pull.map(m => m.key) |
187 | ) |
188 | }) |
189 | } |
190 | |
191 | return Scroller({ |
192 | classList: ['Posts'], |
193 | prepend: [ |
194 | viewSettings, |
195 | Composer(location) |
196 | ], |
197 | streamToTop: createStream({ live: true, old: false }), |
198 | streamToBottom: createStream({ reverse: true }), |
199 | render: key => render(state, key) |
200 | }) |
201 | } |
202 | } |
203 | |
204 | function Composer (location) { |
205 | return api.message.html.compose({ |
206 | location, |
207 | meta: { type: 'post' }, |
208 | placeholder: 'Write a public message' |
209 | }) |
210 | } |
211 | |
212 | // TODO - do a paraMap on the createStream which pre-filters what should be displayable |
213 | function render (state, key) { |
214 | const root = buildRoot(key) |
215 | const { recent, repliesCount, likesCount, backlinksCount, participants } = buildThread(key) |
216 | |
217 | const { feedId, started, participated, other } = state.show |
218 | // throttling? |
219 | const isVisible = computed([root.author, participants], (a, p) => { |
220 | return Boolean( |
221 | (started ? (a === feedId) : null) || |
222 | (participated ? (p.includes(feedId)) : null) || |
223 | (other ? (!p.includes(feedId)) : null) |
224 | ) |
225 | }) |
226 | // NOTE - this filtering could be done more efficiently upstream with some targeted |
227 | // or merged queries. The 'other' case is probably hard to do tidily |
228 | |
229 | const onClick = ev => { |
230 | ev.preventDefault() |
231 | ev.stopPropagation() |
232 | api.app.sync.goTo(key) |
233 | } |
234 | |
235 | return when(root.sync, |
236 | when(isVisible, |
237 | h('ThreadCard', |
238 | { |
239 | // className: computed(root.md, r => r ? '' : '-loading'), |
240 | attributes: { |
241 | tabindex: '0', // needed to be able to navigate and show focus() |
242 | 'data-key': key // TODO do this with decorators? |
243 | } |
244 | }, [ |
245 | h('section.authored', [ |
246 | h('div.avatar', root.avatar), |
247 | h('div.name', root.authorName), |
248 | h('div.timestamp', root.timestamp) |
249 | ]), |
250 | h('section.content-preview', { 'ev-click': onClick }, [ |
251 | h('div.root', root.md), |
252 | h('div.recent', map(recent, msg => { |
253 | return h('div.msg', [ |
254 | h('span.author', api.about.obs.name(msg.value.author)), |
255 | ': ', |
256 | h('span.preview', [ |
257 | api.message.html.markdown(msg.value.content).innerText.slice(0, 120), |
258 | '...' |
259 | ]) |
260 | ]) |
261 | })) |
262 | ]), |
263 | h('section.stats', [ |
264 | h('div.participants', map(participants, api.about.html.avatar)), |
265 | h('div.counts', [ |
266 | h('div.comments', [ repliesCount, h('i.fa.fa-comment-o') ]), |
267 | h('div.likes', [ likesCount, h('i.fa.fa-heart-o') ]), |
268 | h('div.backlinks', [ backlinksCount, h('i.fa.fa-link') ]) |
269 | ]) |
270 | ]) |
271 | ] |
272 | ) |
273 | // h('div', 'non-match') |
274 | ) |
275 | // h('ThreadCard -loading') |
276 | ) |
277 | } |
278 | |
279 | function buildRoot (key) { |
280 | const root = Struct({ |
281 | author: '', |
282 | authorName: '', |
283 | avatar: '', |
284 | timestamp: '', |
285 | md: '' |
286 | }) |
287 | root.sync = Value(false) |
288 | |
289 | api.sbot.async.get(key, (err, value) => { |
290 | if (err) return console.error('ThreadCard could not fetch ', key) |
291 | root.author.set(value.author) |
292 | root.authorName.set(api.about.html.link(value.author)) |
293 | root.avatar.set(api.about.html.avatar(value.author)) |
294 | root.timestamp.set(api.message.html.timestamp({ key, value })) |
295 | root.md.set(api.message.html.markdown(value.content)) |
296 | |
297 | root.sync.set(true) |
298 | }) |
299 | |
300 | return root |
301 | } |
302 | |
303 | function buildThread (key) { |
304 | const recent = MutantArray([]) |
305 | const repliesCount = Value() |
306 | const likesCount = Value() |
307 | const backlinksCount = Value() |
308 | const participants = MutantArray([]) |
309 | |
310 | const opts = { |
311 | query: [{ |
312 | $filter: { dest: key } |
313 | }], |
314 | index: 'DTA' // asserted timestamp |
315 | } |
316 | pull( |
317 | api.sbot.pull.stream(server => server.backlinks.read(opts)), |
318 | pull.collect((err, msgs) => { |
319 | if (err) console.error(err) |
320 | |
321 | msgs = sort(msgs) |
322 | |
323 | const replies = msgs |
324 | .filter(isPost) |
325 | .filter(m => getRoot(m) === key) |
326 | |
327 | repliesCount.set(replies.length) |
328 | recent.set(lastFew(replies)) |
329 | |
330 | const likes = msgs.filter(isLikeOf(key)) |
331 | likesCount.set(likes.length) |
332 | |
333 | const backlinks = msgs |
334 | .filter(isPost) |
335 | .filter(m => getRoot(m) !== key) |
336 | backlinksCount.set(backlinks.length) |
337 | |
338 | const authors = replies |
339 | .map(m => m.value.author) |
340 | participants.set(Array.from(new Set(authors))) |
341 | }) |
342 | ) |
343 | |
344 | return { recent, repliesCount, likesCount, backlinksCount, participants } |
345 | } |
346 | |
347 | // function getFeedRoots (feedId) { |
348 | // const obs = computed(feedId, feedId => { |
349 | // const keys = MutantArray([]) |
350 | // const source = opts => api.sbot.pull.stream(s => s.query.read(opts)) |
351 | // const opts = { |
352 | // query: [{ |
353 | // $filter: { |
354 | // value: { |
355 | // author: feedId, |
356 | // content: { |
357 | // root: { $not: true }, |
358 | // recps: { $not: true } |
359 | // } |
360 | // } |
361 | // } |
362 | // }, { |
363 | // $map: 'key' |
364 | // }], |
365 | // live: true |
366 | // } |
367 | |
368 | // pull( |
369 | // source(opts), |
370 | // pull.drain(k => { |
371 | // if (k.sync) obs.sync.set(true) |
372 | // else keys.push(k) |
373 | // }) |
374 | // ) |
375 | |
376 | // return keys |
377 | // }) |
378 | |
379 | // obs.sync = Value(false) |
380 | // return obs |
381 | // } |
382 | } |
383 | |
384 | function getRoot (msg) { |
385 | return get(msg, 'value.content.root', msg.key) |
386 | } |
387 | |
388 | function isPost (msg) { |
389 | return get(msg, 'value.content.type') === 'post' |
390 | } |
391 | |
392 | function isLikeOf (key) { |
393 | return function (msg) { |
394 | return get(msg, 'value.content.type') === 'vote' && |
395 | get(msg, 'value.content.vote.link') === key |
396 | } |
397 | } |
398 | |
399 | function lastFew (arr) { |
400 | return arr.reverse().slice(0, 3).reverse() |
401 | } |
402 | |
403 | // copied from app.html.scroller |
404 | function keyscroll (content) { |
405 | var curMsgEl |
406 | |
407 | if (!content) return () => {} |
408 | |
409 | content.addEventListener('click', onActivateChild, false) |
410 | content.addEventListener('focus', onActivateChild, true) |
411 | |
412 | function onActivateChild (ev) { |
413 | for (var el = ev.target; el; el = el.parentNode) { |
414 | if (el.parentNode === content) { |
415 | curMsgEl = el |
416 | return |
417 | } |
418 | } |
419 | } |
420 | |
421 | return function scroll (d) { |
422 | selectChild((!curMsgEl || d === 'first') ? content.firstChild |
423 | : d < 0 ? curMsgEl.previousElementSibling || content.firstChild |
424 | : d > 0 ? curMsgEl.nextElementSibling || content.lastChild |
425 | : curMsgEl) |
426 | |
427 | return curMsgEl |
428 | } |
429 | |
430 | function selectChild (el) { |
431 | if (!el) { return } |
432 | |
433 | content.parentElement.scrollTop = el.offsetTop - content.parentElement.offsetTop - 10 |
434 | // if (!el.scrollIntoViewIfNeeded && !el.scrollIntoView) return |
435 | // ;(el.scrollIntoViewIfNeeded || el.scrollIntoView).call(el) |
436 | if (el.focus) el.focus() |
437 | curMsgEl = el |
438 | } |
439 | } |
440 |
Built with git-ssb-web