ui.js 19 KB

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