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