ui.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530
  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. const isDownloadSupported = (typeof document.createElement('a').download !== 'undefined');
  5. const isProductionEnvironment = !window.location.host.startsWith('localhost');
  6. class PeersUI {
  7. constructor() {
  8. Events.on('peer-joined', e => this._onPeerJoined(e.detail));
  9. Events.on('peer-left', e => this._onPeerLeft(e.detail));
  10. Events.on('peers', e => this._onPeers(e.detail));
  11. Events.on('file-progress', e => this._onFileProgress(e.detail));
  12. }
  13. _onPeerJoined(peer) {
  14. if (document.getElementById(peer.id)) return;
  15. const peerUI = new PeerUI(peer);
  16. $$('x-peers').appendChild(peerUI.$el);
  17. }
  18. _onPeers(peers) {
  19. this._clearPeers();
  20. peers.forEach(peer => this._onPeerJoined(peer));
  21. }
  22. _onPeerLeft(peerId) {
  23. const peer = $(peerId);
  24. if (!peer) return;
  25. peer.remove();
  26. }
  27. _onFileProgress(progress) {
  28. const peerId = progress.sender || progress.recipient;
  29. const peer = $(peerId);
  30. if (!peer) return;
  31. peer.ui.setProgress(progress.progress);
  32. }
  33. _clearPeers() {
  34. const $peers = $$('x-peers').innerHTML = '';
  35. }
  36. }
  37. class PeerUI {
  38. html() {
  39. return `
  40. <label class="column center">
  41. <input type="file" multiple>
  42. <x-icon shadow="1">
  43. <svg class="icon"><use xlink:href="#"/></svg>
  44. </x-icon>
  45. <div class="progress">
  46. <div class="circle"></div>
  47. <div class="circle right"></div>
  48. </div>
  49. <div class="name font-subheading"></div>
  50. <div class="status font-body2"></div>
  51. </label>`
  52. }
  53. constructor(peer) {
  54. this._peer = peer;
  55. this._initDom();
  56. this._bindListeners(this.$el);
  57. }
  58. _initDom() {
  59. const el = document.createElement('x-peer');
  60. el.id = this._peer.id;
  61. el.innerHTML = this.html();
  62. el.ui = this;
  63. el.querySelector('svg use').setAttribute('xlink:href', this._icon());
  64. el.querySelector('.name').textContent = this._name();
  65. this.$el = el;
  66. this.$progress = el.querySelector('.progress');
  67. }
  68. _bindListeners(el) {
  69. el.querySelector('input').addEventListener('change', e => this._onFilesSelected(e));
  70. el.addEventListener('drop', e => this._onDrop(e));
  71. el.addEventListener('dragend', e => this._onDragEnd(e));
  72. el.addEventListener('dragleave', e => this._onDragEnd(e));
  73. el.addEventListener('dragover', e => this._onDragOver(e));
  74. el.addEventListener('contextmenu', e => this._onRightClick(e));
  75. el.addEventListener('touchstart', e => this._onTouchStart(e));
  76. el.addEventListener('touchend', e => this._onTouchEnd(e));
  77. // prevent browser's default file drop behavior
  78. Events.on('dragover', e => e.preventDefault());
  79. Events.on('drop', e => e.preventDefault());
  80. }
  81. _name() {
  82. if (this._peer.name.model) {
  83. return this._peer.name.os + ' ' + this._peer.name.model;
  84. }
  85. this._peer.name.os = this._peer.name.os.replace('Mac OS', 'Mac');
  86. return this._peer.name.os + ' ' + this._peer.name.browser;
  87. }
  88. _icon() {
  89. const device = this._peer.name.device || this._peer.name;
  90. if (device.type === 'mobile') {
  91. return '#phone-iphone';
  92. }
  93. if (device.type === 'tablet') {
  94. return '#tablet-mac';
  95. }
  96. return '#desktop-mac';
  97. }
  98. _onFilesSelected(e) {
  99. const $input = e.target;
  100. const files = $input.files;
  101. Events.fire('files-selected', {
  102. files: files,
  103. to: this._peer.id
  104. });
  105. $input.value = null; // reset input
  106. this.setProgress(0.01);
  107. }
  108. setProgress(progress) {
  109. if (progress > 0) {
  110. this.$el.setAttribute('transfer', '1');
  111. }
  112. if (progress > 0.5) {
  113. this.$progress.classList.add('over50');
  114. } else {
  115. this.$progress.classList.remove('over50');
  116. }
  117. const degrees = `rotate(${360 * progress}deg)`;
  118. this.$progress.style.setProperty('--progress', degrees);
  119. if (progress >= 1) {
  120. this.setProgress(0);
  121. this.$el.removeAttribute('transfer');
  122. }
  123. }
  124. _onDrop(e) {
  125. e.preventDefault();
  126. const files = e.dataTransfer.files;
  127. Events.fire('files-selected', {
  128. files: files,
  129. to: this._peer.id
  130. });
  131. this._onDragEnd();
  132. }
  133. _onDragOver() {
  134. this.$el.setAttribute('drop', 1);
  135. }
  136. _onDragEnd() {
  137. this.$el.removeAttribute('drop');
  138. }
  139. _onRightClick(e) {
  140. e.preventDefault();
  141. Events.fire('text-recipient', this._peer.id);
  142. }
  143. _onTouchStart(e) {
  144. this._touchStart = Date.now();
  145. this._touchTimer = setTimeout(_ => this._onTouchEnd(), 610);
  146. }
  147. _onTouchEnd(e) {
  148. if (Date.now() - this._touchStart < 500) {
  149. clearTimeout(this._touchTimer);
  150. } else { // this was a long tap
  151. if (e) e.preventDefault();
  152. Events.fire('text-recipient', this._peer.id);
  153. }
  154. }
  155. }
  156. class Dialog {
  157. constructor(id) {
  158. this.$el = $(id);
  159. this.$el.querySelectorAll('[close]').forEach(el => el.addEventListener('click', e => this.hide()))
  160. this.$autoFocus = this.$el.querySelector('[autofocus]');
  161. }
  162. show() {
  163. this.$el.setAttribute('show', 1);
  164. if (this.$autoFocus) this.$autoFocus.focus();
  165. }
  166. hide() {
  167. this.$el.removeAttribute('show');
  168. document.activeElement.blur();
  169. window.blur();
  170. }
  171. }
  172. class ReceiveDialog extends Dialog {
  173. constructor() {
  174. super('receiveDialog');
  175. Events.on('file-received', e => {
  176. this._nextFile(e.detail);
  177. window.blop.play();
  178. });
  179. this._filesQueue = [];
  180. }
  181. _nextFile(nextFile) {
  182. if (nextFile) this._filesQueue.push(nextFile);
  183. if (this._busy) return;
  184. this._busy = true;
  185. const file = this._filesQueue.shift();
  186. this._displayFile(file);
  187. }
  188. _dequeueFile() {
  189. if (!this._filesQueue.length) { // nothing to do
  190. this._busy = false;
  191. return;
  192. }
  193. // dequeue next file
  194. setTimeout(_ => {
  195. this._busy = false;
  196. this._nextFile();
  197. }, 300);
  198. }
  199. _displayFile(file) {
  200. const $a = this.$el.querySelector('#download');
  201. $a.href = file.url;
  202. $a.download = file.name;
  203. this.$el.querySelector('#fileName').textContent = file.name;
  204. this.$el.querySelector('#fileSize').textContent = this._formatFileSize(file.size);
  205. this.show();
  206. if (!isDownloadSupported) return;
  207. // $a.target = "_blank"; // fallback
  208. $a.target = "_system"; // fallback
  209. $a.href = 'external:' + $a.href;
  210. }
  211. _formatFileSize(bytes) {
  212. if (bytes >= 1e9) {
  213. return (Math.round(bytes / 1e8) / 10) + ' GB';
  214. } else if (bytes >= 1e6) {
  215. return (Math.round(bytes / 1e5) / 10) + ' MB';
  216. } else if (bytes > 1000) {
  217. return Math.round(bytes / 1000) + ' KB';
  218. } else {
  219. return bytes + ' Bytes';
  220. }
  221. }
  222. hide() {
  223. super.hide();
  224. this._dequeueFile();
  225. }
  226. }
  227. class SendTextDialog extends Dialog {
  228. constructor() {
  229. super('sendTextDialog');
  230. Events.on('text-recipient', e => this._onRecipient(e.detail))
  231. this.$text = this.$el.querySelector('#textInput');
  232. const button = this.$el.querySelector('form');
  233. button.addEventListener('submit', e => this._send(e));
  234. }
  235. _onRecipient(recipient) {
  236. this._recipient = recipient;
  237. this.show();
  238. this.$text.setSelectionRange(0, this.$text.value.length)
  239. }
  240. _send(e) {
  241. e.preventDefault();
  242. Events.fire('send-text', {
  243. to: this._recipient,
  244. text: this.$text.value
  245. });
  246. }
  247. }
  248. class ReceiveTextDialog extends Dialog {
  249. constructor() {
  250. super('receiveTextDialog');
  251. Events.on('text-received', e => this._onText(e.detail))
  252. this.$text = this.$el.querySelector('#text');
  253. const $copy = this.$el.querySelector('#copy');
  254. copy.addEventListener('click', _ => this._onCopy());
  255. }
  256. _onText(e) {
  257. this.$text.innerHTML = '';
  258. const text = e.text;
  259. if (isURL(text)) {
  260. const $a = document.createElement('a');
  261. $a.href = text;
  262. $a.target = '_blank';
  263. $a.textContent = text;
  264. this.$text.appendChild($a);
  265. } else {
  266. this.$text.textContent = text;
  267. }
  268. this.show();
  269. window.blop.play();
  270. }
  271. _onCopy() {
  272. if (!document.copy(this.$text.textContent)) return;
  273. Events.fire('notify-user', 'Copied to clipboard');
  274. }
  275. }
  276. class Toast extends Dialog {
  277. constructor() {
  278. super('toast');
  279. Events.on('notify-user', e => this._onNotfiy(e.detail));
  280. }
  281. _onNotfiy(message) {
  282. this.$el.textContent = message;
  283. this.show();
  284. setTimeout(_ => this.hide(), 3000);
  285. }
  286. }
  287. class Notifications {
  288. constructor() {
  289. // Check if the browser supports notifications
  290. if (!('Notification' in window)) return;
  291. // Check whether notification permissions have already been granted
  292. if (Notification.permission !== 'granted') {
  293. this.$button = $('notification');
  294. this.$button.removeAttribute('hidden');
  295. this.$button.addEventListener('click', e => this._requestPermission());
  296. }
  297. Events.on('text-received', e => this._messageNotification(e.detail.text));
  298. Events.on('file-received', e => this._downloadNotification(e.detail.name));
  299. }
  300. _requestPermission() {
  301. Notification.requestPermission(permission => {
  302. if (permission !== 'granted') {
  303. Events.fire('notify-user', Notifications.PERMISSION_ERROR || 'Error');
  304. return;
  305. }
  306. this._notify('Even more snappy sharing!');
  307. this.$button.setAttribute('hidden', 1);
  308. });
  309. }
  310. _notify(message, body) {
  311. const config = {
  312. body: body,
  313. icon: '/images/logo_transparent_128x128.png',
  314. vibrate: [200, 100, 200, 100, 200, 100, 400],
  315. }
  316. if (serviceWorker && serviceWorker.showNotification) {
  317. // android doesn't support "new Notification" if service worker is installed
  318. return serviceWorker.showNotification(message, config);
  319. } else {
  320. return new Notification(message, config);
  321. }
  322. }
  323. _messageNotification(message) {
  324. if (isURL(message)) {
  325. const notification = this._notify(message, 'Click to open link');
  326. notification.onclick = e => window.open(message, '_blank', null, true);
  327. } else {
  328. const notification = this._notify(message, 'Click to copy text');
  329. notification.onclick = e => document.copy(message);
  330. }
  331. }
  332. _downloadNotification(message) {
  333. const notification = this._notify(message, 'Click to download');
  334. if (window.isDownloadSupported) return;
  335. notification.onclick = e => {
  336. document.querySelector('x-dialog [download]').click();
  337. };
  338. }
  339. }
  340. class Snapdrop {
  341. constructor() {
  342. const server = new ServerConnection();
  343. const peers = new PeersManager(server);
  344. const peersUI = new PeersUI();
  345. Events.on('load', e => {
  346. const receiveDialog = new ReceiveDialog();
  347. const sendTextDialog = new SendTextDialog();
  348. const receiveTextDialog = new ReceiveTextDialog();
  349. const toast = new Toast();
  350. const notifications = new Notifications();
  351. })
  352. }
  353. }
  354. const snapdrop = new Snapdrop();
  355. document.copy = text => {
  356. // A <span> contains the text to copy
  357. const span = document.createElement('span');
  358. span.textContent = text;
  359. span.style.whiteSpace = 'pre'; // Preserve consecutive spaces and newlines
  360. // Paint the span outside the viewport
  361. span.style.position = 'absolute';
  362. span.style.left = '-9999px';
  363. span.style.top = '-9999px';
  364. const win = window;
  365. const selection = win.getSelection();
  366. win.document.body.appendChild(span);
  367. const range = win.document.createRange();
  368. selection.removeAllRanges();
  369. range.selectNode(span);
  370. selection.addRange(range);
  371. let success = false;
  372. try {
  373. success = win.document.execCommand('copy');
  374. } catch (err) {}
  375. selection.removeAllRanges();
  376. span.remove();
  377. return success;
  378. }
  379. if ('serviceWorker' in navigator) {
  380. navigator.serviceWorker
  381. .register('/service-worker.js')
  382. .then(serviceWorker => {
  383. console.log('Service Worker registered');
  384. window.serviceWorker = serviceWorker
  385. });
  386. }
  387. // Background Animation
  388. Events.on('load', () => {
  389. var requestAnimFrame = (function() {
  390. return window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || window.oRequestAnimationFrame || window.msRequestAnimationFrame ||
  391. function(callback) {
  392. window.setTimeout(callback, 1000 / 60);
  393. };
  394. })();
  395. var c = document.createElement('canvas');
  396. document.body.appendChild(c);
  397. var style = c.style;
  398. style.width = '100%';
  399. style.position = 'absolute';
  400. style.zIndex = -1;
  401. var ctx = c.getContext('2d');
  402. var x0, y0, w, h, dw;
  403. function init() {
  404. w = window.innerWidth;
  405. h = window.innerHeight;
  406. c.width = w;
  407. c.height = h;
  408. var offset = h > 380 ? 100 : 65;
  409. x0 = w / 2;
  410. y0 = h - offset;
  411. dw = Math.max(w, h, 1000) / 13;
  412. drawCircles();
  413. }
  414. window.onresize = init;
  415. function drawCicrle(radius) {
  416. ctx.beginPath();
  417. var color = Math.round(255 * (1 - radius / Math.max(w, h)));
  418. ctx.strokeStyle = 'rgba(' + color + ',' + color + ',' + color + ',0.1)';
  419. ctx.arc(x0, y0, radius, 0, 2 * Math.PI);
  420. ctx.stroke();
  421. ctx.lineWidth = 2;
  422. }
  423. var step = 0;
  424. function drawCircles() {
  425. ctx.clearRect(0, 0, w, h);
  426. for (var i = 0; i < 8; i++) {
  427. drawCicrle(dw * i + step % dw);
  428. }
  429. step += 1;
  430. }
  431. var loading = true;
  432. function animate() {
  433. if (loading || step % dw < dw - 5) {
  434. requestAnimFrame(function() {
  435. drawCircles();
  436. animate();
  437. });
  438. }
  439. }
  440. window.animateBackground = function(l) {
  441. loading = l;
  442. animate();
  443. };
  444. init();
  445. animate();
  446. setTimeout(e => window.animateBackground(false), 3000);
  447. });
  448. Notifications.PERMISSION_ERROR = `
  449. Notifications permission has been blocked
  450. as the user has dismissed the permission prompt several times.
  451. This can be reset in Page Info
  452. which can be accessed by clicking the lock icon next to the URL.`;
  453. document.body.onclick = e => { // safari hack to fix audio
  454. document.body.onclick = null;
  455. if (!(/.*Version.*Safari.*/.test(navigator.userAgent))) return;
  456. blop.play();
  457. }