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