git ssb

1+

Daan Patchwork / patchwork



Tree: 770fe5e2a3d5973a5c9b7d0945b214c3b2ea6793

Files: 770fe5e2a3d5973a5c9b7d0945b214c3b2ea6793 / lib / main-window.js

14895 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', '/status'
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 electron.ipcRenderer.on('goTostatus', () => api.app.navigate('/status'))
145
146 electron.ipcRenderer.on("navigate-to", (ev, target) => {
147 navigate(target);
148 });
149
150 // context menus are handled on the server. however, we need to know some
151 // information about the element under the cursor at the time of clicking.
152 // Sadly, electron doesn't seem to allow us to attach arbitrary information to
153 // an event and have it be transferred to the server directly. so we need to
154 // transfer that information "out of band" via sendSync.
155 // Note that invoke() might cause race conditions here IFF messages can change
156 // order in the queue between Renderer and Server, or if the IPC and document
157 // events use different queues. I'm not sure whether that's the case, but I
158 // haven't observed this happening yet.
159 document.addEventListener("contextmenu", (ev) => {
160 let element = document.elementFromPoint(ev.x, ev.y)
161 while (element && !element.msg) {
162 element = element.parentNode
163 }
164 // we only send the id, because that's cheap
165 // if the server actually wants to copy the text, it will
166 // ask us (see "copy-message-text" handler below)
167 electron.ipcRenderer.invoke("context-menu-info", {
168 msg: element?.msg || null,
169 });
170 })
171
172 // for when the server process asks us to please copy a message text to
173 // clipboard needed for context menu popups, because we want to call the
174 // getMessage only *if* the text is actually to be copied
175 electron.ipcRenderer.on("copy-message-text", (ev, key) => {
176 getMessageText(key, (err, value) => {
177 electron.clipboard.writeText(
178 !err ? value : `Error while retrieving message ${key}:\n${err}`
179 );
180 });
181 });
182
183 document.head.appendChild(
184 h('style', {
185 innerHTML: computed(api.settings.obs.get('patchwork.theme', 'light'), themeName => {
186 return themes[themeName] || themes.light
187 })
188 })
189 )
190
191 document.head.appendChild(
192 h('style', {
193 innerHTML: computed(api.settings.obs.get('patchwork.theme', 'light'), themeName => {
194 const syntaxThemeOptions = {
195 light: 'github',
196 dark: 'monokai',
197 dracula: 'dracula'
198 }
199
200 const syntaxTheme = syntaxThemeOptions[themeName] || syntaxThemeOptions.light
201 return requireStyle(`highlight.js/styles/${syntaxTheme}.css`)
202 })
203 })
204 )
205
206 document.head.appendChild(
207 h('style', {
208 innerHTML: requireStyle('noto-color-emoji')
209 })
210 )
211
212 document.head.appendChild(
213 h('style', {
214 innerHTML: computed(api.settings.obs.get('patchwork.fontSize'), size => {
215 if (size) {
216 return 'html, body {font-size: ' + size + ';}'
217 }
218 })
219 })
220 )
221
222 document.head.appendChild(
223 h('style', {
224 innerHTML: computed(api.settings.obs.get('patchwork.fontFamily'), family => {
225 if (family) {
226 return 'body, input, select { font-family: ' + family + ';}'
227 }
228 })
229 })
230 )
231
232 const container = h(`MainWindow -${process.platform}`, {
233 classList: [when(fullscreen(), '-fullscreen')],
234 'ev-dragover': preventDefault,
235 'ev-drop': preventDefault,
236 'ev-dragstart': preventDefaultUnlessImage
237 }, [
238 h('div.top', [
239 h('span.history', [
240 h('a', {
241 'ev-click': views.goBack,
242 classList: [when(views.canGoBack, '-active')]
243 }),
244 h('a', {
245 'ev-click': views.goForward,
246 classList: [when(views.canGoForward, '-active')]
247 })
248 ]),
249 h('span.nav', [
250 tab(i18n('Public'), '/public'),
251 tab(i18n('Private'), '/private'),
252 dropTab(i18n('More'))
253 ]),
254 h('span.appTitle', [
255 h('span.title', i18n('Patchwork')),
256 api.app.html.progressNotifier()
257 ]),
258 h('span', [api.app.html.search(api.app.navigate)]),
259 h('span.nav', [
260 tab(i18n('Profile'), id),
261 computed(includeParticipating, (includeParticipating) => {
262 if (includeParticipating) return tab(i18n('Participating'), '/participating')
263 }),
264 tab(i18n('Mentions'), '/mentions')
265 ])
266 ]),
267 when(latestUpdate,
268 h('div.info', [
269 h('a.message -update', { href: 'https://github.com/ssbc/patchwork/releases/latest' }, [
270 h('strong', ['Patchwork ', latestUpdate, i18n(' has been released.')]), i18n(' Click here to download and view more info!'),
271 h('a.ignore', { 'ev-click': latestUpdate.ignore }, 'X')
272 ])
273 ])
274 ),
275 views.html
276 ])
277
278 const previewElement = api.app.linkPreview(container, 500)
279
280 catchLinks(container, (href, external, anchor) => {
281 if (!href) return
282 if (external) {
283 electron.shell.openExternal(href)
284 } else {
285 api.app.navigate(href, anchor)
286 }
287 })
288 return [container, previewElement]
289
290 // scoped
291
292 function getSubscribedChannelMenu () {
293 const channels = Array.from(subscribedChannels()).sort(localeCompare)
294
295 if (channels.length) {
296 return {
297 type: 'submenu',
298 label: i18n('Channels'),
299 submenu: [
300 {
301 type: 'normal',
302 label: i18n('Browse Recently Active'),
303 target: '/channels'
304 },
305 { type: 'separator' }
306 ].concat(channels.map(channel => ({
307 type: 'normal',
308 label: `#${channel}`,
309 target: `#${channel}`,
310 })))
311 }
312 } else {
313 return {
314 type: 'normal',
315 label: i18n('Browse Channels'),
316 target: '/channels',
317 }
318 }
319 }
320
321 function buildDropdownMenuItems() {
322 const dropTabItems = [
323 getSubscribedChannelMenu(),
324 {
325 type: 'submenu',
326 label: i18n("Participating"),
327 submenu: [
328 {
329 type: "normal",
330 label: i18n("All Threads"),
331 target: "/participating",
332 },
333 {
334 type: "normal",
335 label: i18n("Threads Started By You"),
336 target: "/your-posts",
337 },
338 ],
339 },
340 {
341 type: "submenu",
342 label: i18n("Gatherings"),
343 submenu: [
344 {
345 type: "normal",
346 label: i18n("All"),
347 target: "/gatherings",
348 },
349 {
350 type: "normal",
351 label: i18n("Attending"),
352 target: "/attending-gatherings",
353 },
354 ],
355 },
356 {
357 type: "normal",
358 label: i18n("Tags"),
359 target: `/tags/all/${encodeURIComponent(id)}`,
360 },
361 {
362 type: "normal",
363 label: i18n("Extended Network"),
364 target: "/all",
365 },
366 { type: "separator" },
367 {
368 type: "normal",
369 label: i18n("Settings"),
370 target: "/settings",
371 },
372 {
373 type: "normal",
374 label: i18n("Status"),
375 target: "/status",
376 },
377 ];
378 return dropTabItems
379 }
380
381 function dropTab (title) {
382 const element = h('a -drop', {
383 'ev-click': () => {
384 const dropTabItems = buildDropdownMenuItems()
385 const rects = element.getBoundingClientRect()
386 electron.ipcRenderer.invoke('navigation-menu-popup', {
387 x: rects.left,
388 y: rects.bottom,
389 items: dropTabItems,
390 })
391 }
392 }, title)
393 return element
394 }
395
396 function navigate (href, anchor) {
397 if (typeof href !== 'string') return false
398 getExternalHandler(href, (err, handler) => {
399 if (!err && handler) {
400 handler(href)
401 } else {
402 if (href.startsWith('ssb:')) {
403 try {
404 href = ssbUri.toSigilLink(href)
405 } catch (e) {
406 // error can be safely ignored
407 // it just means this isn't an SSB URI
408 }
409 }
410
411 // no external handler found, use page.html.render
412 previewElement.cancel()
413 views.setView(href, anchor)
414 }
415 })
416 }
417
418 function getExternalHandler (href, cb) {
419 const link = ref.parseLink(href)
420 if (link && ref.isMsg(link.link)) {
421 const params = { id: link.link }
422 if (link.query && link.query.unbox) {
423 params.private = true
424 params.unbox = link.query.unbox
425 }
426 api.sbot.async.get(params, function (err, value) {
427 if (err) return cb(err)
428 cb(null, getGitHandler({ key: link.link, value, query: link.query }))
429 })
430 } else if (link && ref.isBlob(link.link)) {
431 cb(null, function (href) {
432 electron.shell.openExternal(api.blob.sync.url(href))
433 })
434 } else {
435 cb()
436 }
437 }
438
439 function tab (name, view) {
440 const instance = views.get(view)
441 return h('a', {
442 'ev-click': function () {
443 const instance = views.get(view)
444 const isSelected = views.currentView() === view
445 const needsRefresh = instance && instance.pendingUpdates && instance.pendingUpdates()
446
447 // refresh if tab is clicked when there are pending items or the page is already selected
448 if ((needsRefresh || isSelected) && instance.reload) {
449 instance.reload()
450 }
451 },
452 href: view,
453 classList: [
454 when(selected(view), '-selected')
455 ]
456 }, [
457 name,
458 instance
459 ? when(instance.pendingUpdates, [' (', instance.pendingUpdates, ')'])
460 : null
461 ])
462 }
463
464 function selected (view) {
465 return computed([views.currentView, view], (currentView, view) => {
466 return currentView === view
467 })
468 }
469
470 function getMessageText (id, cb) {
471 api.sbot.async.get(id, (err, value) => {
472 if (err) return cb(err)
473 if (value.content.type === 'gathering') {
474 api.about.async.latestValues(id, ['title', 'description'], (err, values) => {
475 if (err) return cb(err)
476 const text = `# ${values.title}\n\n${values.description}`
477 cb(null, text)
478 })
479 } else {
480 cb(null, value.content.text)
481 }
482 })
483 }
484}
485
486function overrideConfig (config) {
487 return {
488 'patchwork/config': {
489 gives: nest('config.sync.load'),
490 create: function () {
491 return nest('config.sync.load', () => config)
492 }
493 }
494 }
495}
496
497function addCommand (id, cb) {
498 return {
499 [`patchwork/command/${id}`]: {
500 gives: nest(id),
501 create: function () {
502 return nest(id, cb)
503 }
504 }
505 }
506}
507
508function localeCompare (a, b) {
509 return a.localeCompare(b)
510}
511
512function preventDefault (ev) {
513 ev.preventDefault()
514}
515
516function preventDefaultUnlessImage (ev) {
517 if (ev.target.nodeName !== 'IMG') {
518 ev.preventDefault()
519 }
520}
521

Built with git-ssb-web