git ssb

10+

Matt McKegg / patchwork



Tree: 8b00b03fbc1dd761dc8c12d57b9a66b7b68d7fd3

Files: 8b00b03fbc1dd761dc8c12d57b9a66b7b68d7fd3 / modules / feed / html / rollup.js

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

Built with git-ssb-web