Files: 30af2ef391b12dddb977c5c3dce6ba556f6f91f1 / app / page / posts.js
6834 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 | // 'feed.pull.public': 'first', |
21 | 'sbot.async.get': 'first', |
22 | 'sbot.pull.stream': 'first', |
23 | 'message.html.compose': 'first', |
24 | 'message.html.markdown': 'first', |
25 | 'message.html.timestamp': 'first', |
26 | 'message.obs.backlinks': 'first' |
27 | }) |
28 | |
29 | exports.create = function (api) { |
30 | return nest({ |
31 | 'app.html.menuItem': menuItem, |
32 | 'app.page.posts': postsPage |
33 | }) |
34 | |
35 | function menuItem () { |
36 | return h('a', { |
37 | style: { order: 1 }, |
38 | 'ev-click': () => api.app.sync.goTo({ page: 'posts' }) |
39 | }, '/posts') |
40 | } |
41 | |
42 | function postsPage (location) { |
43 | const BY_UPDATE = 'by_update' |
44 | const BY_ROOT = 'by_root' |
45 | |
46 | const composer = api.message.html.compose({ |
47 | location, |
48 | meta: { type: 'post' }, |
49 | placeholder: 'Write a public message' |
50 | }) |
51 | |
52 | const store = MutantArray([]) |
53 | |
54 | const page = Scroller({ |
55 | classList: ['Posts'], |
56 | prepend: [ |
57 | composer |
58 | ], |
59 | streamToTop: createStream({ live: true, old: false }), |
60 | streamToBottom: createStream({ reverse: true }), |
61 | store, |
62 | updateTop: (soFar, msg) => { |
63 | const root = getRoot(msg) |
64 | if (soFar.includes(root)) soFar.delete(root) |
65 | soFar.insert(root) |
66 | }, |
67 | updateBottom: (soFar, msg) => { |
68 | const root = getRoot(msg) |
69 | if (!soFar.includes(root)) soFar.push(root) |
70 | }, |
71 | render |
72 | }) |
73 | |
74 | function createStream (opts) { |
75 | return api.sbot.pull.stream(server => { |
76 | // by_update - stream by receive time |
77 | const defaults = { |
78 | limit: 50, |
79 | query: [{ |
80 | $filter: { |
81 | timestamp: { $gt: 0 }, |
82 | value: { |
83 | content: { |
84 | type: 'post', |
85 | recps: { $is: 'undefined' } |
86 | } |
87 | } |
88 | } |
89 | }] |
90 | } |
91 | return next(server.query.read, merge({}, defaults, opts), ['timestamp']) |
92 | }) |
93 | } |
94 | |
95 | page.title = '/posts' |
96 | page.scroll = keyscroll(page.querySelector('section.content')) |
97 | return page |
98 | } |
99 | |
100 | // TODO - move out into message.html.render ? |
101 | function render (key) { |
102 | const root = Struct({ |
103 | avatar: '', |
104 | author: '', |
105 | timestamp: '', |
106 | md: '' |
107 | }) |
108 | api.sbot.async.get(key, (err, value) => { |
109 | if (err) console.error('ThreadCard could not fetch ', key) |
110 | root.avatar.set(api.about.html.avatar(value.author)) |
111 | root.author.set(api.about.html.link(value.author)) |
112 | root.timestamp.set(api.message.html.timestamp({ key, value })) |
113 | root.md.set(api.message.html.markdown(value.content)) |
114 | }) |
115 | |
116 | const repliesCount = Value() |
117 | const recent = MutantArray([]) |
118 | const likesCount = Value() |
119 | const backlinksCount = Value() |
120 | const participants = MutantArray([]) |
121 | |
122 | const opts = { |
123 | query: [{ |
124 | $filter: { dest: key } |
125 | }], |
126 | index: 'DTA' // asserted timestamp |
127 | } |
128 | pull( |
129 | api.sbot.pull.stream(server => server.backlinks.read(opts)), |
130 | pull.collect((err, msgs) => { |
131 | if (err) console.error(err) |
132 | |
133 | msgs = sort(msgs) |
134 | |
135 | const replies = msgs |
136 | .filter(isPost) |
137 | .filter(m => getRoot(m) === key) |
138 | |
139 | repliesCount.set(replies.length) |
140 | recent.set(lastFew(replies)) |
141 | |
142 | const likes = msgs.filter(isLikeOf(key)) |
143 | likesCount.set(likes.length) |
144 | |
145 | const backlinks = msgs |
146 | .filter(isPost) |
147 | .filter(m => getRoot(m) !== key) |
148 | backlinksCount.set(backlinks.length) |
149 | |
150 | const authors = replies |
151 | .map(m => m.value.author) |
152 | participants.set(Array.from(new Set(authors))) |
153 | }) |
154 | ) |
155 | |
156 | const className = computed(root.md, r => r ? '' : '-loading') |
157 | const onClick = ev => { |
158 | ev.preventDefault() |
159 | ev.stopPropagation() |
160 | api.app.sync.goTo(key) |
161 | } |
162 | |
163 | return h('ThreadCard', |
164 | { |
165 | className, |
166 | attributes: { |
167 | tabindex: '0', // needed to be able to navigate and show focus() |
168 | 'data-id': key // TODO do this with decorators? |
169 | } |
170 | }, [ |
171 | h('section.context', [ |
172 | h('div.avatar', root.avatar), |
173 | h('div.name', root.author), |
174 | h('div.timestamp', root.timestamp), |
175 | h('div.counts', [ |
176 | h('div.comments', [ repliesCount, h('i.fa.fa-comment-o') ]), |
177 | h('div.likes', [ likesCount, h('i.fa.fa-heart-o') ]), |
178 | h('div.backlinks', [ backlinksCount, h('i.fa.fa-link') ]) |
179 | ]), |
180 | h('div.participants', map(participants, api.about.html.avatar)) |
181 | ]), |
182 | h('section.content-preview', { 'ev-click': onClick }, [ |
183 | h('div.root', root.md), |
184 | h('div.recent', map(recent, msg => { |
185 | return h('div.msg', [ |
186 | h('div.author', api.about.obs.name(msg.value.author)), |
187 | ': ', |
188 | h('div.preview', [ |
189 | api.message.html.markdown(msg.value.content).innerText.slice(0, 120), |
190 | '...' |
191 | ]) |
192 | ]) |
193 | })) |
194 | ]) |
195 | ]) |
196 | } |
197 | } |
198 | |
199 | function getRoot (msg) { |
200 | return get(msg, 'value.content.root', msg.key) |
201 | } |
202 | |
203 | function isPost (msg) { |
204 | return get(msg, 'value.content.type') === 'post' |
205 | } |
206 | |
207 | function isLikeOf (key) { |
208 | return function (msg) { |
209 | return get(msg, 'value.content.type') === 'vote' && |
210 | get(msg, 'value.content.vote.link') === key |
211 | } |
212 | } |
213 | |
214 | function lastFew (arr) { |
215 | return arr.reverse().slice(0, 3).reverse() |
216 | } |
217 | |
218 | // copied from app.html.scroller |
219 | function keyscroll (content) { |
220 | var curMsgEl |
221 | |
222 | if (!content) return () => {} |
223 | |
224 | content.addEventListener('click', onActivateChild, false) |
225 | content.addEventListener('focus', onActivateChild, true) |
226 | |
227 | function onActivateChild (ev) { |
228 | for (var el = ev.target; el; el = el.parentNode) { |
229 | if (el.parentNode === content) { |
230 | curMsgEl = el |
231 | return |
232 | } |
233 | } |
234 | } |
235 | |
236 | return function scroll (d) { |
237 | selectChild((!curMsgEl || d === 'first') ? content.firstChild |
238 | : d < 0 ? curMsgEl.previousElementSibling || content.firstChild |
239 | : d > 0 ? curMsgEl.nextElementSibling || content.lastChild |
240 | : curMsgEl) |
241 | |
242 | return curMsgEl |
243 | } |
244 | |
245 | function selectChild (el) { |
246 | if (!el) { return } |
247 | |
248 | if (!el.scrollIntoViewIfNeeded && !el.scrollIntoView) return |
249 | ;(el.scrollIntoViewIfNeeded || el.scrollIntoView).call(el) |
250 | el.focus() |
251 | curMsgEl = el |
252 | } |
253 | } |
254 | |
255 |
Built with git-ssb-web