git ssb

16+

Dominic / patchbay



Tree: 208be5e878807785bd047484e2d277826aa5f4c7

Files: 208be5e878807785bd047484e2d277826aa5f4c7 / app / page / posts.js

12653 bytesRaw
1const nest = require('depnest')
2const { h, Value, Array: MutantArray, Struct, computed, when, map } = require('mutant')
3const pull = require('pull-stream')
4const Scroller = require('mutant-scroll')
5const next = require('pull-next-query')
6const merge = require('lodash/merge')
7const get = require('lodash/get')
8const sort = require('ssb-sort')
9
10exports.gives = nest({
11 'app.html.menuItem': true,
12 'app.page.posts': true
13})
14
15exports.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
30exports.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.keyboardScroll = 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 - do a paraMap on the createStream which pre-filters what should be displayable
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
371function getRoot (msg) {
372 return get(msg, 'value.content.root', msg.key)
373}
374
375function isPost (msg) {
376 return get(msg, 'value.content.type') === 'post'
377}
378
379function 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
386function lastFew (arr) {
387 return arr.reverse().slice(0, 3).reverse()
388}
389
390// copied from app.html.scroller
391function 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