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