ui.js (19294B)
1 const $ = query => document.getElementById(query); 2 const $$ = query => document.body.querySelector(query); 3 const isURL = text => /^((https?:\/\/|www)[^\s]+)/g.test(text.toLowerCase()); 4 window.isDownloadSupported = (typeof document.createElement('a').download !== 'undefined'); 5 window.isProductionEnvironment = !window.location.host.startsWith('localhost'); 6 window.iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; 7 8 // set display name 9 Events.on('display-name', e => { 10 const me = e.detail.message; 11 const $displayName = $('displayName') 12 $displayName.textContent = 'You are known as ' + me.displayName; 13 $displayName.title = me.deviceName; 14 }); 15 16 class PeersUI { 17 18 constructor() { 19 Events.on('peer-joined', e => this._onPeerJoined(e.detail)); 20 Events.on('peer-left', e => this._onPeerLeft(e.detail)); 21 Events.on('peers', e => this._onPeers(e.detail)); 22 Events.on('file-progress', e => this._onFileProgress(e.detail)); 23 Events.on('paste', e => this._onPaste(e)); 24 } 25 26 _onPeerJoined(peer) { 27 if ($(peer.id)) return; // peer already exists 28 const peerUI = new PeerUI(peer); 29 $$('x-peers').appendChild(peerUI.$el); 30 setTimeout(e => window.animateBackground(false), 1750); // Stop animation 31 } 32 33 _onPeers(peers) { 34 this._clearPeers(); 35 peers.forEach(peer => this._onPeerJoined(peer)); 36 } 37 38 _onPeerLeft(peerId) { 39 const $peer = $(peerId); 40 if (!$peer) return; 41 $peer.remove(); 42 } 43 44 _onFileProgress(progress) { 45 const peerId = progress.sender || progress.recipient; 46 const $peer = $(peerId); 47 if (!$peer) return; 48 $peer.ui.setProgress(progress.progress); 49 } 50 51 _clearPeers() { 52 const $peers = $$('x-peers').innerHTML = ''; 53 } 54 55 _onPaste(e) { 56 const files = e.clipboardData.files || e.clipboardData.items 57 .filter(i => i.type.indexOf('image') > -1) 58 .map(i => i.getAsFile()); 59 const peers = document.querySelectorAll('x-peer'); 60 // send the pasted image content to the only peer if there is one 61 // otherwise, select the peer somehow by notifying the client that 62 // "image data has been pasted, click the client to which to send it" 63 // not implemented 64 if (files.length > 0 && peers.length === 1) { 65 Events.fire('files-selected', { 66 files: files, 67 to: $$('x-peer').id 68 }); 69 } 70 } 71 } 72 73 class PeerUI { 74 75 html() { 76 return ` 77 <label class="column center" title="Click to send files or right click to send a text"> 78 <input type="file" multiple> 79 <x-icon shadow="1"> 80 <svg class="icon"><use xlink:href="#"/></svg> 81 </x-icon> 82 <div class="progress"> 83 <div class="circle"></div> 84 <div class="circle right"></div> 85 </div> 86 <div class="name font-subheading"></div> 87 <div class="device-name font-body2"></div> 88 <div class="status font-body2"></div> 89 </label>` 90 } 91 92 constructor(peer) { 93 this._peer = peer; 94 this._initDom(); 95 this._bindListeners(this.$el); 96 } 97 98 _initDom() { 99 const el = document.createElement('x-peer'); 100 el.id = this._peer.id; 101 el.innerHTML = this.html(); 102 el.ui = this; 103 el.querySelector('svg use').setAttribute('xlink:href', this._icon()); 104 el.querySelector('.name').textContent = this._displayName(); 105 el.querySelector('.device-name').textContent = this._deviceName(); 106 this.$el = el; 107 this.$progress = el.querySelector('.progress'); 108 } 109 110 _bindListeners(el) { 111 el.querySelector('input').addEventListener('change', e => this._onFilesSelected(e)); 112 el.addEventListener('drop', e => this._onDrop(e)); 113 el.addEventListener('dragend', e => this._onDragEnd(e)); 114 el.addEventListener('dragleave', e => this._onDragEnd(e)); 115 el.addEventListener('dragover', e => this._onDragOver(e)); 116 el.addEventListener('contextmenu', e => this._onRightClick(e)); 117 el.addEventListener('touchstart', e => this._onTouchStart(e)); 118 el.addEventListener('touchend', e => this._onTouchEnd(e)); 119 // prevent browser's default file drop behavior 120 Events.on('dragover', e => e.preventDefault()); 121 Events.on('drop', e => e.preventDefault()); 122 } 123 124 _displayName() { 125 return this._peer.name.displayName; 126 } 127 128 _deviceName() { 129 return this._peer.name.deviceName; 130 } 131 132 _icon() { 133 const device = this._peer.name.device || this._peer.name; 134 if (device.type === 'mobile') { 135 return '#phone-iphone'; 136 } 137 if (device.type === 'tablet') { 138 return '#tablet-mac'; 139 } 140 return '#desktop-mac'; 141 } 142 143 _onFilesSelected(e) { 144 const $input = e.target; 145 const files = $input.files; 146 Events.fire('files-selected', { 147 files: files, 148 to: this._peer.id 149 }); 150 $input.value = null; // reset input 151 } 152 153 setProgress(progress) { 154 if (progress > 0) { 155 this.$el.setAttribute('transfer', '1'); 156 } 157 if (progress > 0.5) { 158 this.$progress.classList.add('over50'); 159 } else { 160 this.$progress.classList.remove('over50'); 161 } 162 const degrees = `rotate(${360 * progress}deg)`; 163 this.$progress.style.setProperty('--progress', degrees); 164 if (progress >= 1) { 165 this.setProgress(0); 166 this.$el.removeAttribute('transfer'); 167 } 168 } 169 170 _onDrop(e) { 171 e.preventDefault(); 172 const files = e.dataTransfer.files; 173 Events.fire('files-selected', { 174 files: files, 175 to: this._peer.id 176 }); 177 this._onDragEnd(); 178 } 179 180 _onDragOver() { 181 this.$el.setAttribute('drop', 1); 182 } 183 184 _onDragEnd() { 185 this.$el.removeAttribute('drop'); 186 } 187 188 _onRightClick(e) { 189 e.preventDefault(); 190 Events.fire('text-recipient', this._peer.id); 191 } 192 193 _onTouchStart(e) { 194 this._touchStart = Date.now(); 195 this._touchTimer = setTimeout(_ => this._onTouchEnd(), 610); 196 } 197 198 _onTouchEnd(e) { 199 if (Date.now() - this._touchStart < 500) { 200 clearTimeout(this._touchTimer); 201 } else { // this was a long tap 202 if (e) e.preventDefault(); 203 Events.fire('text-recipient', this._peer.id); 204 } 205 } 206 } 207 208 209 class Dialog { 210 constructor(id) { 211 this.$el = $(id); 212 this.$el.querySelectorAll('[close]').forEach(el => el.addEventListener('click', e => this.hide())) 213 this.$autoFocus = this.$el.querySelector('[autofocus]'); 214 } 215 216 show() { 217 this.$el.setAttribute('show', 1); 218 if (this.$autoFocus) this.$autoFocus.focus(); 219 } 220 221 hide() { 222 this.$el.removeAttribute('show'); 223 document.activeElement.blur(); 224 window.blur(); 225 } 226 } 227 228 class ReceiveDialog extends Dialog { 229 230 constructor() { 231 super('receiveDialog'); 232 Events.on('file-received', e => { 233 this._nextFile(e.detail); 234 window.blop.play(); 235 }); 236 this._filesQueue = []; 237 } 238 239 _nextFile(nextFile) { 240 if (nextFile) this._filesQueue.push(nextFile); 241 if (this._busy) return; 242 this._busy = true; 243 const file = this._filesQueue.shift(); 244 this._displayFile(file); 245 } 246 247 _dequeueFile() { 248 if (!this._filesQueue.length) { // nothing to do 249 this._busy = false; 250 return; 251 } 252 // dequeue next file 253 setTimeout(_ => { 254 this._busy = false; 255 this._nextFile(); 256 }, 300); 257 } 258 259 _displayFile(file) { 260 const $a = this.$el.querySelector('#download'); 261 const url = URL.createObjectURL(file.blob); 262 $a.href = url; 263 $a.download = file.name; 264 265 if(this._autoDownload()){ 266 $a.click() 267 return 268 } 269 if(file.mime.split('/')[0] === 'image'){ 270 console.log('the file is image'); 271 this.$el.querySelector('.preview').style.visibility = 'inherit'; 272 this.$el.querySelector("#img-preview").src = url; 273 } 274 275 this.$el.querySelector('#fileName').textContent = file.name; 276 this.$el.querySelector('#fileSize').textContent = this._formatFileSize(file.size); 277 this.show(); 278 279 if (window.isDownloadSupported) return; 280 // fallback for iOS 281 $a.target = '_blank'; 282 const reader = new FileReader(); 283 reader.onload = e => $a.href = reader.result; 284 reader.readAsDataURL(file.blob); 285 } 286 287 _formatFileSize(bytes) { 288 if (bytes >= 1e9) { 289 return (Math.round(bytes / 1e8) / 10) + ' GB'; 290 } else if (bytes >= 1e6) { 291 return (Math.round(bytes / 1e5) / 10) + ' MB'; 292 } else if (bytes > 1000) { 293 return Math.round(bytes / 1000) + ' KB'; 294 } else { 295 return bytes + ' Bytes'; 296 } 297 } 298 299 hide() { 300 this.$el.querySelector('.preview').style.visibility = 'hidden'; 301 this.$el.querySelector("#img-preview").src = ""; 302 super.hide(); 303 this._dequeueFile(); 304 } 305 306 307 _autoDownload(){ 308 return !this.$el.querySelector('#autoDownload').checked 309 } 310 } 311 312 313 class SendTextDialog extends Dialog { 314 constructor() { 315 super('sendTextDialog'); 316 Events.on('text-recipient', e => this._onRecipient(e.detail)) 317 this.$text = this.$el.querySelector('#textInput'); 318 const button = this.$el.querySelector('form'); 319 button.addEventListener('submit', e => this._send(e)); 320 } 321 322 _onRecipient(recipient) { 323 this._recipient = recipient; 324 this._handleShareTargetText(); 325 this.show(); 326 327 const range = document.createRange(); 328 const sel = window.getSelection(); 329 330 range.selectNodeContents(this.$text); 331 sel.removeAllRanges(); 332 sel.addRange(range); 333 334 } 335 336 _handleShareTargetText() { 337 if (!window.shareTargetText) return; 338 this.$text.textContent = window.shareTargetText; 339 window.shareTargetText = ''; 340 } 341 342 _send(e) { 343 e.preventDefault(); 344 Events.fire('send-text', { 345 to: this._recipient, 346 text: this.$text.innerText 347 }); 348 } 349 } 350 351 class ReceiveTextDialog extends Dialog { 352 constructor() { 353 super('receiveTextDialog'); 354 Events.on('text-received', e => this._onText(e.detail)) 355 this.$text = this.$el.querySelector('#text'); 356 const $copy = this.$el.querySelector('#copy'); 357 copy.addEventListener('click', _ => this._onCopy()); 358 } 359 360 _onText(e) { 361 this.$text.innerHTML = ''; 362 const text = e.text; 363 if (isURL(text)) { 364 const $a = document.createElement('a'); 365 $a.href = text; 366 $a.target = '_blank'; 367 $a.textContent = text; 368 this.$text.appendChild($a); 369 } else { 370 this.$text.textContent = text; 371 } 372 this.show(); 373 window.blop.play(); 374 } 375 376 async _onCopy() { 377 await navigator.clipboard.writeText(this.$text.textContent); 378 Events.fire('notify-user', 'Copied to clipboard'); 379 } 380 } 381 382 class Toast extends Dialog { 383 constructor() { 384 super('toast'); 385 Events.on('notify-user', e => this._onNotfiy(e.detail)); 386 } 387 388 _onNotfiy(message) { 389 this.$el.textContent = message; 390 this.show(); 391 setTimeout(_ => this.hide(), 3000); 392 } 393 } 394 395 396 class Notifications { 397 398 constructor() { 399 // Check if the browser supports notifications 400 if (!('Notification' in window)) return; 401 402 // Check whether notification permissions have already been granted 403 if (Notification.permission !== 'granted') { 404 this.$button = $('notification'); 405 this.$button.removeAttribute('hidden'); 406 this.$button.addEventListener('click', e => this._requestPermission()); 407 } 408 Events.on('text-received', e => this._messageNotification(e.detail.text)); 409 Events.on('file-received', e => this._downloadNotification(e.detail.name)); 410 } 411 412 _requestPermission() { 413 Notification.requestPermission(permission => { 414 if (permission !== 'granted') { 415 Events.fire('notify-user', Notifications.PERMISSION_ERROR || 'Error'); 416 return; 417 } 418 this._notify('Even more snappy sharing!'); 419 this.$button.setAttribute('hidden', 1); 420 }); 421 } 422 423 _notify(message, body, closeTimeout = 20000) { 424 const config = { 425 body: body, 426 icon: '/images/logo_transparent_128x128.png', 427 } 428 let notification; 429 try { 430 notification = new Notification(message, config); 431 } catch (e) { 432 // Android doesn't support "new Notification" if service worker is installed 433 if (!serviceWorker || !serviceWorker.showNotification) return; 434 notification = serviceWorker.showNotification(message, config); 435 } 436 437 // Notification is persistent on Android. We have to close it manually 438 if (closeTimeout) { 439 setTimeout(_ => notification.close(), closeTimeout); 440 } 441 442 return notification; 443 } 444 445 _messageNotification(message) { 446 if (isURL(message)) { 447 const notification = this._notify(message, 'Click to open link'); 448 this._bind(notification, e => window.open(message, '_blank', null, true)); 449 } else { 450 const notification = this._notify(message, 'Click to copy text'); 451 this._bind(notification, e => this._copyText(message, notification)); 452 } 453 } 454 455 _downloadNotification(message) { 456 const notification = this._notify(message, 'Click to download'); 457 if (!window.isDownloadSupported) return; 458 this._bind(notification, e => this._download(notification)); 459 } 460 461 _download(notification) { 462 document.querySelector('x-dialog [download]').click(); 463 notification.close(); 464 } 465 466 _copyText(message, notification) { 467 notification.close(); 468 if (!navigator.clipboard.writeText(message)) return; 469 this._notify('Copied text to clipboard'); 470 } 471 472 _bind(notification, handler) { 473 if (notification.then) { 474 notification.then(e => serviceWorker.getNotifications().then(notifications => { 475 serviceWorker.addEventListener('notificationclick', handler); 476 })); 477 } else { 478 notification.onclick = handler; 479 } 480 } 481 } 482 483 484 class NetworkStatusUI { 485 486 constructor() { 487 window.addEventListener('offline', e => this._showOfflineMessage(), false); 488 window.addEventListener('online', e => this._showOnlineMessage(), false); 489 if (!navigator.onLine) this._showOfflineMessage(); 490 } 491 492 _showOfflineMessage() { 493 Events.fire('notify-user', 'You are offline'); 494 } 495 496 _showOnlineMessage() { 497 Events.fire('notify-user', 'You are back online'); 498 } 499 } 500 501 class WebShareTargetUI { 502 constructor() { 503 const parsedUrl = new URL(window.location); 504 const title = parsedUrl.searchParams.get('title'); 505 const text = parsedUrl.searchParams.get('text'); 506 const url = parsedUrl.searchParams.get('url'); 507 508 let shareTargetText = title ? title : ''; 509 shareTargetText += text ? shareTargetText ? ' ' + text : text : ''; 510 511 if(url) shareTargetText = url; // We share only the Link - no text. Because link-only text becomes clickable. 512 513 if (!shareTargetText) return; 514 window.shareTargetText = shareTargetText; 515 history.pushState({}, 'URL Rewrite', '/'); 516 console.log('Shared Target Text:', '"' + shareTargetText + '"'); 517 } 518 } 519 520 521 class Snapdrop { 522 constructor() { 523 const server = new ServerConnection(); 524 const peers = new PeersManager(server); 525 const peersUI = new PeersUI(); 526 Events.on('load', e => { 527 const receiveDialog = new ReceiveDialog(); 528 const sendTextDialog = new SendTextDialog(); 529 const receiveTextDialog = new ReceiveTextDialog(); 530 const toast = new Toast(); 531 const notifications = new Notifications(); 532 const networkStatusUI = new NetworkStatusUI(); 533 const webShareTargetUI = new WebShareTargetUI(); 534 }); 535 } 536 } 537 538 const snapdrop = new Snapdrop(); 539 540 541 542 if ('serviceWorker' in navigator) { 543 navigator.serviceWorker.register('/service-worker.js') 544 .then(serviceWorker => { 545 console.log('Service Worker registered'); 546 window.serviceWorker = serviceWorker 547 }); 548 } 549 550 window.addEventListener('beforeinstallprompt', e => { 551 if (window.matchMedia('(display-mode: standalone)').matches) { 552 // don't display install banner when installed 553 return e.preventDefault(); 554 } else { 555 const btn = document.querySelector('#install') 556 btn.hidden = false; 557 btn.onclick = _ => e.prompt(); 558 return e.preventDefault(); 559 } 560 }); 561 562 // Background Animation 563 Events.on('load', () => { 564 let c = document.createElement('canvas'); 565 document.body.appendChild(c); 566 let style = c.style; 567 style.width = '100%'; 568 style.position = 'absolute'; 569 style.zIndex = -1; 570 style.top = 0; 571 style.left = 0; 572 let ctx = c.getContext('2d'); 573 let x0, y0, w, h, dw; 574 575 function init() { 576 w = window.innerWidth; 577 h = window.innerHeight; 578 c.width = w; 579 c.height = h; 580 let offset = h > 380 ? 100 : 65; 581 offset = h > 800 ? 116 : offset; 582 x0 = w / 2; 583 y0 = h - offset; 584 dw = Math.max(w, h, 1000) / 13; 585 drawCircles(); 586 } 587 window.onresize = init; 588 589 function drawCircle(radius) { 590 ctx.beginPath(); 591 let color = Math.round(255 * (1 - radius / Math.max(w, h))); 592 ctx.strokeStyle = 'rgba(' + color + ',' + color + ',' + color + ',0.1)'; 593 ctx.arc(x0, y0, radius, 0, 2 * Math.PI); 594 ctx.stroke(); 595 ctx.lineWidth = 2; 596 } 597 598 let step = 0; 599 600 function drawCircles() { 601 ctx.clearRect(0, 0, w, h); 602 for (let i = 0; i < 8; i++) { 603 drawCircle(dw * i + step % dw); 604 } 605 step += 1; 606 } 607 608 let loading = true; 609 610 function animate() { 611 if (loading || step % dw < dw - 5) { 612 requestAnimationFrame(function() { 613 drawCircles(); 614 animate(); 615 }); 616 } 617 } 618 window.animateBackground = function(l) { 619 loading = l; 620 animate(); 621 }; 622 init(); 623 animate(); 624 }); 625 626 Notifications.PERMISSION_ERROR = ` 627 Notifications permission has been blocked 628 as the user has dismissed the permission prompt several times. 629 This can be reset in Page Info 630 which can be accessed by clicking the lock icon next to the URL.`; 631 632 document.body.onclick = e => { // safari hack to fix audio 633 document.body.onclick = null; 634 if (!(/.*Version.*Safari.*/.test(navigator.userAgent))) return; 635 blop.play(); 636 }