git ssb

10+

Matt McKegg / patchwork



Tree: 2bcaceb027e18f217403accb637305982f0a929d

Files: 2bcaceb027e18f217403accb637305982f0a929d / modules / feed / html / rollup.js

14246 bytesRaw
1var nest = require('depnest')
2var {Value, Proxy, Array: MutantArray, h, computed, when, onceTrue, throttle} = require('mutant')
3var pull = require('pull-stream')
4var Abortable = require('pull-abortable')
5var Scroller = require('../../../lib/scroller')
6var nextStepper = require('../../../lib/next-stepper')
7var extend = require('xtend')
8var paramap = require('pull-paramap')
9var Group = require('pull-group')
10
11var 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
21var rootBumpTypes = ['mention', 'channel-mention']
22
23// group these message types together using meta-summary
24var metaSummaryTypes = ['about', 'channel', 'contact']
25
26exports.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
50exports.gives = nest({
51 'feed.html.rollup': true
52})
53
54exports.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
358function 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
368function 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
404function getAuthors (items) {
405 return items.reduce((result, msg) => {
406 result.add(msg.value.author)
407 return result
408 }, new Set())
409}
410
411function 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
424function 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
431function getType (msg) {
432 return msg && msg.value && msg.value.content && msg.value.content.type
433}
434
435function returnTrue () {
436 return true
437}
438
439function returnFalse () {
440 return false
441}
442
443function last (array) {
444 if (Array.isArray(array)) {
445 return array[array.length - 1]
446 } else {
447 return array
448 }
449}
450
451function 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
477function hasReply (msg) {
478 return msg.replies && msg.replies.some(msg => msg.value.content.type === 'post')
479}
480

Built with git-ssb-web