Files: 2d9c04497ae27569166c3fce934b7c17eee3753b / app / html / sideNav / sideNavDiscovery.js
12293 bytesRaw
1 | const nest = require('depnest') |
2 | const { h, computed, map, when, Dict, Array: MutantArray, Value, Set, resolve } = require('mutant') |
3 | const pull = require('pull-stream') |
4 | const next = require('pull-next-step') |
5 | const get = require('lodash/get') |
6 | const isEmpty = require('lodash/isEmpty') |
7 | const path = require('path') |
8 | |
9 | exports.gives = nest({ |
10 | 'app.html.sideNav': true, |
11 | 'unread.sync.markUnread': true |
12 | }) |
13 | |
14 | exports.needs = nest({ |
15 | 'app.html.scroller': 'first', |
16 | 'app.obs.pluginsOk': 'first', |
17 | 'about.html.avatar': 'first', |
18 | 'about.obs.name': 'first', |
19 | 'feed.pull.private': 'first', |
20 | 'history.sync.push': 'first', |
21 | 'history.obs.store': 'first', |
22 | 'keys.sync.id': 'first', |
23 | 'message.html.subject': 'first', |
24 | 'message.sync.getParticipants': 'first', |
25 | 'sbot.obs.localPeers': 'first', |
26 | 'translations.sync.strings': 'first', |
27 | 'unread.sync.isUnread': 'first' |
28 | }) |
29 | |
30 | exports.create = (api) => { |
31 | var recentMsgCache = MutantArray() |
32 | var usersLastMsgCache = Dict() // { id: [ msgs ] } |
33 | var unreadMsgsCache = Dict() // { id: [ msgs ] } |
34 | |
35 | return nest({ |
36 | // intercept markUnread and remove them from the cache. |
37 | 'unread.sync.markUnread': markUnread, |
38 | 'app.html.sideNav': sideNav |
39 | }) |
40 | |
41 | function markUnread (msg) { |
42 | unreadMsgsCache.get(msg.value.content.root || msg.key) |
43 | .delete(msg.key) |
44 | |
45 | const participants = api.message.sync.getParticipants(msg) |
46 | unreadMsgsCache.get(participants.key) |
47 | .delete(msg.key) |
48 | } |
49 | |
50 | function sideNav (location) { |
51 | if (!isSideNavDiscovery(location)) return |
52 | |
53 | const strings = api.translations.sync.strings() |
54 | var nearby = api.sbot.obs.localPeers() |
55 | const getParticipants = api.message.sync.getParticipants |
56 | const myKey = api.keys.sync.id() |
57 | |
58 | // Unread message counts |
59 | function updateCache (cache, msg) { |
60 | if (api.unread.sync.isUnread(msg)) { cache.add(msg.key) } else { cache.delete(msg.key) } |
61 | } |
62 | |
63 | function updateUnreadMsgsCache (msg) { |
64 | if (msg.value.author === myKey) return |
65 | |
66 | const participantsKey = getParticipants(msg).key |
67 | updateCache(getUnreadMsgsCache(participantsKey), msg) |
68 | |
69 | const rootKey = get(msg, 'value.content.root', msg.key) |
70 | updateCache(getUnreadMsgsCache(rootKey), msg) |
71 | } |
72 | |
73 | pull( |
74 | next(api.feed.pull.private, {reverse: true, limit: 1000, live: false, property: ['timestamp']}), |
75 | privateMsgFilter(), |
76 | pull.drain(updateUnreadMsgsCache) |
77 | ) |
78 | |
79 | pull( |
80 | next(api.feed.pull.private, {old: false, live: true, property: ['timestamp']}), |
81 | privateMsgFilter(), |
82 | pull.drain(updateUnreadMsgsCache) |
83 | ) |
84 | |
85 | return h('SideNav -discovery', [ |
86 | LevelOneSideNav(), |
87 | LevelTwoSideNav() |
88 | ]) |
89 | |
90 | function LevelOneSideNav () { |
91 | function isDiscoverLocation (loc) { |
92 | const PAGES_UNDER_DISCOVER = ['blogIndex', 'blogShow', 'userShow'] |
93 | |
94 | if (PAGES_UNDER_DISCOVER.includes(location.page)) return true |
95 | if (location.page === 'threadNew') return false |
96 | if (location.page === 'channelSubscriptions') return false |
97 | if (get(location, 'value.private') === undefined) return true |
98 | return false |
99 | } |
100 | |
101 | const prepend = [ |
102 | // Nearby |
103 | computed(nearby, n => !isEmpty(n) ? h('header', strings.peopleNearby) : null), |
104 | map(nearby, feedId => Option({ |
105 | notifications: notifications(feedId), |
106 | imageEl: api.about.html.avatar(feedId, 'small'), |
107 | label: api.about.obs.name(feedId), |
108 | selected: get(location, 'participants', []).join() === feedId && !isDiscoverLocation(location), |
109 | location: computed(recentMsgCache, recent => { |
110 | const lastMsg = recent.find(msg => msg.value.author === feedId) |
111 | return lastMsg |
112 | ? Object.assign(lastMsg, { participants: [feedId] }) |
113 | : { page: 'threadNew', participants: [feedId] } |
114 | }) |
115 | }), { comparer: (a, b) => a === b }), |
116 | |
117 | // --------------------- |
118 | computed(nearby, n => !isEmpty(n) ? h('hr') : null), |
119 | |
120 | // Discover |
121 | Option({ |
122 | imageEl: h('i', [ |
123 | h('img', { src: path.join(__dirname, '../../../assets', 'discover.png') }) |
124 | ]), |
125 | label: strings.blogIndex.title, |
126 | selected: isDiscoverLocation(location), |
127 | location: { page: 'blogIndex' } |
128 | }), |
129 | computed(api.app.obs.pluginsOk(), ok => { |
130 | return [ |
131 | // My subscriptions |
132 | Option({ |
133 | imageEl: h('i', [ |
134 | h('img', { src: path.join(__dirname, '../../../assets', 'my_subscribed.png') }) |
135 | ]), |
136 | label: strings.subscriptions.user, |
137 | selected: location.page === 'channelSubscriptions' && location.scope === 'user', |
138 | disabled: !ok, |
139 | location: { page: 'channelSubscriptions', scope: 'user' } |
140 | }), |
141 | |
142 | // Friends subscriptions |
143 | Option({ |
144 | imageEl: h('i', [ |
145 | h('img', { src: path.join(__dirname, '../../../assets', 'friends_subscribed.png') }) |
146 | ]), |
147 | label: strings.subscriptions.friends, |
148 | selected: location.page === 'channelSubscriptions' && location.scope === 'friends', |
149 | disabled: !ok, |
150 | location: { page: 'channelSubscriptions', scope: 'friends' } |
151 | }) |
152 | ] |
153 | }) |
154 | ] |
155 | |
156 | return api.app.html.scroller({ |
157 | classList: [ 'level', '-one' ], |
158 | prepend, |
159 | stream: api.feed.pull.private, |
160 | filter: privateMsgFilter, |
161 | store: recentMsgCache, |
162 | updateTop: updateRecentMsgCache, |
163 | updateBottom: updateRecentMsgCache, |
164 | render |
165 | }) |
166 | |
167 | function render (msgObs) { |
168 | const msg = resolve(msgObs) |
169 | const participants = getParticipants(msg) |
170 | // TODO msg has been decorated with a flat participantsKey, could re-hydrate |
171 | |
172 | if (participants.length === 1 && nearby.has(participants.key)) return |
173 | const locParticipantsKey = get(location, 'participants', []).join(' ') // TODO collect logic |
174 | |
175 | if (participants.length === 1) { |
176 | const author = participants[0] |
177 | return Option({ |
178 | // the number of threads with each peer |
179 | notifications: notifications(author), |
180 | imageEl: api.about.html.avatar(author), |
181 | label: api.about.obs.name(author), |
182 | selected: locParticipantsKey === author, |
183 | location: Object.assign({}, msg, { participants }) // TODO make obs? |
184 | }) |
185 | } else { |
186 | const rootMsg = get(msg, 'value.content.root', msg) |
187 | return Option({ |
188 | // the number of threads with each peer |
189 | notifications: notifications(participants), |
190 | imageEl: participants.map(p => api.about.html.avatar(p, 'halfSmall')), |
191 | label: api.message.html.subject(rootMsg), |
192 | selected: locParticipantsKey === participants.key, |
193 | location: Object.assign({}, msg, { participants }) // TODO make obs? |
194 | }) |
195 | } |
196 | } |
197 | |
198 | function updateRecentMsgCache (soFar, newMsg) { |
199 | soFar.transaction(() => { |
200 | const { timestamp } = newMsg.value |
201 | newMsg.participants = getParticipants(newMsg) |
202 | const index = indexOf(soFar, (msg) => newMsg.participants.key === resolve(msg).participants.key) |
203 | var object = Value() |
204 | |
205 | if (index >= 0) { |
206 | // reference already exists, lets use this instead! |
207 | const existingMsg = soFar.get(index) |
208 | |
209 | if (resolve(existingMsg).value.timestamp > timestamp) return |
210 | // but abort if the existing reference is newer |
211 | |
212 | object = existingMsg |
213 | soFar.deleteAt(index) |
214 | } |
215 | |
216 | object.set(newMsg) |
217 | |
218 | const justOlderPosition = indexOf(soFar, (msg) => timestamp > resolve(msg).value.timestamp) |
219 | if (justOlderPosition > -1) { |
220 | soFar.insert(object, justOlderPosition) |
221 | } else { |
222 | soFar.push(object) |
223 | } |
224 | }) |
225 | } |
226 | } |
227 | |
228 | function getUnreadMsgsCache (key) { |
229 | var cache = unreadMsgsCache.get(key) |
230 | if (!cache) { |
231 | cache = Set() |
232 | unreadMsgsCache.put(key, cache) |
233 | } |
234 | return cache |
235 | } |
236 | |
237 | function notifications (key) { |
238 | key = typeof key === 'string' |
239 | ? key |
240 | : key.key // participants.key case |
241 | return computed(getUnreadMsgsCache(key), cache => cache.length) |
242 | } |
243 | |
244 | function LevelTwoSideNav () { |
245 | const { key, value, participants, page } = location |
246 | const root = get(value, 'content.root', key) |
247 | if (isEmpty(participants)) return |
248 | if (page === 'userShow') return |
249 | |
250 | const prepend = Option({ |
251 | selected: page === 'threadNew', |
252 | location: {page: 'threadNew', participants}, |
253 | label: h('Button', strings.threadNew.action.new) |
254 | }) |
255 | |
256 | var participantsKey = participants.join(' ') // TODO collect this repeated logic |
257 | var userLastMsgCache = usersLastMsgCache.get(participantsKey) |
258 | if (!userLastMsgCache) { |
259 | userLastMsgCache = MutantArray() |
260 | usersLastMsgCache.put(participantsKey, userLastMsgCache) |
261 | } |
262 | |
263 | return api.app.html.scroller({ |
264 | classList: [ 'level', '-two' ], |
265 | prepend, |
266 | stream: api.feed.pull.private, |
267 | filter: () => pull( |
268 | pull.filter(msg => !msg.value.content.root), |
269 | pull.filter(msg => msg.value.content.type === 'post'), |
270 | pull.filter(msg => getParticipants(msg).key === participantsKey) |
271 | ), |
272 | store: userLastMsgCache, |
273 | updateTop: updateLastMsgCache, |
274 | updateBottom: updateLastMsgCache, |
275 | render |
276 | }) |
277 | |
278 | function render (rootMsgObs) { |
279 | const rootMsg = resolve(rootMsgObs) |
280 | const participants = getParticipants(rootMsg) |
281 | return Option({ |
282 | notifications: notifications(rootMsg.key), |
283 | label: api.message.html.subject(rootMsg), |
284 | selected: rootMsg.key === root, |
285 | location: Object.assign(rootMsg, { participants }) |
286 | }) |
287 | } |
288 | |
289 | function updateLastMsgCache (soFar, newMsg) { |
290 | soFar.transaction(() => { |
291 | const { timestamp } = newMsg.value |
292 | const index = indexOf(soFar, (msg) => timestamp === resolve(msg).value.timestamp) |
293 | |
294 | if (index >= 0) return |
295 | // if reference already exists, abort |
296 | |
297 | var object = Value(newMsg) |
298 | |
299 | const justOlderPosition = indexOf(soFar, (msg) => timestamp > resolve(msg).value.timestamp) |
300 | if (justOlderPosition > -1) { |
301 | soFar.insert(object, justOlderPosition) |
302 | } else { |
303 | soFar.push(object) |
304 | } |
305 | }) |
306 | } |
307 | } |
308 | |
309 | function Option ({ notifications = 0, imageEl, label, location, selected, disabled }) { |
310 | const classList = [] |
311 | if (selected) classList.push('-selected') |
312 | if (disabled) classList.push('-disabled') |
313 | |
314 | function goToLocation (e) { |
315 | if (disabled) return |
316 | |
317 | e.preventDefault() |
318 | e.stopPropagation() |
319 | api.history.sync.push(resolve(location)) |
320 | } |
321 | |
322 | if (!imageEl) { |
323 | return h('Option', { classList, 'ev-click': goToLocation }, [ |
324 | when(notifications, h('div.spacer', h('div.alert', notifications))), |
325 | h('div.label', label) |
326 | ]) |
327 | } |
328 | |
329 | return h('Option', { classList }, [ |
330 | h('div.circle', [ |
331 | when(notifications, h('div.alert', notifications)), |
332 | Array.isArray(imageEl) |
333 | ? h('div.many-images', imageEl.slice(0, 4)) // not ideal? not enough space to show more though |
334 | : imageEl |
335 | ]), |
336 | h('div.label', { 'ev-click': goToLocation }, label) |
337 | ]) |
338 | } |
339 | |
340 | function privateMsgFilter () { |
341 | return pull( |
342 | pull.filter(msg => msg.value.content.type === 'post'), |
343 | pull.filter(msg => msg.value.content.recps) |
344 | ) |
345 | } |
346 | } |
347 | } |
348 | |
349 | function indexOf (array, fn) { |
350 | for (var i = 0; i < array.getLength(); i++) { |
351 | if (fn(array.get(i))) { |
352 | return i |
353 | } |
354 | } |
355 | return -1 |
356 | } |
357 | |
358 | function isSideNavDiscovery (location) { |
359 | if (location.page) { |
360 | if (location.page.match(/^blog/)) return true |
361 | if (location.page.match(/^thread/)) return true |
362 | if (location.page.match(/^user/)) return true |
363 | if (location.page.match(/^channel/)) return true |
364 | } |
365 | if (location.key) { |
366 | return true |
367 | } |
368 | return false |
369 | } |
370 |
Built with git-ssb-web