git ssb

16+

Dominic / patchbay



Tree: 8a51c32a71a79eaf5aacc86fda73831f1a6356dc

Files: 8a51c32a71a79eaf5aacc86fda73831f1a6356dc / app / page / posts.js

12484 bytesRaw
1const nest = require('depnest')
2const { h, Value, Array: MutantArray, Struct, computed, when, map } = require('mutant')
3const pull = require('pull-stream')
4pull.paramap = require('pull-paramap')
5const Scroller = require('mutant-scroll')
6const next = require('pull-next-query')
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 '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 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 = '{"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 { feedId, 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
372function getRoot (msg) {
373 return get(msg, 'value.content.root', msg.key)
374}
375
376function isPost (msg) {
377 return get(msg, 'value.content.type') === 'post'
378}
379
380function 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
387function lastFew (arr) {
388 return arr.reverse().slice(0, 3).reverse()
389}
390
391// copied from app.html.scroller
392function 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