git ssb

10+

Matt McKegg / patchwork



Tree: b6ad2e7692f72c980a5ef0564f60b049333a9e1e

Files: b6ad2e7692f72c980a5ef0564f60b049333a9e1e / modules / page / html / render / public.js

11902 bytesRaw
1var nest = require('depnest')
2var extend = require('xtend')
3var pull = require('pull-stream')
4var { h, send, when, computed, map, onceTrue } = require('mutant')
5
6exports.needs = nest({
7 sbot: {
8 obs: {
9 connectedPeers: 'first',
10 localPeers: 'first',
11 connection: 'first'
12 }
13 },
14 'sbot.pull.stream': 'first',
15 'feed.pull.public': 'first',
16 'about.html.image': 'first',
17 'about.obs.name': 'first',
18 'invite.sheet': 'first',
19
20 'message.html.compose': 'first',
21 'message.async.publish': 'first',
22 'message.sync.root': 'first',
23 'progress.html.peer': 'first',
24
25 'feed.html.followWarning': 'first',
26 'feed.html.followerWarning': 'first',
27 'feed.html.rollup': 'first',
28 'profile.obs.recentlyUpdated': 'first',
29 'profile.obs.contact': 'first',
30 'contact.obs.following': 'first',
31 'contact.obs.blocking': 'first',
32 'channel.obs': {
33 subscribed: 'first',
34 recent: 'first'
35 },
36 'channel.sync.normalize': 'first',
37 'keys.sync.id': 'first',
38 'settings.obs.get': 'first',
39 'intl.sync.i18n': 'first'
40})
41
42exports.gives = nest({
43 'page.html.render': true
44})
45
46exports.create = function (api) {
47 const i18n = api.intl.sync.i18n
48 return nest('page.html.render', page)
49
50 function page (path) {
51 if (path !== '/public') return // "/" is a sigil for "page"
52
53 var id = api.keys.sync.id()
54 var following = api.contact.obs.following(id)
55 var blocking = api.contact.obs.blocking(id)
56 var subscribedChannels = api.channel.obs.subscribed(id)
57 var recentChannels = api.channel.obs.recent()
58 var loading = computed([subscribedChannels.sync, recentChannels.sync], (...args) => !args.every(Boolean))
59 var channels = computed(recentChannels, items => items.slice(0, 8), {comparer: arrayEq})
60 var connectedPeers = api.sbot.obs.connectedPeers()
61 var localPeers = api.sbot.obs.localPeers()
62 var connectedPubs = computed([connectedPeers, localPeers], (c, l) => c.filter(x => !l.includes(x)))
63 var contact = api.profile.obs.contact(id)
64
65 var prepend = [
66 api.message.html.compose({ meta: { type: 'post' }, placeholder: i18n('Write a public message') }),
67 noVisibleNewPostsWarning(),
68 noFollowersWarning()
69 ]
70
71 var lastMessage = null
72
73 var getStream = (opts) => {
74 if (!opts.lt) {
75 // HACK: reset the isReplacementMessage check
76 lastMessage = null
77 }
78 if (opts.lt != null && !opts.lt.marker) {
79 // if an lt has been specified that is not a marker, assume stream is finished
80 return pull.empty()
81 } else {
82 return api.sbot.pull.stream(sbot => sbot.patchwork.roots(extend(opts, {
83 ids: [id]
84 })))
85 }
86 }
87
88 var filters = api.settings.obs.get('filters')
89 var feedView = api.feed.html.rollup(getStream, {
90 prepend,
91 prefiltered: true, // we've already filtered out the roots we don't want to include
92 updateStream: api.sbot.pull.stream(sbot => sbot.patchwork.latest({ids: [id]})),
93 bumpFilter: function (msg) {
94 // this needs to match the logic in sbot/roots so that we display the
95 // correct bump explainations
96 if (msg.value && msg.value.content && typeof msg.value.content === 'object') {
97 var type = msg.value.content.type
98 if (type === 'vote') return false
99
100 var author = msg.value.author
101
102 if (id === author || following().includes(author)) {
103 if (isAttendee(msg)) {
104 return 'attending'
105 } else {
106 return true
107 }
108 } else if (matchesSubscribedChannel(msg)) {
109 return 'matches-channel'
110 }
111 }
112 },
113 rootFilter: function (msg) {
114 if (msg.value && msg.value.content && msg.value.content.type === 'contact') {
115 // don't show unfollows in the main feed, but do show follows and blocks
116 // we still show unfollows on a person's profile though
117 if (msg.value.content.following === false && !msg.value.content.blocking) return false
118 }
119
120 if (msg.value && msg.value.content && msg.value.content.type === 'channel') {
121 // don't show channel unsubscribes in the main feed, but we still show on their profile
122 if (msg.value.content.subscribed === false) return false
123 }
124
125 // skip messages that are directly replaced by the previous message
126 // e.g. follow / unfollow in quick succession
127 // THIS IS A TOTAL HACK!!! SHOULD BE REPLACED WITH A PROPER ROLLUP!
128 var isOutdated = isReplacementMessage(msg, lastMessage)
129
130 if (checkFeedFilter(msg) && !isOutdated) {
131 lastMessage = msg
132 return true
133 }
134 },
135 compactFilter: function (msg, root) {
136 if (!root && api.message.sync.root(msg)) {
137 // msg has a root, but is being displayed as root (fork)
138 return true
139 }
140 },
141 waitFor: computed([
142 following.sync,
143 subscribedChannels.sync
144 ], (...x) => x.every(Boolean))
145 })
146
147 // call reload whenever filters changes (equivalent to the refresh from inside rollup)
148 filters(feedView.reload)
149
150 var result = h('div.SplitView', [
151 h('div.side', [
152 getSidebar()
153 ]),
154 h('div.main', feedView)
155 ])
156
157 result.pendingUpdates = feedView.pendingUpdates
158 result.reload = function () {
159 feedView.reload()
160 }
161
162 return result
163
164 function checkFeedFilter (root) {
165 const filterObj = filters()
166 if (filterObj) {
167 const rootType = getType(root)
168 if (
169 (filterObj.following && rootType === 'contact') ||
170 (filterObj.subscriptions && rootType === 'channel') ||
171 (filterObj.onlySubscribed && rootType === 'post' && !matchesSubscribedChannel(root))
172 ) {
173 return false
174 }
175 }
176 return true
177 }
178
179 function matchesSubscribedChannel (msg) {
180 if (msg.filterResult) {
181 return msg.filterResult.matchesChannel || msg.filterResult.matchingTags.length
182 } else {
183 var channel = api.channel.sync.normalize(msg.value.content.channel)
184 var tagged = checkTag(msg.value.content.mentions)
185 var isSubscribed = channel ? subscribedChannels().has(channel) : false
186 return isSubscribed || tagged
187 }
188 }
189
190 function checkTag (mentions) {
191 if (Array.isArray(mentions)) {
192 return mentions.some((mention) => {
193 if (mention && typeof mention.link === 'string' && mention.link.startsWith('#')) {
194 var channel = api.channel.sync.normalize(mention.link.slice(1))
195 return channel ? subscribedChannels().has(channel) : false
196 }
197 })
198 }
199 }
200
201 function getSidebar () {
202 var whoToFollow = computed([api.profile.obs.recentlyUpdated(), following, blocking, localPeers], (recent, ...ignoreFeeds) => {
203 return recent.filter(x => x !== id && !ignoreFeeds.some(f => f.includes(x))).slice(0, 10)
204 })
205 return [
206 h('button -pub -full', {
207 'ev-click': api.invite.sheet
208 }, i18n('+ Join Pub')),
209 when(loading, [ h('Loading') ], [
210 when(computed(channels, x => x.length), h('h2', i18n('Active Channels'))),
211 h('div', {
212 classList: 'ChannelList',
213 hidden: loading
214 }, [
215 map(channels, (channel) => {
216 var subscribed = subscribedChannels.has(channel)
217 return h('a.channel', {
218 href: `#${channel}`,
219 classList: [
220 when(subscribed, '-subscribed')
221 ]
222 }, [
223 h('span.name', '#' + channel)
224 ])
225 }, {maxTime: 5}),
226 h('a.channel -more', {href: '/channels'}, i18n('More Channels...'))
227 ])
228 ]),
229
230 PeerList(localPeers, i18n('Local')),
231 PeerList(connectedPubs, i18n('Connected Pubs')),
232
233 when(computed(whoToFollow, x => x.length), h('h2', i18n('Who to follow'))),
234 when(following.sync,
235 h('div', {
236 classList: 'ProfileList'
237 }, [
238 map(whoToFollow, (id) => {
239 return h('a.profile', {
240 href: id
241 }, [
242 h('div.avatar', [api.about.html.image(id)]),
243 h('div.main', [
244 h('div.name', [ api.about.obs.name(id) ])
245 ])
246 ])
247 })
248 ])
249 )
250 ]
251 }
252
253 function PeerList (ids, title) {
254 return [
255 when(computed(ids, x => x.length), h('h2', title)),
256 h('div', {
257 classList: 'ProfileList'
258 }, [
259 map(ids, (id) => {
260 var connected = computed([connectedPeers, id], (peers, id) => peers.includes(id))
261 return h('a.profile', {
262 classList: [
263 when(connected, '-connected')
264 ],
265 href: id
266 }, [
267 h('div.avatar', [api.about.html.image(id)]),
268 h('div.main', [
269 h('div.name', [ api.about.obs.name(id) ])
270 ]),
271 h('div.progress', [
272 api.progress.html.peer(id)
273 ]),
274 h('div.controls', [
275 h('a.disconnect', {href: '#', 'ev-click': send(disconnect, id), title: i18n('Force Disconnect')}, ['x'])
276 ])
277 ])
278 })
279 ])
280 ]
281 }
282
283 function noVisibleNewPostsWarning () {
284 const explanation = i18n('You may not be able to see new content until you follow some users or pubs.')
285
286 const shownWhen = computed([loading, contact.isNotFollowingAnybody],
287 (isLoading, isNotFollowingAnybody) => !isLoading && isNotFollowingAnybody
288 )
289
290 return api.feed.html.followWarning(shownWhen, explanation)
291 }
292
293 function noFollowersWarning () {
294 const explanation = i18n(
295 'Nobody will be able to see your posts until you have a follower. The easiest way to get a follower is to use a pub invite as the pub will follow you back. If you have already redeemed a pub invite and you see it has not followed you back on your profile, try another pub.'
296 )
297
298 // We only show this if the user has followed someone as the first warning ('You are not following anyone')
299 // should be sufficient to get the user to join a pub. However, pubs have been buggy and not followed back on occassion.
300 // Additionally, someone onboarded on a local network might follow someone on the network, but not be followed back by
301 // them, so we begin to show this warning if the user has followed someone, but has no followers.
302 const shownWhen = computed([loading, contact.hasNoFollowers, contact.isNotFollowingAnybody],
303 (isLoading, hasNoFollowers, isNotFollowingAnybody) =>
304 !isLoading && (hasNoFollowers && !isNotFollowingAnybody)
305 )
306
307 return api.feed.html.followerWarning(shownWhen, explanation)
308 }
309
310 function disconnect (id) {
311 onceTrue(api.sbot.obs.connection, (sbot) => {
312 sbot.patchwork.disconnect(id)
313 })
314 }
315 }
316}
317
318function getType (msg) {
319 return msg && msg.value && msg.value.content && msg.value.content.type
320}
321
322function arrayEq (a, b) {
323 if (Array.isArray(a) && Array.isArray(b) && a.length === b.length && a !== b) {
324 return a.every((value, i) => value === b[i])
325 }
326}
327
328function isReplacementMessage (msgA, msgB) {
329 if (msgA && msgB && msgA.value.content && msgB.value.content && msgA.value.content.type === msgB.value.content.type) {
330 if (msgA.key === msgB.key) return false
331 var type = msgA.value.content.type
332 if (type === 'contact') {
333 return msgA.value.author === msgB.value.author && msgA.value.content.contact === msgB.value.content.contact
334 }
335 }
336}
337
338function isAttendee (msg) {
339 var content = msg.value && msg.value.content
340 return (content && content.type === 'about' && content.attendee && !content.attendee.remove)
341}
342

Built with git-ssb-web