git ssb

2+

mixmix / ticktack



Tree: 4ef31e43684a11333c8cc97d2919d6dd41895bd0

Files: 4ef31e43684a11333c8cc97d2919d6dd41895bd0 / app / html / sideNav / sideNavDiscovery.js

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

Built with git-ssb-web