snapdrop

A Progressive Web App for local file sharing https://github.com/RobinLinus/snapdrop snapdrop.net
git clone http://git.hanabi.in/repos/snapdrop.git
Log | Files | Refs | README | LICENSE

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 }