Files: f50400a95d8c5a977f0f39ee8b857c5ee9cdd630 / lib / depject / feed / html / rollup.js
11397 bytesRaw
1 | const nest = require('depnest') |
2 | const { Value, Proxy, Array: MutantArray, h, computed, when, throttle, isObservable, resolve } = require('mutant') |
3 | const pull = require('pull-stream') |
4 | const Abortable = require('pull-abortable') |
5 | const Scroller = require('../../../scroller') |
6 | const extend = require('xtend') |
7 | const GroupSummaries = require('../../../group-summaries') |
8 | const rootBumpTypes = ['mention', 'channel-mention', 'invite'] |
9 | const getBump = require('../../../get-bump') |
10 | const many = require('../../../many') |
11 | |
12 | const bumpMessages = { |
13 | reaction: 'liked this message', |
14 | invite: 'invited you to this gathering', |
15 | reply: 'replied to this message', |
16 | updated: 'added changes', |
17 | mention: 'mentioned you', |
18 | 'channel-mention': 'mentioned this channel', |
19 | attending: 'can attend' |
20 | } |
21 | |
22 | exports.needs = nest({ |
23 | 'app.sync.externalHandler': 'first', |
24 | 'message.html.canRender': 'first', |
25 | 'message.html.render': 'first', |
26 | 'message.sync.unbox': 'first', |
27 | 'message.sync.timestamp': 'first', |
28 | 'profile.html.person': 'first', |
29 | 'channel.html.link': 'first', |
30 | 'message.html.link': 'first', |
31 | 'message.sync.root': 'first', |
32 | 'sbot.async.get': 'first', |
33 | 'keys.sync.id': 'first', |
34 | 'intl.sync.i18n': 'first', |
35 | 'intl.sync.i18n_n': 'first', |
36 | 'message.html.missing': 'first', |
37 | 'feed.html.metaSummary': 'first' |
38 | }) |
39 | |
40 | exports.gives = nest({ |
41 | 'feed.html.rollup': true |
42 | }) |
43 | |
44 | exports.create = function (api) { |
45 | const i18n = api.intl.sync.i18n |
46 | const i18nPlural = api.intl.sync.i18n_n |
47 | return nest('feed.html.rollup', function (getStream, { |
48 | prepend, |
49 | hidden = false, |
50 | groupSummaries = true, |
51 | compactFilter = returnFalse, |
52 | ungroupFilter = returnFalse, |
53 | searchSpinner = false, |
54 | updateStream |
55 | }) { |
56 | const updates = Value(0) |
57 | const yourId = api.keys.sync.id() |
58 | const throttledUpdates = throttle(updates, 200) |
59 | const updateLoader = h('a Notifier -loader', { href: '#', 'ev-click': refresh }, [ |
60 | 'Show ', h('strong', [throttledUpdates]), ' ', plural(throttledUpdates, i18n('update'), i18n('updates')) |
61 | ]) |
62 | |
63 | let abortLastFeed = null |
64 | let unwatchHidden = null |
65 | const content = Value() |
66 | const loading = Proxy(true) |
67 | const unreadIds = new Set() |
68 | let newSinceRefresh = new Set() |
69 | let highlightItems = new Set() |
70 | |
71 | const container = h('Scroller', { |
72 | // only bind elements that are visible in scroller |
73 | intersectionBindingViewport: { rootMargin: '1000px' }, |
74 | |
75 | style: { overflow: 'auto' }, |
76 | hooks: [() => { |
77 | // don't activate until added to DOM |
78 | refresh() |
79 | |
80 | if (isObservable(hidden)) { |
81 | unwatchHidden = hidden(() => { |
82 | refresh() |
83 | }) |
84 | } |
85 | |
86 | // deactivate when removed from DOM |
87 | return () => { |
88 | if (abortLastFeed) { |
89 | abortLastFeed() |
90 | abortLastFeed = null |
91 | } |
92 | |
93 | if (unwatchHidden) { |
94 | unwatchHidden() |
95 | unwatchHidden = null |
96 | } |
97 | } |
98 | }] |
99 | }, [ |
100 | h('div.wrapper', [ |
101 | h('section.prepend', prepend), |
102 | content, |
103 | when(loading, searchSpinner ? h('Loading -large -search') : h('Loading -large')) |
104 | ]) |
105 | ]) |
106 | |
107 | if (updateStream) { |
108 | pull( |
109 | updateStream, |
110 | pull.drain((msg) => { |
111 | if (!(msg && msg.value && msg.value.content && msg.value.content.type)) return |
112 | if (!canRenderMessage(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 (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 | const existingContainer = content().querySelector(`[data-root-id="${api.message.sync.root(msg)}"]`) |
126 | if (existingContainer) { |
127 | const replies = existingContainer.querySelector('div.replies') |
128 | replies.appendChild(api.message.html.render(msg, { |
129 | compact: false, |
130 | priority: 2 |
131 | })) |
132 | } |
133 | } else { |
134 | highlightItems.add(msg.key) |
135 | content().prepend( |
136 | renderItem(extend(msg, { |
137 | latestReplies: [], |
138 | totalReplies: 0, |
139 | rootBump: { type: 'post' } |
140 | })) |
141 | ) |
142 | } |
143 | } |
144 | |
145 | updates.set(newSinceRefresh.size) |
146 | }) |
147 | ) |
148 | } |
149 | |
150 | const result = MutantArray([ |
151 | when(hidden, |
152 | null, |
153 | when(updates, updateLoader) |
154 | ), |
155 | container |
156 | ]) |
157 | |
158 | result.pendingUpdates = throttledUpdates |
159 | result.reload = refresh |
160 | |
161 | return result |
162 | |
163 | function canRenderMessage (msg) { |
164 | if (msg && msg.value && msg.value.content) { |
165 | return api.message.html.canRender(msg) |
166 | } |
167 | } |
168 | |
169 | function refresh () { |
170 | if (resolve(hidden)) { |
171 | content.set(null) |
172 | loading.set(false) |
173 | return |
174 | } |
175 | if (abortLastFeed) abortLastFeed() |
176 | updates.set(0) |
177 | content.set(h('section.content')) |
178 | |
179 | const abortable = Abortable() |
180 | abortLastFeed = abortable.abort |
181 | |
182 | highlightItems = newSinceRefresh |
183 | newSinceRefresh = new Set() |
184 | |
185 | const firstItemVisible = Value(false) |
186 | |
187 | const done = Value(false) |
188 | const scroller = Scroller(container, content(), renderItem, { |
189 | onDone: () => done.set(true), |
190 | onItemVisible: (item) => { |
191 | if (!firstItemVisible()) { |
192 | firstItemVisible.set(true) |
193 | } |
194 | if (Array.isArray(item.msgIds)) { |
195 | item.msgIds.forEach(id => { |
196 | unreadIds.delete(id) |
197 | }) |
198 | } |
199 | } |
200 | }) |
201 | |
202 | // track loading state |
203 | loading.set(scroller.waiting) |
204 | |
205 | const seen = new Set() |
206 | |
207 | pull( |
208 | getStream(), |
209 | abortable, |
210 | pull.filter(item => { |
211 | // don't show the same threads again (repeats due to pull-resume) |
212 | if (seen.has(item.key)) { |
213 | return false |
214 | } |
215 | seen.add(item.key) |
216 | return true |
217 | }), |
218 | pull.filter(canRenderMessage), |
219 | |
220 | // group related items (follows, subscribes, abouts) |
221 | groupSummaries ? GroupSummaries({ windowSize: 15, getPriority, ungroupFilter }) : pull.through(), |
222 | |
223 | scroller |
224 | ) |
225 | } |
226 | |
227 | function renderItem (item, opts) { |
228 | if (item.group) { |
229 | return api.feed.html.metaSummary(item, renderItem, getPriority, opts) |
230 | } |
231 | |
232 | let mostRecentBumpType = (item.bumps && item.bumps[0] && item.bumps[0].type) |
233 | |
234 | const rootBump = getBump(item, item.rootBump) |
235 | if (!mostRecentBumpType) { |
236 | if (rootBump && rootBumpTypes.includes(rootBump.type)) { |
237 | mostRecentBumpType = rootBump.type |
238 | } else { |
239 | mostRecentBumpType = 'reply' |
240 | } |
241 | } |
242 | |
243 | const bumps = getBumps(item)[mostRecentBumpType] |
244 | |
245 | const renderedMessage = api.message.html.render(item, { |
246 | compact: compactFilter(item), |
247 | includeForks: false, |
248 | pageId: item.key, |
249 | forkedFrom: item.value.content.root, |
250 | priority: getPriority(item) |
251 | }) |
252 | |
253 | const unreadBumps = [] |
254 | if (!highlightItems.has(item.key) && item.bumps) { |
255 | item.bumps.forEach(bump => { |
256 | if (highlightItems.has(bump.id)) { |
257 | unreadBumps.push(bump.id) |
258 | } |
259 | }) |
260 | } |
261 | |
262 | unreadIds.delete(item.key) |
263 | |
264 | let meta = null |
265 | |
266 | // explain why this message is in your feed |
267 | if (mostRecentBumpType !== 'matches-channel' && item.rootBump && item.rootBump.type === 'matches-channel') { |
268 | // the root post was in a channel that you subscribe to |
269 | meta = h('div.meta', [ |
270 | many(item.rootBump.channels, api.channel.html.link, i18n), ' ', i18n('mentioned in your network') |
271 | ]) |
272 | } else if (bumps && bumps.length) { |
273 | const authors = getAuthors(bumps) |
274 | if (mostRecentBumpType === 'matches-channel') { |
275 | // a reply to this post matches a channel you subscribe to |
276 | const channels = new Set() |
277 | bumps.forEach(bump => bump.channels && bump.channels.forEach(c => channels.add(c))) |
278 | meta = h('div.meta', [ |
279 | i18nPlural('%s people from your network replied to this message on ', authors.length), |
280 | many(channels, api.channel.html.link, i18n) |
281 | ]) |
282 | } else { |
283 | // someone you follow replied to this message |
284 | const description = i18n(bumpMessages[mostRecentBumpType] || 'added changes') |
285 | meta = h('div.meta', [ |
286 | many(authors, api.profile.html.person, i18n), ' ', description |
287 | ]) |
288 | } |
289 | } |
290 | |
291 | const latestReplyIds = item.latestReplies.map(msg => msg.key) |
292 | const hasMoreNewReplies = unreadBumps.some(id => !latestReplyIds.includes(id)) |
293 | |
294 | return h('FeedEvent -post', { |
295 | msgIds: [item.key].concat(item.latestReplies.map(x => x.key)), |
296 | attributes: { |
297 | 'data-root-id': item.key |
298 | } |
299 | }, [ |
300 | meta, |
301 | renderedMessage, |
302 | item.totalReplies > item.latestReplies.length |
303 | ? h('a.full', |
304 | { |
305 | href: item.key, |
306 | anchor: { |
307 | // highlight unread messages in thread view |
308 | unread: unreadBumps, |
309 | // only drop to latest messages if there are more new messages not visible in thread summary |
310 | anchor: hasMoreNewReplies ? unreadBumps[unreadBumps.length - 1] : null |
311 | } |
312 | }, [i18n('View full thread') + ' (', item.totalReplies, ')']) |
313 | : null, |
314 | h('div.replies', [ |
315 | item.latestReplies.map(msg => { |
316 | const result = api.message.html.render(msg, { |
317 | compact: compactFilter(msg, item), |
318 | priority: getPriority(msg) |
319 | }) |
320 | |
321 | return [ |
322 | // insert missing message marker (if can't be found) |
323 | api.message.html.missing(last(msg.value.content.branch), msg, item), |
324 | result |
325 | ] |
326 | }) |
327 | ]) |
328 | ]) |
329 | } |
330 | |
331 | function getPriority (msg) { |
332 | if (highlightItems.has(msg.key)) { |
333 | return 2 |
334 | } else if (unreadIds.has(msg.key)) { |
335 | return 1 |
336 | } else { |
337 | return 0 |
338 | } |
339 | } |
340 | }) |
341 | } |
342 | |
343 | function plural (value, single, many) { |
344 | return computed(value, (value) => { |
345 | if (value === 1) { |
346 | return single |
347 | } else { |
348 | return many |
349 | } |
350 | }) |
351 | } |
352 | |
353 | function getAuthors (items) { |
354 | const authors = {} |
355 | items.forEach(item => { |
356 | authors[item.author] = true |
357 | }) |
358 | return Object.keys(authors) |
359 | } |
360 | |
361 | function returnFalse () { |
362 | return false |
363 | } |
364 | |
365 | function last (array) { |
366 | if (Array.isArray(array)) { |
367 | return array[array.length - 1] |
368 | } else { |
369 | return array |
370 | } |
371 | } |
372 | |
373 | function getBumps (msg) { |
374 | const bumps = {} |
375 | const rootBump = getBump(msg, msg.rootBump) |
376 | |
377 | if (rootBump) { |
378 | bumps[rootBump.type] = [rootBump] |
379 | } |
380 | |
381 | if (Array.isArray(msg.bumps)) { |
382 | msg.bumps.forEach(bump => { |
383 | const type = bump.type || 'reply' |
384 | bumps[type] = bumps[type] || [] |
385 | bumps[type].push(bump) |
386 | }) |
387 | } |
388 | return bumps |
389 | } |
390 |
Built with git-ssb-web