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