Files: a224084e5f0cf22563242ad706400c3e4a22d5c5 / modules / feed / html / rollup.js
11028 bytesRaw
1 | var nest = require('depnest') |
2 | var {Value, Proxy, Array: MutantArray, h, computed, when, onceTrue, throttle, resolve} = require('mutant') |
3 | var pull = require('pull-stream') |
4 | var Abortable = require('pull-abortable') |
5 | var Scroller = require('../../../lib/scroller') |
6 | var nextStepper = require('../../../lib/next-stepper') |
7 | var extend = require('xtend') |
8 | var paramap = require('pull-paramap') |
9 | |
10 | var bumpMessages = { |
11 | 'vote': 'liked this message', |
12 | 'post': 'replied to this message', |
13 | 'about': 'added changes', |
14 | 'mention': 'mentioned you', |
15 | 'channel-mention': 'mentioned this channel' |
16 | } |
17 | |
18 | // bump even for first message |
19 | var rootBumpTypes = ['mention', 'channel-mention'] |
20 | |
21 | exports.needs = nest({ |
22 | 'about.obs.name': 'first', |
23 | 'app.sync.externalHandler': 'first', |
24 | 'message.html.canRender': 'first', |
25 | 'message.html.render': 'first', |
26 | 'message.sync.isBlocked': 'first', |
27 | 'message.sync.unbox': 'first', |
28 | 'message.sync.timestamp': 'first', |
29 | 'profile.html.person': 'first', |
30 | 'message.html.link': 'first', |
31 | 'message.sync.root': 'first', |
32 | 'feed.pull.rollup': 'first', |
33 | 'feed.pull.withReplies': 'first', |
34 | 'feed.pull.unique': 'first', |
35 | 'sbot.async.get': 'first', |
36 | 'keys.sync.id': 'first', |
37 | 'intl.sync.i18n': 'first', |
38 | 'message.html.missing': 'first' |
39 | }) |
40 | |
41 | exports.gives = nest({ |
42 | 'feed.html.rollup': true |
43 | }) |
44 | |
45 | exports.create = function (api) { |
46 | const i18n = api.intl.sync.i18n |
47 | return nest('feed.html.rollup', function (getStream, { |
48 | prepend, |
49 | rootFilter = returnTrue, |
50 | bumpFilter = returnTrue, |
51 | compactFilter = returnFalse, |
52 | prefiltered = false, |
53 | displayFilter = returnTrue, |
54 | updateStream, // override the stream used for realtime updates |
55 | waitFor = true |
56 | }) { |
57 | var updates = Value(0) |
58 | var yourId = api.keys.sync.id() |
59 | var throttledUpdates = throttle(updates, 200) |
60 | var updateLoader = h('a Notifier -loader', { href: '#', 'ev-click': refresh }, [ |
61 | 'Show ', h('strong', [throttledUpdates]), ' ', plural(throttledUpdates, i18n('update'), i18n('updates')) |
62 | ]) |
63 | |
64 | var abortLastFeed = null |
65 | var content = Value() |
66 | var loading = Proxy(true) |
67 | var unreadIds = new Set() |
68 | var newSinceRefresh = new Set() |
69 | var highlightItems = new Set() |
70 | |
71 | var container = h('Scroller', { |
72 | style: { overflow: 'auto' }, |
73 | hooks: [(element) => { |
74 | // don't activate until added to DOM |
75 | refresh() |
76 | |
77 | // deactivate when removed from DOM |
78 | return () => { |
79 | if (abortLastFeed) { |
80 | abortLastFeed() |
81 | abortLastFeed = null |
82 | } |
83 | } |
84 | }] |
85 | }, [ |
86 | h('div.wrapper', [ |
87 | h('section.prepend', prepend), |
88 | content, |
89 | when(loading, h('Loading -large')) |
90 | ]) |
91 | ]) |
92 | |
93 | onceTrue(waitFor, () => { |
94 | // display pending updates |
95 | pull( |
96 | updateStream || pull( |
97 | getStream({old: false}), |
98 | LookupRoot() |
99 | ), |
100 | pull.filter((msg) => { |
101 | // only render posts that have a root message |
102 | var root = msg.root || msg |
103 | return root && root.value && root.value.content && rootFilter(root) && bumpFilter(msg) && displayFilter(msg) |
104 | }), |
105 | pull.drain((msg) => { |
106 | if (msg.value.content.type === 'vote') return |
107 | if (api.app.sync.externalHandler(msg)) return |
108 | |
109 | // Only increment the 'new since' for items that we render on |
110 | // the feed as otherwise the 'show <n> updates message' will be |
111 | // shown on new messages that patchwork cannot render |
112 | if (canRenderMessage(msg) && (!msg.root || canRenderMessage(msg.root))) { |
113 | newSinceRefresh.add(msg.key) |
114 | unreadIds.add(msg.key) |
115 | } |
116 | |
117 | if (updates() === 0 && msg.value.author === yourId && container.scrollTop < 20) { |
118 | refresh() |
119 | } else { |
120 | updates.set(newSinceRefresh.size) |
121 | } |
122 | }) |
123 | ) |
124 | }) |
125 | |
126 | var result = MutantArray([ |
127 | when(updates, updateLoader), |
128 | container |
129 | ]) |
130 | |
131 | result.pendingUpdates = throttledUpdates |
132 | result.reload = refresh |
133 | |
134 | return result |
135 | |
136 | function canRenderMessage (msg) { |
137 | return api.message.html.canRender(msg) |
138 | } |
139 | |
140 | function refresh () { |
141 | onceTrue(waitFor, () => { |
142 | if (abortLastFeed) abortLastFeed() |
143 | updates.set(0) |
144 | content.set(h('section.content')) |
145 | |
146 | var abortable = Abortable() |
147 | abortLastFeed = abortable.abort |
148 | |
149 | highlightItems = newSinceRefresh |
150 | newSinceRefresh = new Set() |
151 | |
152 | var done = Value(false) |
153 | var stream = nextStepper(getStream, {reverse: true, limit: 50}) |
154 | var scroller = Scroller(container, content(), renderItem, { |
155 | onDone: () => done.set(true), |
156 | onItemVisible: (item) => { |
157 | if (Array.isArray(item.msgIds)) { |
158 | item.msgIds.forEach(id => { |
159 | unreadIds.delete(id) |
160 | }) |
161 | } |
162 | } |
163 | }) |
164 | |
165 | // track loading state |
166 | loading.set(computed([done, scroller.queue], (done, queue) => { |
167 | return !done && queue < 5 |
168 | })) |
169 | |
170 | pull( |
171 | stream, |
172 | abortable, |
173 | pull.filter(msg => msg && msg.value && msg.value.content), |
174 | prefiltered ? pull( |
175 | api.feed.pull.unique(), |
176 | pull.filter(msg => !api.message.sync.isBlocked(msg)), |
177 | pull.filter(rootFilter), |
178 | api.feed.pull.withReplies() |
179 | ) : pull( |
180 | pull.filter(bumpFilter), |
181 | api.feed.pull.rollup(rootFilter) |
182 | ), |
183 | scroller |
184 | ) |
185 | }) |
186 | } |
187 | |
188 | function renderItem (item, opts) { |
189 | var partial = opts && opts.partial |
190 | var meta = null |
191 | var previousId = item.key |
192 | |
193 | var groupedBumps = {} |
194 | var lastBumpType = null |
195 | |
196 | var rootBumpType = bumpFilter(item) |
197 | if (rootBumpTypes.includes(rootBumpType)) { |
198 | lastBumpType = rootBumpType |
199 | groupedBumps[lastBumpType] = [item] |
200 | } |
201 | |
202 | item.replies.forEach(msg => { |
203 | var value = bumpFilter(msg) |
204 | if (value) { |
205 | var type = typeof value === 'string' ? value : getType(msg) |
206 | ;(groupedBumps[type] = groupedBumps[type] || []).unshift(msg) |
207 | lastBumpType = type |
208 | } |
209 | }) |
210 | |
211 | var replies = item.replies.filter(isReply).sort(byAssertedTime) |
212 | var highlightedReplies = replies.filter(getPriority) |
213 | var replyElements = replies.filter(displayFilter).slice(-3).map((msg) => { |
214 | var result = api.message.html.render(msg, { |
215 | previousId, |
216 | compact: compactFilter(msg, item), |
217 | priority: getPriority(msg) |
218 | }) |
219 | previousId = msg.key |
220 | |
221 | return [ |
222 | // insert missing message marker (if can't be found) |
223 | api.message.html.missing(last(msg.value.content.branch), msg), |
224 | result |
225 | ] |
226 | }) |
227 | |
228 | var renderedMessage = api.message.html.render(item, { |
229 | compact: compactFilter(item), |
230 | includeForks: false, // this is a root message, so forks are already displayed as replies |
231 | priority: getPriority(item) |
232 | }) |
233 | |
234 | unreadIds.delete(item.key) |
235 | |
236 | if (!renderedMessage) return h('div') |
237 | if (lastBumpType) { |
238 | var bumps = lastBumpType === 'vote' |
239 | ? getLikeAuthors(groupedBumps[lastBumpType]) |
240 | : getAuthors(groupedBumps[lastBumpType]) |
241 | |
242 | var description = i18n(bumpMessages[lastBumpType] || 'added changes') |
243 | meta = h('div.meta', [ |
244 | many(bumps, api.profile.html.person, i18n), ' ', description |
245 | ]) |
246 | } |
247 | |
248 | // if there are new messages, view full thread goes to the top of those, otherwise to very first reply |
249 | var anchorReply = highlightedReplies.length >= 3 ? highlightedReplies[0] : replies[0] |
250 | |
251 | var result = h('FeedEvent -post', { |
252 | attributes: { |
253 | 'data-root-id': item.key |
254 | } |
255 | }, [ |
256 | meta, |
257 | renderedMessage, |
258 | when(replies.length > replyElements.length || partial, |
259 | h('a.full', {href: item.key, anchor: anchorReply && anchorReply.key}, [i18n('View full thread') + ' (', replies.length, ')']) |
260 | ), |
261 | h('div.replies', replyElements) |
262 | ]) |
263 | |
264 | result.msgIds = [item.key].concat(item.replies.map(x => x.key)) |
265 | |
266 | return result |
267 | } |
268 | |
269 | function getPriority (msg) { |
270 | if (highlightItems.has(msg.key)) { |
271 | return 2 |
272 | } else if (unreadIds.has(msg.key)) { |
273 | return 1 |
274 | } else { |
275 | return 0 |
276 | } |
277 | } |
278 | }) |
279 | |
280 | function LookupRoot () { |
281 | return paramap((msg, cb) => { |
282 | var rootId = api.message.sync.root(msg) |
283 | if (rootId) { |
284 | api.sbot.async.get(rootId, (_, value) => { |
285 | if (typeof value.content === 'string') { |
286 | // unbox private message |
287 | value = api.message.sync.unbox(value) |
288 | } |
289 | cb(null, extend(msg, { |
290 | root: {key: rootId, value} |
291 | })) |
292 | }) |
293 | } else { |
294 | cb(null, msg) |
295 | } |
296 | }) |
297 | } |
298 | |
299 | function byAssertedTime (a, b) { |
300 | return api.message.sync.timestamp(a) - api.message.sync.timestamp(b) |
301 | } |
302 | } |
303 | |
304 | function plural (value, single, many) { |
305 | return computed(value, (value) => { |
306 | if (value === 1) { |
307 | return single |
308 | } else { |
309 | return many |
310 | } |
311 | }) |
312 | } |
313 | |
314 | function many (ids, fn, intl) { |
315 | ids = Array.from(ids) |
316 | var featuredIds = ids.slice(0, 4) |
317 | |
318 | if (ids.length) { |
319 | if (ids.length > 4) { |
320 | return [ |
321 | fn(featuredIds[0]), ', ', |
322 | fn(featuredIds[1]), ', ', |
323 | fn(featuredIds[2]), intl(' and '), |
324 | ids.length - 3, intl(' others') |
325 | ] |
326 | } else if (ids.length === 4) { |
327 | return [ |
328 | fn(featuredIds[0]), ', ', |
329 | fn(featuredIds[1]), ', ', |
330 | fn(featuredIds[2]), intl(' and '), |
331 | fn(featuredIds[3]) |
332 | ] |
333 | } else if (ids.length === 3) { |
334 | return [ |
335 | fn(featuredIds[0]), ', ', |
336 | fn(featuredIds[1]), intl(' and '), |
337 | fn(featuredIds[2]) |
338 | ] |
339 | } else if (ids.length === 2) { |
340 | return [ |
341 | fn(featuredIds[0]), intl(' and '), |
342 | fn(featuredIds[1]) |
343 | ] |
344 | } else { |
345 | return fn(featuredIds[0]) |
346 | } |
347 | } |
348 | } |
349 | |
350 | function getAuthors (items) { |
351 | return items.reduce((result, msg) => { |
352 | result.add(msg.value.author) |
353 | return result |
354 | }, new Set()) |
355 | } |
356 | |
357 | function getLikeAuthors (items) { |
358 | return items.reduce((result, msg) => { |
359 | if (msg.value.content.type === 'vote') { |
360 | if (msg.value.content && msg.value.content.vote && msg.value.content.vote.value === 1) { |
361 | result.add(msg.value.author) |
362 | } else { |
363 | result.delete(msg.value.author) |
364 | } |
365 | } |
366 | return result |
367 | }, new Set()) |
368 | } |
369 | |
370 | function isReply (msg) { |
371 | if (msg.value && msg.value.content) { |
372 | var type = msg.value.content.type |
373 | return type === 'post' || (type === 'about' && msg.value.content.attendee) |
374 | } |
375 | } |
376 | |
377 | function getType (msg) { |
378 | return msg && msg.value && msg.value.content && msg.value.content.type |
379 | } |
380 | |
381 | function returnTrue () { |
382 | return true |
383 | } |
384 | |
385 | function returnFalse () { |
386 | return false |
387 | } |
388 | |
389 | function last (array) { |
390 | if (Array.isArray(array)) { |
391 | return array[array.length - 1] |
392 | } else { |
393 | return array |
394 | } |
395 | } |
396 |
Built with git-ssb-web