git ssb

16+

Dominic / patchbay



Tree: ed0d0518717fe16a686399588766fb35b3e6e37a

Files: ed0d0518717fe16a686399588766fb35b3e6e37a / app / page / posts.js

13030 bytesRaw
1const nest = require('depnest')
2const { h, Value, Array: MutantArray, Struct, computed, when, map } = require('mutant')
3const pull = require('pull-stream')
4const pullAbortable = require('pull-abortable')
5const next = require('pull-next-query')
6const Scroller = require('mutant-scroll')
7const merge = require('lodash/merge')
8const get = require('lodash/get')
9const sort = require('ssb-sort')
10
11exports.gives = nest({
12 'app.html.menuItem': true,
13 'app.page.posts': true
14})
15
16exports.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
31exports.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
384function getRoot (msg) {
385 return get(msg, 'value.content.root', msg.key)
386}
387
388function isPost (msg) {
389 return get(msg, 'value.content.type') === 'post'
390}
391
392function 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
399function lastFew (arr) {
400 return arr.reverse().slice(0, 3).reverse()
401}
402
403// copied from app.html.scroller
404function 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