git ssb

1+

Daan Patchwork / patchwork



Tree: 1786f9c4c5f7c770e215e697546f92cbd098bc2c

Files: 1786f9c4c5f7c770e215e697546f92cbd098bc2c / lib / main-window.js

14710 bytesRaw
1const combine = require('depject')
2const entry = require('depject/entry')
3const electron = require('electron')
4const h = require('mutant/h')
5const when = require('mutant/when')
6const onceTrue = require('mutant/once-true')
7const computed = require('mutant/computed')
8const catchLinks = require('./catch-links')
9const themes = require('../styles')
10const nest = require('depnest')
11const LatestUpdate = require('./latest-update')
12const ref = require('ssb-ref')
13const watch = require('mutant/watch')
14const requireStyle = require('require-style')
15const ssbUri = require('ssb-uri')
16const pull = require('pull-stream')
17const moment = require('moment-timezone')
18const fullscreen = require('./fullscreen.js')
19const appViews = require('./app/views.js')
20const getGitHandler = require('./app/sync/external-handler/git')
21
22const localTimezone = moment.tz.guess()
23moment.tz.setDefault(localTimezone)
24
25module.exports = function (config) {
26 const sockets = combine(
27 overrideConfig(config),
28 addCommand('app.navigate', navigate),
29 require('./depject'),
30 require('patch-settings')
31 )
32
33 const api = entry(sockets, nest({
34 'config.sync.load': 'first',
35 'keys.sync.id': 'first',
36 'sbot.obs.connection': 'first',
37 'sbot.async.get': 'first',
38 'blob.sync.url': 'first',
39 'page.html.render': 'first',
40 'app.html.search': 'first',
41 'app.html.channels': 'first',
42 'app.html.progressNotifier': 'first',
43 'about.async.latestValues': 'first',
44 'profile.sheet.edit': 'first',
45 'profile.html.preview': 'first',
46 'app.navigate': 'first',
47 'app.linkPreview': 'first',
48 'channel.obs.subscribed': 'first',
49 'settings.obs.get': 'first',
50 'intl.sync.i18n': 'first',
51 'contact.obs.blocking': 'first'
52 }))
53
54 const i18n = api.intl.sync.i18n
55 const language = api.settings.obs.get('patchwork.lang', '')()
56 moment.locale(language)
57
58 const id = api.keys.sync.id()
59 const latestUpdate = LatestUpdate()
60 const subscribedChannels = api.channel.obs.subscribed(id)
61 const includeParticipating = api.settings.obs.get('patchwork.includeParticipating', false)
62 const autoDeleteBlocked = api.settings.obs.get('patchwork.autoDeleteBlocked', false)
63
64 // prompt to setup profile on first use
65 onceTrue(api.sbot.obs.connection, (ssb) => {
66 ssb.latestSequence(api.keys.sync.id(), (err, key) => {
67 if (err) {
68 // This may throw an error if the feed doesn't have any messages, but
69 // that shouldn't cause any problems so this error can be ignored.
70 }
71
72 if (key == null) {
73 api.profile.sheet.edit({ usePreview: false })
74 }
75 })
76
77 const currentlyDeleting = {}
78
79 watch(api.contact.obs.blocking(id), (blocking) => {
80 if (autoDeleteBlocked() !== true) return
81 if (blocking.length === 0) return
82
83 if (ssb.del == null) return
84
85 blocking
86 .filter(feedId => feedId !== ssb.id)
87 .forEach(feed => {
88 pull(
89 ssb.createUserStream({ id: feed }),
90 pull.asyncMap((msg, cb) => {
91 const key = msg.key
92 if (currentlyDeleting[key] === true) {
93 return cb(null, null) // already deleting
94 }
95
96 currentlyDeleting[key] = true
97
98 ssb.del(key, (err) => {
99 currentlyDeleting[key] = false
100 cb(err, key)
101 })
102 }),
103 pull.filter(),
104 pull.collect((err, keys) => {
105 if (err) {
106 console.error(keys)
107 throw err
108 }
109
110 if (keys.length > 0) {
111 console.log(`deleted ${keys.length} messages from blocked authors`)
112 }
113 })
114 )
115 })
116 })
117 })
118
119 const defaultViews = computed(includeParticipating, (includeParticipating) => {
120 const result = [
121 '/public', '/private', '/mentions'
122 ]
123
124 // allow user to choose in settings whether to show participating tab
125 if (includeParticipating) {
126 result.push('/participating')
127 }
128
129 return result
130 })
131
132 const views = appViews(api.page.html.render, defaultViews)
133
134 const pendingCount = views.get('/mentions').pendingUpdates
135
136 watch(pendingCount, count => {
137 electron.ipcRenderer.invoke('badgeCount', count)
138 })
139
140 electron.ipcRenderer.on('goForward', views.goForward)
141 electron.ipcRenderer.on('goBack', views.goBack)
142
143 electron.ipcRenderer.on('goToSettings', () => api.app.navigate('/settings'))
144
145 electron.ipcRenderer.on("navigate-to", (ev, target) => {
146 navigate(target);
147 });
148
149 // context menus are handled on the server. however, we need to know some
150 // information about the element under the cursor at the time of clicking.
151 // Sadly, electron doesn't seem to allow us to attach arbitrary information to
152 // an event and have it be transferred to the server directly. so we need to
153 // transfer that information "out of band" via sendSync.
154 // Note that invoke() might cause race conditions here IFF messages can change
155 // order in the queue between Renderer and Server, or if the IPC and document
156 // events use different queues. I'm not sure whether that's the case, but I
157 // haven't observed this happening yet.
158 document.addEventListener("contextmenu", (ev) => {
159 let element = document.elementFromPoint(ev.x, ev.y)
160 while (element && !element.msg) {
161 element = element.parentNode
162 }
163 // we only send the id, because that's cheap
164 // if the server actually wants to copy the text, it will
165 // ask us (see "copy-message-text" handler below)
166 electron.ipcRenderer.invoke("context-menu-info", {
167 msg: element?.msg || null,
168 });
169 })
170
171 // for when the server process asks us to please copy a message text to
172 // clipboard needed for context menu popups, because we want to call the
173 // getMessage only *if* the text is actually to be copied
174 electron.ipcRenderer.on("copy-message-text", (ev, key) => {
175 getMessageText(key, (err, value) => {
176 electron.clipboard.writeText(
177 !err ? value : `Error while retrieving message ${key}:\n${err}`
178 );
179 });
180 });
181
182 document.head.appendChild(
183 h('style', {
184 innerHTML: computed(api.settings.obs.get('patchwork.theme', 'light'), themeName => {
185 return themes[themeName] || themes.light
186 })
187 })
188 )
189
190 document.head.appendChild(
191 h('style', {
192 innerHTML: computed(api.settings.obs.get('patchwork.theme', 'light'), themeName => {
193 const syntaxThemeOptions = {
194 light: 'github',
195 dark: 'monokai',
196 dracula: 'dracula'
197 }
198
199 const syntaxTheme = syntaxThemeOptions[themeName] || syntaxThemeOptions.light
200 return requireStyle(`highlight.js/styles/${syntaxTheme}.css`)
201 })
202 })
203 )
204
205 document.head.appendChild(
206 h('style', {
207 innerHTML: requireStyle('noto-color-emoji')
208 })
209 )
210
211 document.head.appendChild(
212 h('style', {
213 innerHTML: computed(api.settings.obs.get('patchwork.fontSize'), size => {
214 if (size) {
215 return 'html, body {font-size: ' + size + ';}'
216 }
217 })
218 })
219 )
220
221 document.head.appendChild(
222 h('style', {
223 innerHTML: computed(api.settings.obs.get('patchwork.fontFamily'), family => {
224 if (family) {
225 return 'body, input, select { font-family: ' + family + ';}'
226 }
227 })
228 })
229 )
230
231 const container = h(`MainWindow -${process.platform}`, {
232 classList: [when(fullscreen(), '-fullscreen')],
233 'ev-dragover': preventDefault,
234 'ev-drop': preventDefault,
235 'ev-dragstart': preventDefaultUnlessImage
236 }, [
237 h('div.top', [
238 h('span.history', [
239 h('a', {
240 'ev-click': views.goBack,
241 classList: [when(views.canGoBack, '-active')]
242 }),
243 h('a', {
244 'ev-click': views.goForward,
245 classList: [when(views.canGoForward, '-active')]
246 })
247 ]),
248 h('span.nav', [
249 tab(i18n('Public'), '/public'),
250 tab(i18n('Private'), '/private'),
251 dropTab(i18n('More'))
252 ]),
253 h('span.appTitle', [
254 h('span.title', i18n('Patchwork')),
255 api.app.html.progressNotifier()
256 ]),
257 h('span', [api.app.html.search(api.app.navigate)]),
258 h('span.nav', [
259 tab(i18n('Profile'), id),
260 computed(includeParticipating, (includeParticipating) => {
261 if (includeParticipating) return tab(i18n('Participating'), '/participating')
262 }),
263 tab(i18n('Mentions'), '/mentions')
264 ])
265 ]),
266 when(latestUpdate,
267 h('div.info', [
268 h('a.message -update', { href: 'https://github.com/ssbc/patchwork/releases/latest' }, [
269 h('strong', ['Patchwork ', latestUpdate, i18n(' has been released.')]), i18n(' Click here to download and view more info!'),
270 h('a.ignore', { 'ev-click': latestUpdate.ignore }, 'X')
271 ])
272 ])
273 ),
274 views.html
275 ])
276
277 const previewElement = api.app.linkPreview(container, 500)
278
279 catchLinks(container, (href, external, anchor) => {
280 if (!href) return
281 if (external) {
282 electron.shell.openExternal(href)
283 } else {
284 api.app.navigate(href, anchor)
285 }
286 })
287 return [container, previewElement]
288
289 // scoped
290
291 function getSubscribedChannelMenu () {
292 const channels = Array.from(subscribedChannels()).sort(localeCompare)
293
294 if (channels.length) {
295 return {
296 type: 'submenu',
297 label: i18n('Channels'),
298 submenu: [
299 {
300 type: 'normal',
301 label: i18n('Browse Recently Active'),
302 target: '/channels'
303 },
304 { type: 'separator' }
305 ].concat(channels.map(channel => ({
306 type: 'normal',
307 label: `#${channel}`,
308 target: `#${channel}`,
309 })))
310 }
311 } else {
312 return {
313 type: 'normal',
314 label: i18n('Browse Channels'),
315 target: '/channels',
316 }
317 }
318 }
319
320 function buildDropdownMenuItems() {
321 const dropTabItems = [
322 getSubscribedChannelMenu(),
323 {
324 type: 'submenu',
325 label: i18n("Participating"),
326 submenu: [
327 {
328 type: "normal",
329 label: i18n("All Threads"),
330 target: "/participating",
331 },
332 {
333 type: "normal",
334 label: i18n("Threads Started By You"),
335 target: "/your-posts",
336 },
337 ],
338 },
339 {
340 type: "submenu",
341 label: i18n("Gatherings"),
342 submenu: [
343 {
344 type: "normal",
345 label: i18n("All"),
346 target: "/gatherings",
347 },
348 {
349 type: "normal",
350 label: i18n("Attending"),
351 target: "/attending-gatherings",
352 },
353 ],
354 },
355 {
356 type: "normal",
357 label: i18n("Tags"),
358 target: `/tags/all/${encodeURIComponent(id)}`,
359 },
360 {
361 type: "normal",
362 label: i18n("Extended Network"),
363 target: "/all",
364 },
365 { type: "separator" },
366 {
367 type: "normal",
368 label: i18n("Settings"),
369 target: "/settings",
370 },
371 ];
372 return dropTabItems
373 }
374
375 function dropTab (title) {
376 const element = h('a -drop', {
377 'ev-click': () => {
378 const dropTabItems = buildDropdownMenuItems()
379 const rects = element.getBoundingClientRect()
380 electron.ipcRenderer.invoke('navigation-menu-popup', {
381 x: rects.left,
382 y: rects.bottom,
383 items: dropTabItems,
384 })
385 }
386 }, title)
387 return element
388 }
389
390 function navigate (href, anchor) {
391 if (typeof href !== 'string') return false
392 getExternalHandler(href, (err, handler) => {
393 if (!err && handler) {
394 handler(href)
395 } else {
396 if (href.startsWith('ssb:')) {
397 try {
398 href = ssbUri.toSigilLink(href)
399 } catch (e) {
400 // error can be safely ignored
401 // it just means this isn't an SSB URI
402 }
403 }
404
405 // no external handler found, use page.html.render
406 previewElement.cancel()
407 views.setView(href, anchor)
408 }
409 })
410 }
411
412 function getExternalHandler (href, cb) {
413 const link = ref.parseLink(href)
414 if (link && ref.isMsg(link.link)) {
415 const params = { id: link.link }
416 if (link.query && link.query.unbox) {
417 params.private = true
418 params.unbox = link.query.unbox
419 }
420 api.sbot.async.get(params, function (err, value) {
421 if (err) return cb(err)
422 cb(null, getGitHandler({ key: link.link, value, query: link.query }))
423 })
424 } else if (link && ref.isBlob(link.link)) {
425 cb(null, function (href) {
426 electron.shell.openExternal(api.blob.sync.url(href))
427 })
428 } else {
429 cb()
430 }
431 }
432
433 function tab (name, view) {
434 const instance = views.get(view)
435 return h('a', {
436 'ev-click': function () {
437 const instance = views.get(view)
438 const isSelected = views.currentView() === view
439 const needsRefresh = instance && instance.pendingUpdates && instance.pendingUpdates()
440
441 // refresh if tab is clicked when there are pending items or the page is already selected
442 if ((needsRefresh || isSelected) && instance.reload) {
443 instance.reload()
444 }
445 },
446 href: view,
447 classList: [
448 when(selected(view), '-selected')
449 ]
450 }, [
451 name,
452 instance
453 ? when(instance.pendingUpdates, [' (', instance.pendingUpdates, ')'])
454 : null
455 ])
456 }
457
458 function selected (view) {
459 return computed([views.currentView, view], (currentView, view) => {
460 return currentView === view
461 })
462 }
463
464 function getMessageText (id, cb) {
465 api.sbot.async.get(id, (err, value) => {
466 if (err) return cb(err)
467 if (value.content.type === 'gathering') {
468 api.about.async.latestValues(id, ['title', 'description'], (err, values) => {
469 if (err) return cb(err)
470 const text = `# ${values.title}\n\n${values.description}`
471 cb(null, text)
472 })
473 } else {
474 cb(null, value.content.text)
475 }
476 })
477 }
478}
479
480function overrideConfig (config) {
481 return {
482 'patchwork/config': {
483 gives: nest('config.sync.load'),
484 create: function () {
485 return nest('config.sync.load', () => config)
486 }
487 }
488 }
489}
490
491function addCommand (id, cb) {
492 return {
493 [`patchwork/command/${id}`]: {
494 gives: nest(id),
495 create: function () {
496 return nest(id, cb)
497 }
498 }
499 }
500}
501
502function localeCompare (a, b) {
503 return a.localeCompare(b)
504}
505
506function preventDefault (ev) {
507 ev.preventDefault()
508}
509
510function preventDefaultUnlessImage (ev) {
511 if (ev.target.nodeName !== 'IMG') {
512 ev.preventDefault()
513 }
514}
515

Built with git-ssb-web