git ssb

10+

Matt McKegg / patchwork



Tree: 7e44e13afc44b6b906290606afe84a7c45b33eff

Files: 7e44e13afc44b6b906290606afe84a7c45b33eff / modules / feed / html / rollup.js

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

Built with git-ssb-web