git ssb

2+

mixmix / ticktack



Tree: f80524e4e4d9ee8b39654228746a0afe58778971

Files: f80524e4e4d9ee8b39654228746a0afe58778971 / app / html / sideNav / sideNavDiscovery.js

12293 bytesRaw
1const nest = require('depnest')
2const { h, computed, map, when, Dict, Array: MutantArray, Value, Set, resolve } = require('mutant')
3const pull = require('pull-stream')
4const next = require('pull-next-step')
5const get = require('lodash/get')
6const isEmpty = require('lodash/isEmpty')
7const path = require('path')
8
9exports.gives = nest({
10 'app.html.sideNav': true,
11 'unread.sync.markUnread': true
12})
13
14exports.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
30exports.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
349function 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
358function 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