git ssb

10+

Matt McKegg / patchwork



Tree: 02eb138217bf2dd28c3be988febc79c8d62e190b

Files: 02eb138217bf2dd28c3be988febc79c8d62e190b / modules / page / html / render / public.js

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

Built with git-ssb-web