secure-scuttlebot classic
1'use strict'
2var h = require('hyperscript')
3var selfId = require('../keys').id
4var suggest = require('suggest-box')
5var mentions = require('ssb-mentions')
6var lightbox = require('hyperlightbox')
7var cont = require('cont')
8
9//var plugs = require('../plugs')
10//var suggest_mentions= plugs.asyncConcat(exports.suggest_mentions = [])
11//var publish = plugs.first(exports.sbot_publish = [])
12//var message_content = plugs.first(exports.message_content = [])
13//var message_confirm = plugs.first(exports.message_confirm = [])
14//var file_input = plugs.first(exports.file_input = [])
15
16exports.needs = {
17 suggest_mentions: 'map', //<-- THIS MUST BE REWRITTEN
18 publish: 'first',
19 message_content: 'first',
20 message_confirm: 'first',
21 file_input: 'first',
22 message_link: 'first',
23 avatar: 'first'
24}
25
26exports.gives = 'message_compose'
27
28function id (e) { return e }
29
30/*
31 opts can take
32
33 placeholder: string. placeholder text, defaults to "Write a message"
34 prepublish: function. called before publishing a message.
35 shrink: boolean. set to false, to make composer not shrink (or hide controls) when unfocused.
36*/
37
38exports.create = function (api) {
39
40 return function (meta, opts, cb) {
41 if('function' === typeof cb) {
42 if('function' === typeof opts)
43 opts = {prepublish: opts}
44 }
45
46 if(!opts) opts = {}
47 opts.prepublish = opts.prepublish || id
48
49 var accessories
50 meta = meta || {}
51 if(!meta.type) throw new Error('message must have type')
52 var modal = !!opts.modal
53 var lb = null
54 var modalContent = null
55 var replyHintEls = []
56 var modalTimer = null
57 var onKeydown = null
58 var trigger = null
59 var baseSnapshot = null
60 var replyActive = false
61 var lastReplyMsg = null
62 var inlineReplyHint = null
63
64 function cloneMeta (src) {
65 var out = {}
66 for (var k in src) out[k] = src[k]
67 return out
68 }
69
70 function applyMeta (target, source) {
71 for (var k in target) delete target[k]
72 for (var key in source) target[key] = source[key]
73 }
74
75 function captureBaseMeta () {
76 baseSnapshot = cloneMeta(meta)
77 }
78
79 function clearReply () {
80 if (replyActive && baseSnapshot) {
81 applyMeta(meta, baseSnapshot)
82 }
83 replyActive = false
84 lastReplyMsg = null
85 updateReplyHint(null)
86 }
87 function createReplyHintEl (className) {
88 var selector = 'div' + (className ? '.' + className : '')
89 var el = h(selector, {style: {display: 'none'}})
90 replyHintEls.push(el)
91 return el
92 }
93 if (!modal) {
94 inlineReplyHint = h('div.compose-reply-hint', {style: {display: 'none'}})
95 replyHintEls.push(inlineReplyHint)
96 }
97 var ta = h('textarea', {
98 placeholder: opts.placeholder || 'Write a message',
99 style: {height: opts.shrink === false ? '200px' : ''}
100 })
101
102 if(opts.shrink !== false) {
103 var blur
104 ta.addEventListener('focus', function () {
105 clearTimeout(blur)
106 if(!ta.value) {
107 ta.style.height = '200px'
108 }
109 accessories.style.display = 'block'
110 })
111 ta.addEventListener('blur', function () {
112 //don't shrink right away, so there is time
113 //to click the publish button.
114 clearTimeout(blur)
115 blur = setTimeout(function () {
116 if(ta.value) return
117 ta.style.height = '50px'
118 accessories.style.display = 'none'
119 }, 200)
120 })
121 }
122
123 ta.addEventListener('keydown', function (ev) {
124 if(ev.keyCode === 13 && ev.ctrlKey) publish()
125 })
126
127 var files = []
128 var filesById = {}
129
130 function ensureLightbox () {
131 if (lb) return
132 lb = lightbox()
133 document.body.appendChild(lb)
134 lb.addEventListener('click', function (ev) {
135 if (ev.target === lb) closeModal()
136 })
137 }
138
139 function showModal (fromEl) {
140 if (!modal) return
141 ensureLightbox()
142 if (trigger) trigger.style.display = 'none'
143 if (!modalContent) {
144 modalContent = h('div.compose-modal',
145 h('div.compose-modal__header',
146 h('div.compose-modal__title', h('div.avatar',
147 api.avatar(selfId, 'thumbnail')
148 )),
149 h('button.btn.compose-modal__close', 'Close', {onclick: closeModal})
150 ),
151 createReplyHintEl('compose-modal__hint'),
152 composer
153 )
154 if (lastReplyMsg) updateReplyHint(lastReplyMsg)
155 }
156
157 lb.show(modalContent)
158 document.body.classList.add('lightbox-open')
159 window.requestAnimationFrame(function () {
160 modalContent.classList.add('compose-modal--animate')
161 ta.focus()
162 })
163
164 onKeydown = function (ev) {
165 if (ev.keyCode === 27) closeModal()
166 }
167 document.addEventListener('keydown', onKeydown)
168 }
169
170 function closeModal () {
171 if (!lb || !modalContent) return
172 modalContent.classList.remove('compose-modal--animate')
173 if (modalTimer) clearTimeout(modalTimer)
174 modalTimer = setTimeout(function () {
175 if (lb) lb.close()
176 document.body.classList.remove('lightbox-open')
177 if (trigger) trigger.style.display = ''
178 }, 160)
179 if (onKeydown) {
180 document.removeEventListener('keydown', onKeydown)
181 onKeydown = null
182 }
183 }
184
185 function publish() {
186 publishBtn.disabled = true
187 var content
188 try {
189 content = JSON.parse(ta.value)
190 } catch (err) {
191 meta.text = ta.value
192 meta.mentions = mentions(ta.value).map(function (mention) {
193 // merge markdown-detected mention with file info
194 var file = filesById[mention.link]
195 if (file) {
196 if (file.type) mention.type = file.type
197 if (file.size) mention.size = file.size
198 }
199 return mention
200 })
201 try {
202 meta = opts.prepublish(meta)
203 } catch (err) {
204 publishBtn.disabled = false
205 if (cb) cb(err)
206 else alert(err.message)
207 }
208 if (modal) closeModal()
209 return api.message_confirm(meta, done)
210 }
211
212 if (modal) closeModal()
213 api.message_confirm(content, done)
214
215 function done (err, msg) {
216 publishBtn.disabled = false
217 if(err) return alert(err.stack)
218 else if (msg) {
219 ta.value = ''
220 clearReply()
221 }
222 else if (modal) showModal(trigger)
223
224 if (cb) cb(err, msg)
225 }
226 }
227
228
229 var publishBtn = h('button.btn.btn-primary', 'Preview', {onclick: publish})
230 var composerChildren = [ta,
231 accessories = h('div.row.compose__controls',
232 //hidden until you focus the textarea
233 {style: {display: opts.shrink === false ? '' : 'none'}},
234 api.file_input(function (file) {
235 files.push(file)
236 filesById[file.link] = file
237
238 var embed = file.type.indexOf('image/') === 0 ? '!' : ''
239 ta.value += embed + '['+file.name+']('+file.link+')'
240 console.log('added:', file)
241 }),
242 publishBtn)
243 ]
244 if (inlineReplyHint) composerChildren.unshift(inlineReplyHint)
245 var composer =
246 h('div.message.message-card.compose', h('div.column', composerChildren))
247
248 suggest(ta, function (name, cb) {
249 cont.para(api.suggest_mentions(name))
250 (function (err, ary) {
251 cb(null, ary.reduce(function (a, b) {
252 if(!b) return a
253 return a.concat(b)
254 }, []))
255 })
256 }, {})
257
258 function applyReply (msg) {
259 if (!msg || !msg.key || !msg.value || !msg.value.content) return
260 if (!replyActive) captureBaseMeta()
261 var nextMeta = cloneMeta(baseSnapshot || meta)
262 nextMeta.type = meta.type || 'post'
263 var content = msg.value.content
264 nextMeta.root = content.root || msg.key
265 nextMeta.branch = msg.key
266 if (content.channel) nextMeta.channel = content.channel
267 else delete nextMeta.channel
268
269 if (msg.value.private) {
270 var selfId = require('../keys').id
271 var recps = content.recps
272 if (recps) nextMeta.recps = recps
273 else nextMeta.recps = [msg.value.author, selfId]
274 } else delete nextMeta.recps
275
276 applyMeta(meta, nextMeta)
277 replyActive = true
278 lastReplyMsg = msg
279 updateReplyHint(msg)
280 }
281
282 function updateReplyHint (msg) {
283 if (!replyHintEls.length) return
284 if (!msg || !msg.value || !msg.value.content) {
285 replyHintEls.forEach(function (el) {
286 el.textContent = ''
287 el.style.display = 'none'
288 })
289 return
290 }
291 var root = msg.value.content.root || msg.key
292 var re = h('span', 're: ', api.message_link(root))
293 replyHintEls.forEach(function (el) {
294 while (el.firstChild) el.removeChild(el.firstChild)
295 el.appendChild(h('div.message_content', re))
296 el.style.display = ''
297 })
298 }
299
300 function handleReplyEvent (ev) {
301 if (!trigger || !document.body.contains(trigger)) return
302 var detail = ev && ev.detail
303 var replyMsg = detail && detail.msg
304 applyReply(replyMsg)
305 showModal(trigger)
306 }
307
308 if (modal && opts.listenReplyEvents) {
309 window.addEventListener('decent:reply', handleReplyEvent)
310 }
311
312 if (modal) {
313 var label = opts.triggerLabel || 'Compose'
314 trigger = h('button.btn.btn-primary.compose-trigger__button', {
315 'aria-label': label,
316 title: label,
317 onclick: function () {
318 clearReply()
319 showModal(trigger)
320 }
321 }, h('span.compose-trigger__icon.material-symbols-outlined', {
322 'aria-hidden': 'true'
323 }, 'edit'))
324 if (opts.autoOpen) {
325 var attempts = 0
326 var tryOpen = function () {
327 if (document.body.contains(trigger) || attempts > 8) {
328 showModal(trigger)
329 return
330 }
331 attempts += 1
332 setTimeout(tryOpen, 50)
333 }
334 setTimeout(tryOpen, 0)
335 }
336 return h('div.compose-trigger', trigger)
337 }
338
339 return composer
340
341 }
342
343}