secure-scuttlebot classic
at main 343 lines 10 kB view raw
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}