Files: 3451510316992d414ec76ba5b29681fe359b7428 / lib / depject / message / html / compose.js
9167 bytesRaw
1 | const h = require('mutant/h') |
2 | const when = require('mutant/when') |
3 | const resolve = require('mutant/resolve') |
4 | const Value = require('mutant/value') |
5 | const computed = require('mutant/computed') |
6 | const nest = require('depnest') |
7 | const mentions = require('ssb-mentions') |
8 | const extend = require('xtend') |
9 | const ref = require('ssb-ref') |
10 | const blobFiles = require('ssb-blob-files') |
11 | |
12 | exports.needs = nest({ |
13 | 'blob.html.input': 'first', |
14 | 'suggest.hook': 'first', |
15 | |
16 | 'message.async.publish': 'first', |
17 | 'sbot.obs.connection': 'first', |
18 | 'intl.sync.i18n': 'first' |
19 | }) |
20 | |
21 | exports.gives = nest('message.html.compose') |
22 | |
23 | exports.create = function (api) { |
24 | const i18n = api.intl.sync.i18n |
25 | return nest('message.html.compose', function ({ shrink = true, isPrivate, participants, meta, hooks, prepublish, placeholder = 'Write a message', draftKey }, cb) { |
26 | const files = [] |
27 | const filesById = {} |
28 | const focused = Value(false) |
29 | const hasContent = Value(false) |
30 | const publishing = Value(false) |
31 | |
32 | let blurTimeout = null |
33 | let saveTimer = null |
34 | |
35 | const expanded = computed([shrink, focused, hasContent], (shrink, focused, hasContent) => { |
36 | if (!shrink || hasContent) { |
37 | return true |
38 | } else { |
39 | return focused |
40 | } |
41 | }) |
42 | |
43 | const textArea = h('textarea', { |
44 | hooks: [api.suggest.hook({ participants })], |
45 | 'ev-dragover': onDragOver, |
46 | 'ev-drop': onDrop, |
47 | 'ev-input': function () { |
48 | refreshHasContent() |
49 | queueSave() |
50 | }, |
51 | 'ev-blur': () => { |
52 | clearTimeout(blurTimeout) |
53 | blurTimeout = setTimeout(() => focused.set(false), 200) |
54 | }, |
55 | 'ev-focus': () => { |
56 | clearTimeout(blurTimeout) |
57 | focused.set(true) |
58 | }, |
59 | 'ev-paste': ev => { |
60 | const files = ev.clipboardData && ev.clipboardData.files |
61 | if (!files || !files.length) return |
62 | attachFiles(files) |
63 | }, |
64 | 'ev-keydown': ev => { |
65 | if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey)) { |
66 | publish() |
67 | ev.preventDefault() |
68 | } |
69 | }, |
70 | disabled: publishing, |
71 | placeholder |
72 | }) |
73 | |
74 | const contentWarningInput = h('input.contentWarning', { |
75 | placeholder: 'Write a content warning (optional)', |
76 | 'ev-input': function () { |
77 | refreshHasContent() |
78 | queueSave() |
79 | }, |
80 | 'ev-blur': () => { |
81 | clearTimeout(blurTimeout) |
82 | blurTimeout = setTimeout(() => focused.set(false), 200) |
83 | }, |
84 | 'ev-focus': () => { |
85 | clearTimeout(blurTimeout) |
86 | focused.set(true) |
87 | } |
88 | }) |
89 | |
90 | if (draftKey) { |
91 | const draft = window.localStorage[`patchwork.drafts.${draftKey}`] |
92 | if (draft) { |
93 | textArea.value = draft |
94 | } |
95 | const draftCW = window.localStorage[`patchwork.drafts.contentWarning.${draftKey}`] |
96 | if (draftCW) { |
97 | contentWarningInput.value = draftCW |
98 | } |
99 | refreshHasContent() |
100 | } |
101 | |
102 | const warningMessage = Value(null) |
103 | const warning = h('section.warning', |
104 | { className: when(warningMessage, '-open', '-closed') }, |
105 | [ |
106 | h('div.warning', warningMessage), |
107 | h('div.close', { 'ev-click': () => warningMessage.set(null) }, 'x') |
108 | ] |
109 | ) |
110 | const fileInput = api.blob.html.input(afterAttach, { |
111 | private: isPrivate, |
112 | multiple: true |
113 | }) |
114 | |
115 | fileInput.onclick = function () { |
116 | hasContent.set(true) |
117 | } |
118 | |
119 | const clearButton = h('button -clear', { |
120 | 'ev-click': clear |
121 | }, [ |
122 | i18n('Clear Draft') |
123 | ]) |
124 | |
125 | const publishBtn = h('button', { |
126 | 'ev-click': publish, |
127 | classList: [ |
128 | when(isPrivate, '-private') |
129 | ], |
130 | disabled: publishing |
131 | }, when(publishing, |
132 | i18n('Publishing...'), |
133 | when(isPrivate, i18n('Preview & Publish Privately'), i18n('Preview & Publish')) |
134 | )) |
135 | |
136 | const actions = h('section.actions', [ |
137 | fileInput, |
138 | contentWarningInput, |
139 | h('div', [ |
140 | when(hasContent, clearButton), |
141 | publishBtn |
142 | ]) |
143 | ]) |
144 | |
145 | const composer = h('Compose', { |
146 | hooks, |
147 | classList: [ |
148 | when(expanded, '-expanded', '-contracted') |
149 | ] |
150 | }, [ |
151 | textArea, |
152 | warning, |
153 | actions |
154 | ]) |
155 | |
156 | composer.focus = function () { |
157 | textArea.focus() |
158 | } |
159 | |
160 | composer.setText = function (value) { |
161 | textArea.value = value |
162 | refreshHasContent() |
163 | } |
164 | |
165 | return composer |
166 | |
167 | // scoped |
168 | |
169 | function clear () { |
170 | if (!window.confirm(i18n('Are you certain you want to clear your draft?'))) { |
171 | return |
172 | } |
173 | textArea.value = '' |
174 | contentWarningInput.value = '' |
175 | refreshHasContent() |
176 | save() |
177 | } |
178 | |
179 | function refreshHasContent () { |
180 | hasContent.set(!!textArea.value || !!contentWarningInput.value) |
181 | } |
182 | |
183 | function queueSave () { |
184 | saveTimer = setTimeout(save, 1000) |
185 | } |
186 | |
187 | function save () { |
188 | clearTimeout(saveTimer) |
189 | if (draftKey) { |
190 | if (!textArea.value) { |
191 | delete window.localStorage[`patchwork.drafts.${draftKey}`] |
192 | } else { |
193 | window.localStorage[`patchwork.drafts.${draftKey}`] = textArea.value |
194 | } |
195 | if (!contentWarningInput.value) { |
196 | delete window.localStorage[`patchwork.drafts.contentWarning.${draftKey}`] |
197 | } else { |
198 | window.localStorage[`patchwork.drafts.contentWarning.${draftKey}`] = contentWarningInput.value |
199 | } |
200 | } |
201 | } |
202 | |
203 | function onDragOver (ev) { |
204 | ev.dataTransfer.dropEffect = 'copy' |
205 | ev.preventDefault() |
206 | return false |
207 | } |
208 | |
209 | function onDrop (ev) { |
210 | ev.preventDefault() |
211 | |
212 | const files = ev.dataTransfer && ev.dataTransfer.files |
213 | if (!files || !files.length) return |
214 | |
215 | ev.dataTransfer.dropEffect = 'copy' |
216 | attachFiles(files) |
217 | return false |
218 | } |
219 | |
220 | function attachFiles (files) { |
221 | blobFiles(files, api.sbot.obs.connection, { |
222 | stripExif: true, |
223 | isPrivate: resolve(isPrivate) |
224 | }, afterAttach) |
225 | } |
226 | |
227 | function afterAttach (err, file) { |
228 | if (err) { |
229 | if (err instanceof blobFiles.MaxSizeError) { |
230 | warningMessage.set([ |
231 | // TODO: handle localised error messages (https://github.com/ssbc/ssb-blob-files/issues/3) |
232 | '⚠️ ', i18n('{{name}} ({{size}}) is larger than the allowed limit of {{max_size}}', { |
233 | name: err.fileName, |
234 | size: humanSize(err.fileSize), |
235 | max_size: humanSize(err.maxFileSize) |
236 | }) |
237 | ]) |
238 | } |
239 | return |
240 | } |
241 | |
242 | files.push(file) |
243 | |
244 | const parsed = ref.parseLink(file.link) |
245 | filesById[parsed.link] = file |
246 | |
247 | const embed = isEmbeddable(file.type) ? '!' : '' |
248 | const pos = textArea.selectionStart |
249 | let before = textArea.value.slice(0, pos) |
250 | let after = textArea.value.slice(pos) |
251 | |
252 | const spacer = embed ? '\n' : ' ' |
253 | if (before && !before.endsWith(spacer)) before += spacer |
254 | if (!after.startsWith(spacer)) after = spacer + after |
255 | |
256 | const embedPrefix = getEmbedPrefix(file.type) |
257 | |
258 | textArea.value = `${before}${embed}[${embedPrefix}${file.name}](${file.link})${after}` |
259 | console.log('added:', file) |
260 | } |
261 | |
262 | function publish () { |
263 | if (!textArea.value) { |
264 | return |
265 | } |
266 | publishing.set(true) |
267 | |
268 | let content = extend(resolve(meta), { |
269 | text: textArea.value, |
270 | mentions: mentions(textArea.value).map(mention => { |
271 | // merge markdown-detected mention with file info |
272 | const file = filesById[mention.link] |
273 | if (file) { |
274 | if (file.type) mention.type = file.type |
275 | if (file.size) mention.size = file.size |
276 | } |
277 | return mention |
278 | }) |
279 | }) |
280 | |
281 | const cw = contentWarningInput.value.trim() |
282 | if (cw.length > 0) { |
283 | content.contentWarning = cw |
284 | } |
285 | |
286 | try { |
287 | if (typeof prepublish === 'function') { |
288 | content = prepublish(content) |
289 | } |
290 | } catch (err) { |
291 | return done(err) |
292 | } |
293 | |
294 | return api.message.async.publish(content, done) |
295 | |
296 | function done (err, msg) { |
297 | publishing.set(false) |
298 | if (err) { |
299 | if (cb) cb(err) |
300 | else { |
301 | showDialog({ |
302 | type: 'error', |
303 | title: i18n('Error'), |
304 | buttons: [i18n('OK')], |
305 | message: i18n('An error occurred while publishing your message.'), |
306 | detail: err.message |
307 | }) |
308 | } |
309 | } else { |
310 | if (msg) { |
311 | textArea.value = '' |
312 | contentWarningInput.value = '' |
313 | refreshHasContent() |
314 | save() |
315 | } else { |
316 | textArea.focus() |
317 | } |
318 | if (cb) cb(null, msg) |
319 | } |
320 | } |
321 | } |
322 | }) |
323 | } |
324 | |
325 | function showDialog (opts) { |
326 | const electron = require('electron') |
327 | electron.remote.dialog.showMessageBox(electron.remote.getCurrentWindow(), opts) |
328 | } |
329 | |
330 | function isEmbeddable (type) { |
331 | return type.startsWith('image/') || type.startsWith('audio/') || type.startsWith('video/') |
332 | } |
333 | |
334 | function getEmbedPrefix (type) { |
335 | if (typeof type === 'string') { |
336 | if (type.startsWith('audio/')) return 'audio:' |
337 | if (type.startsWith('video/')) return 'video:' |
338 | } |
339 | return '' |
340 | } |
341 | |
342 | function humanSize (size) { |
343 | return (Math.ceil(size / (1024 * 1024) * 10) / 10) + ' MB' |
344 | } |
345 |
Built with git-ssb-web