class PeersUI { constructor() { this.$xPeers = $$('x-peers'); this.$xNoPeers = $$('x-no-peers'); this.$xInstructions = $$('x-instructions'); this.$wsFallbackWarning = $('websocket-fallback'); this.$sharePanel = $$('.shr-panel'); this.$shareModeImageThumb = $$('.shr-panel .image-thumb'); this.$shareModeTextThumb = $$('.shr-panel .text-thumb'); this.$shareModeFileThumb = $$('.shr-panel .file-thumb'); this.$shareModeDescriptor = $$('.shr-panel .share-descriptor'); this.$shareModeDescriptorItem = $$('.shr-panel .descriptor-item'); this.$shareModeDescriptorOther = $$('.shr-panel .descriptor-other'); this.$shareModeCancelBtn = $$('.shr-panel .cancel-btn'); this.$shareModeEditBtn = $$('.shr-panel .edit-btn'); this.peerUIs = {}; this.shareMode = {}; this.shareMode.active = false; this.shareMode.descriptor = ""; this.shareMode.files = []; this.shareMode.text = ""; Events.on('peer-joined', e => this._onPeerJoined(e.detail.peer, e.detail.roomType, e.detail.roomId)); Events.on('peer-connected', e => this._onPeerConnected(e.detail.peerId, e.detail.connectionHash)); Events.on('peer-connecting', e => this._onPeerConnecting(e.detail)); Events.on('peer-disconnected', e => this._onPeerDisconnected(e.detail)); Events.on('peers', e => this._onPeers(e.detail)); Events.on('set-progress', e => this._onSetProgress(e.detail)); Events.on('drop', e => this._onDrop(e)); Events.on('keydown', e => this._onKeyDown(e)); Events.on('dragover', e => this._onDragOver(e)); Events.on('dragleave', _ => this._onDragEnd()); Events.on('dragend', _ => this._onDragEnd()); Events.on('resize', _ => this._evaluateOverflowingPeers()); Events.on('header-changed', _ => this._evaluateOverflowingPeers()); Events.on('paste', e => this._onPaste(e)); Events.on('activate-share-mode', e => this._activateShareMode(e.detail.files, e.detail.text)); Events.on('translation-loaded', _ => this._reloadShareMode()); Events.on('room-type-removed', e => this._onRoomTypeRemoved(e.detail.peerId, e.detail.roomType)); this.$shareModeCancelBtn.addEventListener('click', _ => this._deactivateShareMode()); Events.on('peer-display-name-changed', e => this._onPeerDisplayNameChanged(e.detail.peerId, e.detail.displayName)); Events.on('ws-config', e => this._evaluateRtcSupport(e.detail)) } _evaluateRtcSupport(wsConfig) { if (wsConfig.wsFallback) { this.$wsFallbackWarning.removeAttribute("hidden"); } else { this.$wsFallbackWarning.setAttribute("hidden", true); if (!window.isRtcSupported) { alert(Localization.getTranslation("instructions.webrtc-requirement")); } } } _changePeerDisplayName(peerId, displayName) { const peerUI = this.peerUIs[peerId]; if (!peerUI) return; peerUI._setDisplayName(displayName); } _onPeerDisplayNameChanged(peerId, displayName) { if (!peerId || !displayName) return; this._changePeerDisplayName(peerId, displayName); } async _onKeyDown(e) { if (!this.shareMode.active || Dialog.anyDialogShown()) return; if (e.key === "Escape") { await this._deactivateShareMode(); } // close About PairDrop page on Escape if (e.key === "Escape") { window.location.hash = '#'; } } _onPeerJoined(peer, roomType, roomId) { this._joinPeer(peer, roomType, roomId); } _joinPeer(peer, roomType, roomId) { const existingPeerUI = this.peerUIs[peer.id]; if (existingPeerUI) { // peerUI already exists. Abort but add roomType to GUI existingPeerUI._addRoomId(roomType, roomId); return; } const peerUI = new PeerUI(peer, roomType, roomId, { active: this.shareMode.active, descriptor: this.shareMode.descriptor, }); this.peerUIs[peer.id] = peerUI; } _onPeerConnected(peerId, connectionHash) { const peerUI = this.peerUIs[peerId]; if (!peerUI) return; peerUI._peerConnected(true, connectionHash); this._addPeerUIIfMissing(peerUI); } _addPeerUIIfMissing(peerUI) { if (this.$xPeers.contains(peerUI.$el)) return; this.$xPeers.appendChild(peerUI.$el); this._evaluateOverflowingPeers(); } _onPeerConnecting(peerId) { const peerUI = this.peerUIs[peerId]; if (!peerUI) return; peerUI._peerConnected(false); } _evaluateOverflowingPeers() { if (this.$xPeers.clientHeight < this.$xPeers.scrollHeight) { this.$xPeers.classList.add('overflowing'); } else { this.$xPeers.classList.remove('overflowing'); } } _onPeers(msg) { msg.peers.forEach(peer => this._joinPeer(peer, msg.roomType, msg.roomId)); } _onPeerDisconnected(peerId) { const peerUI = this.peerUIs[peerId]; if (!peerUI) return; peerUI._removeDom(); delete this.peerUIs[peerId]; this._evaluateOverflowingPeers(); } _onRoomTypeRemoved(peerId, roomType) { const peerUI = this.peerUIs[peerId]; if (!peerUI) return; peerUI._removeRoomId(roomType); } _onSetProgress(progress) { const peerUI = this.peerUIs[progress.peerId]; if (!peerUI) return; peerUI.setProgress(progress.progress, progress.status); } _onDrop(e) { e.preventDefault(); if (this.shareMode.active || Dialog.anyDialogShown()) return; if (!$$('x-peer') || !$$('x-peer').contains(e.target)) { if (e.dataTransfer.files.length > 0) { Events.fire('activate-share-mode', {files: e.dataTransfer.files}); } else { for (let i=0; i { Events.fire('activate-share-mode', {text: text}); }); } } } } this._onDragEnd(); } _onDragOver(e) { e.preventDefault(); if (this.shareMode.active || Dialog.anyDialogShown()) return; this.$xInstructions.setAttribute('drop-bg', true); this.$xNoPeers.setAttribute('drop-bg', true); } _onDragEnd() { this.$xInstructions.removeAttribute('drop-bg'); this.$xNoPeers.removeAttribute('drop-bg'); } _onPaste(e) { // prevent send on paste when dialog is open if (this.shareMode.active || Dialog.anyDialogShown()) return; e.preventDefault() const files = e.clipboardData.files; const text = e.clipboardData.getData("Text"); if (files.length > 0) { Events.fire('activate-share-mode', {files: files}); } else if (text.length > 0) { if (ShareTextDialog.isApproveShareTextSet()) { Events.fire('share-text-dialog', text); } else { Events.fire('activate-share-mode', {text: text}); } } } async _activateShareMode(files = [], text = "") { if (this.shareMode.active || (files.length === 0 && text.length === 0)) return; this._activateCallback = e => this._sendShareData(e); this._editShareTextCallback = _ => { this._deactivateShareMode(); Events.fire('share-text-dialog', text); }; Events.on('share-mode-pointerdown', this._activateCallback); const sharedText = Localization.getTranslation("instructions.activate-share-mode-shared-text"); const andOtherFilesPlural = Localization.getTranslation("instructions.activate-share-mode-and-other-files-plural", null, {count: files.length-1}); const andOtherFiles = Localization.getTranslation("instructions.activate-share-mode-and-other-file"); let descriptorComplete, descriptorItem, descriptorOther, descriptorInstructions; if (files.length > 2) { // files shared descriptorItem = files[0].name; descriptorOther = andOtherFilesPlural; descriptorComplete = `${descriptorItem} ${descriptorOther}`; } else if (files.length === 2) { descriptorItem = files[0].name; descriptorOther = andOtherFiles; descriptorComplete = `${descriptorItem} ${descriptorOther}`; } else if (files.length === 1) { descriptorItem = files[0].name; descriptorComplete = descriptorItem; } else { // text shared descriptorItem = text.replace(/\s/g," "); descriptorComplete = sharedText; } if (files.length > 0) { if (descriptorOther) { this.$shareModeDescriptorOther.innerText = descriptorOther; this.$shareModeDescriptorOther.removeAttribute('hidden'); } if (files.length > 1) { descriptorInstructions = Localization.getTranslation("instructions.activate-share-mode-shared-files-plural", null, {count: files.length}); } else { descriptorInstructions = Localization.getTranslation("instructions.activate-share-mode-shared-file"); } files = await mime.addMissingMimeTypesToFiles(files); if (files[0].type.split('/')[0] === 'image') { try { let imageUrl = await getThumbnailAsDataUrl(files[0], 80, null, 0.9); this.$shareModeImageThumb.style.backgroundImage = `url(${imageUrl})`; this.$shareModeImageThumb.removeAttribute('hidden'); } catch (e) { console.error(e); this.$shareModeFileThumb.removeAttribute('hidden'); } } else { this.$shareModeFileThumb.removeAttribute('hidden'); } } else { this.$shareModeTextThumb.removeAttribute('hidden'); this.$shareModeEditBtn.addEventListener('click', this._editShareTextCallback); this.$shareModeEditBtn.removeAttribute('hidden'); descriptorInstructions = Localization.getTranslation("instructions.activate-share-mode-shared-text"); } const desktop = Localization.getTranslation("instructions.x-instructions-share-mode_desktop", null, {descriptor: descriptorInstructions}); const mobile = Localization.getTranslation("instructions.x-instructions-share-mode_mobile", null, {descriptor: descriptorInstructions}); this.$xInstructions.setAttribute('desktop', desktop); this.$xInstructions.setAttribute('mobile', mobile); this.$sharePanel.removeAttribute('hidden'); this.$shareModeDescriptor.removeAttribute('hidden'); this.$shareModeDescriptorItem.innerText = descriptorItem; this.shareMode.active = true; this.shareMode.descriptor = descriptorComplete; this.shareMode.files = files; this.shareMode.text = text; console.log('Share mode activated.'); Events.fire('share-mode-changed', { active: true, descriptor: descriptorComplete }); } async _reloadShareMode() { // If shareMode is active only if (!this.shareMode.active) return; let files = this.shareMode.files; let text = this.shareMode.text; await this._deactivateShareMode(); await this._activateShareMode(files, text); } async _deactivateShareMode() { if (!this.shareMode.active) return; this.shareMode.active = false; this.shareMode.descriptor = ""; this.shareMode.files = []; this.shareMode.text = ""; Events.off('share-mode-pointerdown', this._activateCallback); const desktop = Localization.getTranslation("instructions.x-instructions_desktop"); const mobile = Localization.getTranslation("instructions.x-instructions_mobile"); this.$xInstructions.setAttribute('desktop', desktop); this.$xInstructions.setAttribute('mobile', mobile); this.$sharePanel.setAttribute('hidden', true); this.$shareModeImageThumb.setAttribute('hidden', true); this.$shareModeFileThumb.setAttribute('hidden', true); this.$shareModeTextThumb.setAttribute('hidden', true); this.$shareModeDescriptorItem.innerHTML = ""; this.$shareModeDescriptorItem.classList.remove('cursive'); this.$shareModeDescriptorOther.innerHTML = ""; this.$shareModeDescriptorOther.setAttribute('hidden', true); this.$shareModeEditBtn.removeEventListener('click', this._editShareTextCallback); this.$shareModeEditBtn.setAttribute('hidden', true); console.log('Share mode deactivated.') Events.fire('share-mode-changed', { active: false }); } _sendShareData(e) { // send the shared file/text content const peerId = e.detail.peerId; const files = this.shareMode.files; const text = this.shareMode.text; if (files.length > 0) { Events.fire('files-selected', { files: files, to: peerId }); } else if (text.length > 0) { Events.fire('send-text', { text: text, to: peerId }); } } } class PeerUI { static _badgeClassNames = ["badge-room-ip", "badge-room-secret", "badge-room-public-id"]; constructor(peer, roomType, roomId, shareMode = {active: false, descriptor: ""}) { this.$xInstructions = $$('x-instructions'); this._peer = peer; this._connectionHash = ""; this._connected = false; this._roomIds = {} this._roomIds[roomType] = roomId; this._shareMode = shareMode; this._createCallbacks(); this._initDom(); // ShareMode Events.on('share-mode-changed', e => this._onShareModeChanged(e.detail.active, e.detail.descriptor)); } _initDom() { this.$el = document.createElement('x-peer'); this.$el.id = this._peer.id; this.$el.ui = this; this.$el.classList.add('center'); this.html(); this.$label = this.$el.querySelector('label'); this.$input = this.$el.querySelector('input'); this.$displayName = this.$el.querySelector('.name'); this.updateTypesClassList(); this.setStatus("connect"); this._evaluateShareMode(); this._bindListeners(); } _removeDom() { this.$el.remove(); } html() { let title= Localization.getTranslation("peer-ui.click-to-send"); this.$el.innerHTML = ` `; this.$el.querySelector('svg use').setAttribute('xlink:href', this._icon()); this.$el.querySelector('.name').textContent = this._displayName(); this.$el.querySelector('.device-name').textContent = this._deviceName(); } updateTypesClassList() { // Remove all classes this.$el.classList.remove('type-ip', 'type-secret', 'type-public-id', 'type-same-browser', 'ws-peer'); // Add classes accordingly Object.keys(this._roomIds).forEach(roomType => this.$el.classList.add(`type-${roomType}`)); if (BrowserTabsConnector.peerIsSameBrowser(this._peer.id)) { this.$el.classList.add(`type-same-browser`); } if (!this._peer.rtcSupported || !window.isRtcSupported) { this.$el.classList.add('ws-peer'); } } _addRoomId(roomType, roomId) { this._roomIds[roomType] = roomId; this.updateTypesClassList(); } _removeRoomId(roomType) { delete this._roomIds[roomType]; this.updateTypesClassList(); } _onShareModeChanged(active = false, descriptor = "") { this._shareMode.active = active; this._shareMode.descriptor = descriptor; this._evaluateShareMode(); this._bindListeners(); } _evaluateShareMode() { let title; if (!this._shareMode.active) { title = Localization.getTranslation("peer-ui.click-to-send"); this.$input.removeAttribute('disabled'); } else { title = Localization.getTranslation("peer-ui.click-to-send-share-mode", null, {descriptor: this._shareMode.descriptor}); this.$input.setAttribute('disabled', true); } this.$label.setAttribute('title', title); } _createCallbacks() { this._callbackInput = e => this._onFilesSelected(e); this._callbackClickSleep = _ => NoSleepUI.enable(); this._callbackTouchStartSleep = _ => NoSleepUI.enable(); this._callbackDrop = e => this._onDrop(e); this._callbackDragEnd = e => this._onDragEnd(e); this._callbackDragLeave = e => this._onDragEnd(e); this._callbackDragOver = e => this._onDragOver(e); this._callbackContextMenu = e => this._onRightClick(e); this._callbackTouchStart = e => this._onTouchStart(e); this._callbackTouchEnd = e => this._onTouchEnd(e); this._callbackPointerDown = e => this._onPointerDown(e); } _bindListeners() { if(!this._shareMode.active) { // Remove Events Share mode this.$el.removeEventListener('pointerdown', this._callbackPointerDown); // Add Events Normal Mode this.$el.querySelector('input').addEventListener('change', this._callbackInput); this.$el.addEventListener('click', this._callbackClickSleep); this.$el.addEventListener('touchstart', this._callbackTouchStartSleep); this.$el.addEventListener('drop', this._callbackDrop); this.$el.addEventListener('dragend', this._callbackDragEnd); this.$el.addEventListener('dragleave', this._callbackDragLeave); this.$el.addEventListener('dragover', this._callbackDragOver); this.$el.addEventListener('contextmenu', this._callbackContextMenu); this.$el.addEventListener('touchstart', this._callbackTouchStart); this.$el.addEventListener('touchend', this._callbackTouchEnd); } else { // Remove Events Normal Mode this.$el.removeEventListener('click', this._callbackClickSleep); this.$el.removeEventListener('touchstart', this._callbackTouchStartSleep); this.$el.removeEventListener('drop', this._callbackDrop); this.$el.removeEventListener('dragend', this._callbackDragEnd); this.$el.removeEventListener('dragleave', this._callbackDragLeave); this.$el.removeEventListener('dragover', this._callbackDragOver); this.$el.removeEventListener('contextmenu', this._callbackContextMenu); this.$el.removeEventListener('touchstart', this._callbackTouchStart); this.$el.removeEventListener('touchend', this._callbackTouchEnd); // Add Events Share mode this.$el.addEventListener('pointerdown', this._callbackPointerDown); } } _onPointerDown(e) { // Prevents triggering of event twice on touch devices e.stopPropagation(); e.preventDefault(); Events.fire('share-mode-pointerdown', { peerId: this._peer.id }); } _peerConnected(connected = true, connectionHash = "") { if (connected) { this._connected = true; // on reconnect this.setStatus(this.oldStatus); this.oldStatus = null; this._connectionHash = connectionHash; } else { this._connected = false; if (!this.oldStatus && this.currentStatus !== "connect") { // save old status when reconnecting this.oldStatus = this.currentStatus; } this.setStatus("connect"); this._connectionHash = ""; } } getConnectionHashWithSpaces() { if (this._connectionHash.length !== 16) { return "" } return `${this._connectionHash.substring(0, 4)} ${this._connectionHash.substring(4, 8)} ${this._connectionHash.substring(8, 12)} ${this._connectionHash.substring(12, 16)}`; } _displayName() { return this._peer.name.displayName; } _deviceName() { return this._peer.name.deviceName; } _setDisplayName(displayName) { this._peer.name.displayName = displayName; this.$displayName.textContent = displayName; } _roomTypes() { return Object.keys(this._roomIds); } _badgeClassName() { const roomTypes = this._roomTypes(); if (roomTypes.includes('secret')) { return 'badge-room-secret'; } else if (roomTypes.includes('ip')) { return 'badge-room-ip'; } else { return 'badge-room-public-id'; } } _icon() { const device = this._peer.name.device || this._peer.name; if (device.type === 'mobile') { return '#phone-iphone'; } if (device.type === 'tablet') { return '#tablet-mac'; } return '#desktop-mac'; } _onFilesSelected(e) { const $input = e.target; const files = $input.files; Events.fire('files-selected', { files: files, to: this._peer.id }); $input.files = null; // reset input } setProgress(progress, status) { const $progress = this.$el.querySelector('.progress'); if (0.5 < progress && progress < 1) { $progress.classList.add('over50'); } else { $progress.classList.remove('over50'); } if (progress === 1) { progress = 0; status = null; } this.setStatus(status); const degrees = `rotate(${360 * progress}deg)`; $progress.style.setProperty('--progress', degrees); } setStatus(status) { if (!status) { this.$el.removeAttribute('status'); this.$el.querySelector('.status').innerHTML = ''; this.currentStatus = null; NoSleepUI.disableIfPeersIdle(); return; } if (status === this.currentStatus) return; let statusName = { "connect": Localization.getTranslation("peer-ui.connecting"), "prepare": Localization.getTranslation("peer-ui.preparing"), "transfer": Localization.getTranslation("peer-ui.transferring"), "receive": Localization.getTranslation("peer-ui.receiving"), "process": Localization.getTranslation("peer-ui.processing"), "wait": Localization.getTranslation("peer-ui.waiting") }[status]; this.$el.setAttribute('status', status); this.$el.querySelector('.status').innerText = statusName; this.currentStatus = status; } _onDrop(e) { if (this._shareMode.active || Dialog.anyDialogShown()) return; e.preventDefault(); if (e.dataTransfer.files.length > 0) { Events.fire('files-selected', { files: e.dataTransfer.files, to: this._peer.id }); } else { for (let i=0; i { Events.fire('send-text', { text: text, to: this._peer.id }); }); } } } this._onDragEnd(); } _onDragOver() { this.$el.setAttribute('drop', true); this.$xInstructions.setAttribute('drop-peer', true); } _onDragEnd() { this.$el.removeAttribute('drop'); this.$xInstructions.removeAttribute('drop-peer'); } _onRightClick(e) { e.preventDefault(); Events.fire('text-recipient', { peerId: this._peer.id, deviceName: e.target.closest('x-peer').querySelector('.name').innerText }); } _onTouchStart(e) { this._touchStart = Date.now(); this._touchTimer = setTimeout(() => this._onTouchEnd(e), 610); } _onTouchEnd(e) { if (Date.now() - this._touchStart < 500) { clearTimeout(this._touchTimer); } else if (this._touchTimer) { // this was a long tap e.preventDefault(); Events.fire('text-recipient', { peerId: this._peer.id, deviceName: e.target.closest('x-peer').querySelector('.name').innerText }); } this._touchTimer = null; } } class Dialog { constructor(id) { this.$el = $(id); this.$autoFocus = this.$el.querySelector('[autofocus]'); this.$xBackground = this.$el.querySelector('x-background'); this.$closeBtns = this.$el.querySelectorAll('[close]'); this.$closeBtns.forEach(el => { el.addEventListener('click', _ => this.hide()) }); Events.on('peer-disconnected', e => this._onPeerDisconnected(e.detail)); } static anyDialogShown() { return document.querySelectorAll('x-dialog[show]').length > 0; } show() { if (this.$xBackground) { this.$xBackground.scrollTop = 0; } this.$el.setAttribute('show', true); if (!window.isMobile && this.$autoFocus) { this.$autoFocus.focus(); } } isShown() { return !!this.$el.attributes["show"]; } hide() { this.$el.removeAttribute('show'); if (!window.isMobile) { document.activeElement.blur(); window.blur(); } document.title = 'PairDrop | Transfer Files Cross-Platform. No Setup, No Signup.'; changeFavicon("images/favicon-96x96.png"); this.correspondingPeerId = undefined; } _onPeerDisconnected(peerId) { if (this.isShown() && this.correspondingPeerId === peerId) { this.hide(); Events.fire('notify-user', Localization.getTranslation("notifications.selected-peer-left")); } } _evaluateOverflowing(element) { if (element.clientHeight < element.scrollHeight) { element.classList.add('overflowing'); } else { element.classList.remove('overflowing'); } } } class LanguageSelectDialog extends Dialog { constructor() { super('language-select-dialog'); this.$languageSelectBtn = $('language-selector'); this.$languageSelectBtn.addEventListener('click', _ => this.show()); this.$languageButtons = this.$el.querySelectorAll(".language-buttons .btn"); this.$languageButtons.forEach($btn => { $btn.addEventListener("click", e => this.selectLanguage(e)); }) Events.on('keydown', e => this._onKeyDown(e)); } _onKeyDown(e) { if (!this.isShown()) return; if (e.code === "Escape") { this.hide(); } } show() { let locale = Localization.getLocale(); this.currentLanguageBtn = Localization.isSystemLocale() ? this.$languageButtons[0] : this.$el.querySelector(`.btn[value="${locale}"]`); this.currentLanguageBtn.classList.add("current"); super.show(); } hide() { this.currentLanguageBtn.classList.remove("current"); super.hide(); } selectLanguage(e) { e.preventDefault() let languageCode = e.target.value; if (languageCode) { localStorage.setItem('language_code', languageCode); } else { localStorage.removeItem('language_code'); } Localization.setTranslation(languageCode) .then(_ => this.hide()); } } class ReceiveDialog extends Dialog { constructor(id) { super(id); this.$fileDescription = this.$el.querySelector('.file-description'); this.$displayName = this.$el.querySelector('.display-name'); this.$fileStem = this.$el.querySelector('.file-stem'); this.$fileExtension = this.$el.querySelector('.file-extension'); this.$fileOther = this.$el.querySelector('.file-other'); this.$fileSize = this.$el.querySelector('.file-size'); this.$previewBox = this.$el.querySelector('.file-preview'); this.$receiveTitle = this.$el.querySelector('h2:first-of-type'); } _formatFileSize(bytes) { // 1 GB = 1024 MB = 1024^2 KB = 1024^3 B // 1024^2 = 104876; 1024^3 = 1073741824 if (bytes >= 1073741824) { return Math.round(10 * bytes / 1073741824) / 10 + ' GB'; } else if (bytes >= 1048576) { return Math.round(bytes / 1048576) + ' MB'; } else if (bytes > 1024) { return Math.round(bytes / 1024) + ' KB'; } else { return bytes + ' Bytes'; } } _parseFileData(displayName, connectionHash, files, imagesOnly, totalSize, badgeClassName) { let fileOther = ""; if (files.length === 2) { fileOther = imagesOnly ? Localization.getTranslation("dialogs.file-other-description-image") : Localization.getTranslation("dialogs.file-other-description-file"); } else if (files.length >= 2) { fileOther = imagesOnly ? Localization.getTranslation("dialogs.file-other-description-image-plural", null, {count: files.length - 1}) : Localization.getTranslation("dialogs.file-other-description-file-plural", null, {count: files.length - 1}); } this.$fileOther.innerText = fileOther; const fileName = files[0].name; const fileNameSplit = fileName.split('.'); const fileExtension = '.' + fileNameSplit[fileNameSplit.length - 1]; this.$fileStem.innerText = fileName.substring(0, fileName.length - fileExtension.length); this.$fileExtension.innerText = fileExtension; this.$fileSize.innerText = this._formatFileSize(totalSize); this.$displayName.innerText = displayName; this.$displayName.title = connectionHash; this.$displayName.classList.remove("badge-room-ip", "badge-room-secret", "badge-room-public-id"); this.$displayName.classList.add(badgeClassName) } } class ReceiveFileDialog extends ReceiveDialog { constructor() { super('receive-file-dialog'); this.$downloadBtn = this.$el.querySelector('#download-btn'); this.$shareBtn = this.$el.querySelector('#share-btn'); Events.on('files-received', e => this._onFilesReceived(e.detail.peerId, e.detail.files, e.detail.imagesOnly, e.detail.totalSize)); this._filesQueue = []; } async _onFilesReceived(peerId, files, imagesOnly, totalSize) { const displayName = $(peerId).ui._displayName(); const connectionHash = $(peerId).ui._connectionHash; const badgeClassName = $(peerId).ui._badgeClassName(); this._filesQueue.push({ peerId: peerId, displayName: displayName, connectionHash: connectionHash, files: files, imagesOnly: imagesOnly, totalSize: totalSize, badgeClassName: badgeClassName }); window.blop.play(); await this._nextFiles(); } async _nextFiles() { if (this._busy || !this._filesQueue.length) return; this._busy = true; const {peerId, displayName, connectionHash, files, imagesOnly, totalSize, badgeClassName} = this._filesQueue.shift(); await this._displayFiles(peerId, displayName, connectionHash, files, imagesOnly, totalSize, badgeClassName); } createPreviewElement(file) { return new Promise((resolve, reject) => { try { let mime = file.type.split('/')[0] let previewElement = { image: 'img', audio: 'audio', video: 'video' } if (Object.keys(previewElement).indexOf(mime) === -1) { resolve(false); } else { let element = document.createElement(previewElement[mime]); element.controls = true; element.onload = _ => { this.$previewBox.appendChild(element); resolve(true); }; element.onloadeddata = _ => { this.$previewBox.appendChild(element); resolve(true); }; element.onerror = _ => { reject(`${mime} preview could not be loaded from type ${file.type}`); }; element.src = URL.createObjectURL(file); } } catch (e) { reject(`preview could not be loaded from type ${file.type}`); } }); } async _displayFiles(peerId, displayName, connectionHash, files, imagesOnly, totalSize, badgeClassName) { this._parseFileData(displayName, connectionHash, files, imagesOnly, totalSize, badgeClassName); let descriptor, url, filenameDownload; if (files.length === 1) { descriptor = imagesOnly ? Localization.getTranslation("dialogs.title-image") : Localization.getTranslation("dialogs.title-file"); } else { descriptor = imagesOnly ? Localization.getTranslation("dialogs.title-image-plural") : Localization.getTranslation("dialogs.title-file-plural"); } this.$receiveTitle.innerText = Localization.getTranslation("dialogs.receive-title", null, {descriptor: descriptor}); const canShare = (window.iOS || window.android) && !!navigator.share && navigator.canShare({files}); if (canShare) { this.$shareBtn.removeAttribute('hidden'); this.$shareBtn.onclick = _ => { navigator.share({files: files}) .catch(err => { console.error(err); }); } } let downloadZipped = false; if (files.length > 1) { downloadZipped = true; try { let bytesCompleted = 0; zipper.createNewZipWriter(); for (let i=0; i { Events.fire('set-progress', { peerId: peerId, progress: (bytesCompleted + progress) / totalSize, status: 'process' }) } }); bytesCompleted += files[i].size; } url = await zipper.getBlobURL(); let now = new Date(Date.now()); let year = now.getFullYear().toString(); let month = (now.getMonth()+1).toString(); month = month.length < 2 ? "0" + month : month; let date = now.getDate().toString(); date = date.length < 2 ? "0" + date : date; let hours = now.getHours().toString(); hours = hours.length < 2 ? "0" + hours : hours; let minutes = now.getMinutes().toString(); minutes = minutes.length < 2 ? "0" + minutes : minutes; filenameDownload = `PairDrop_files_${year+month+date}_${hours+minutes}.zip`; } catch (e) { console.error(e); downloadZipped = false; } } this.$downloadBtn.removeAttribute('disabled'); this.$downloadBtn.innerText = Localization.getTranslation("dialogs.download"); this.$downloadBtn.onclick = _ => { if (downloadZipped) { let tmpZipBtn = document.createElement("a"); tmpZipBtn.download = filenameDownload; tmpZipBtn.href = url; tmpZipBtn.click(); } else { this._downloadFilesIndividually(files); } if (!canShare) { this.$downloadBtn.innerText = Localization.getTranslation("dialogs.download-again"); } Events.fire('notify-user', Localization.getTranslation("notifications.download-successful", null, {descriptor: descriptor})); // Prevent clicking the button multiple times this.$downloadBtn.style.pointerEvents = "none"; setTimeout(() => this.$downloadBtn.style.pointerEvents = "unset", 2000); }; document.title = files.length === 1 ? `${ Localization.getTranslation("document-titles.file-received") } - PairDrop` : `${ Localization.getTranslation("document-titles.file-received-plural", null, {count: files.length}) } - PairDrop`; changeFavicon("images/favicon-96x96-notification.png"); Events.fire('set-progress', {peerId: peerId, progress: 1, status: 'process'}) this.show(); setTimeout(() => { // wait for the dialog to be shown if (canShare) { this.$shareBtn.click(); } else { this.$downloadBtn.click(); } }, 500); this.createPreviewElement(files[0]) .then(canPreview => { if (canPreview) { console.log('the file is able to preview'); } else { console.log('the file is not able to preview'); } }) .catch(r => console.error(r)); } _downloadFilesIndividually(files) { let tmpBtn = document.createElement("a"); for (let i=0; i { this.$shareBtn.setAttribute('hidden', true); this.$downloadBtn.setAttribute('disabled', true); this.$previewBox.innerHTML = ''; this._busy = false; await this._nextFiles(); }, 300); } } class ReceiveRequestDialog extends ReceiveDialog { constructor() { super('receive-request-dialog'); this.$acceptRequestBtn = this.$el.querySelector('#accept-request'); this.$declineRequestBtn = this.$el.querySelector('#decline-request'); this.$acceptRequestBtn.addEventListener('click', _ => this._respondToFileTransferRequest(true)); this.$declineRequestBtn.addEventListener('click', _ => this._respondToFileTransferRequest(false)); Events.on('files-transfer-request', e => this._onRequestFileTransfer(e.detail.request, e.detail.peerId)) Events.on('keydown', e => this._onKeyDown(e)); this._filesTransferRequestQueue = []; } _onKeyDown(e) { if (!this.isShown()) return; if (e.code === "Escape") { this._respondToFileTransferRequest(false); } } _onRequestFileTransfer(request, peerId) { this._filesTransferRequestQueue.push({request: request, peerId: peerId}); if (this.isShown()) return; this._dequeueRequests(); } _dequeueRequests() { if (!this._filesTransferRequestQueue.length) return; let {request, peerId} = this._filesTransferRequestQueue.shift(); this._showRequestDialog(request, peerId) } _showRequestDialog(request, peerId) { this.correspondingPeerId = peerId; const displayName = $(peerId).ui._displayName(); const connectionHash = $(peerId).ui._connectionHash; const badgeClassName = $(peerId).ui._badgeClassName(); this._parseFileData(displayName, connectionHash, request.header, request.imagesOnly, request.totalSize, badgeClassName); if (request.thumbnailDataUrl && request.thumbnailDataUrl.substring(0, 22) === "data:image/jpeg;base64") { let element = document.createElement('img'); element.src = request.thumbnailDataUrl; this.$previewBox.appendChild(element) } const transferRequestTitle= request.imagesOnly ? Localization.getTranslation('document-titles.image-transfer-requested') : Localization.getTranslation('document-titles.file-transfer-requested'); this.$receiveTitle.innerText = transferRequestTitle; document.title = `${transferRequestTitle} - PairDrop`; changeFavicon("images/favicon-96x96-notification.png"); this.$acceptRequestBtn.removeAttribute('disabled'); this.show(); } _respondToFileTransferRequest(accepted) { Events.fire('respond-to-files-transfer-request', { to: this.correspondingPeerId, accepted: accepted }) if (accepted) { Events.fire('set-progress', {peerId: this.correspondingPeerId, progress: 0, status: 'wait'}); NoSleepUI.enable(); } this.hide(); } hide() { // clear previewBox after dialog is closed setTimeout(() => { this.$previewBox.innerHTML = ''; this.$acceptRequestBtn.setAttribute('disabled', true); }, 300); super.hide(); // show next request setTimeout(() => this._dequeueRequests(), 300); } } class InputKeyContainer { constructor(inputKeyContainer, evaluationRegex, onAllCharsFilled, onNoAllCharsFilled, onLastCharFilled) { this.$inputKeyContainer = inputKeyContainer; this.$inputKeyChars = inputKeyContainer.querySelectorAll('input'); this.$inputKeyChars.forEach(char => char.addEventListener('input', e => this._onCharsInput(e))); this.$inputKeyChars.forEach(char => char.addEventListener('keydown', e => this._onCharsKeyDown(e))); this.$inputKeyChars.forEach(char => char.addEventListener('keyup', e => this._onCharsKeyUp(e))); this.$inputKeyChars.forEach(char => char.addEventListener('focus', e => e.target.select())); this.$inputKeyChars.forEach(char => char.addEventListener('click', e => e.target.select())); this.evalRgx = evaluationRegex this._onAllCharsFilled = onAllCharsFilled; this._onNotAllCharsFilled = onNoAllCharsFilled; this._onLastCharFilled = onLastCharFilled; } _enableChars() { this.$inputKeyChars.forEach(char => char.removeAttribute('disabled')); } _disableChars() { this.$inputKeyChars.forEach(char => char.setAttribute('disabled', true)); } _clearChars() { this.$inputKeyChars.forEach(char => char.value = ''); } _cleanUp() { this._clearChars(); this._disableChars(); } _onCharsInput(e) { if (!e.target.value.match(this.evalRgx)) { e.target.value = ''; return; } this._evaluateKeyChars(); let nextSibling = e.target.nextElementSibling; if (nextSibling) { e.preventDefault(); nextSibling.focus(); } } _onCharsKeyDown(e) { let previousSibling = e.target.previousElementSibling; let nextSibling = e.target.nextElementSibling; if (e.key === "Backspace" && previousSibling && !e.target.value) { previousSibling.value = ''; previousSibling.focus(); } else if (e.key === "ArrowRight" && nextSibling) { e.preventDefault(); nextSibling.focus(); } else if (e.key === "ArrowLeft" && previousSibling) { e.preventDefault(); previousSibling.focus(); } } _onCharsKeyUp(e) { // deactivate submit btn when e.g. using backspace to clear element if (!e.target.value) { this._evaluateKeyChars(); } } _getInputKey() { let key = ""; this.$inputKeyChars.forEach(char => { key += char.value; }) return key; } _onPaste(pastedKey) { let rgx = new RegExp("(?!" + this.evalRgx.source + ").", "g"); pastedKey = pastedKey.replace(rgx,'').substring(0, this.$inputKeyChars.length) for (let i = 0; i < pastedKey.length; i++) { document.activeElement.value = pastedKey.charAt(i); let nextSibling = document.activeElement.nextElementSibling; if (!nextSibling) break; nextSibling.focus(); } this._evaluateKeyChars(); } _evaluateKeyChars() { if (this.$inputKeyContainer.querySelectorAll('input:placeholder-shown').length > 0) { this._onNotAllCharsFilled(); } else { this._onAllCharsFilled(); const lastCharFocused = document.activeElement === this.$inputKeyChars[this.$inputKeyChars.length - 1]; if (lastCharFocused) { this._onLastCharFilled(); } } } focusLastChar() { let lastChar = this.$inputKeyChars[this.$inputKeyChars.length-1]; lastChar.focus(); } } class PairDeviceDialog extends Dialog { constructor() { super('pair-device-dialog'); this.$pairDeviceHeaderBtn = $('pair-device'); this.$editPairedDevicesHeaderBtn = $('edit-paired-devices'); this.$footerInstructionsPairedDevices = $$('.discovery-wrapper .badge-room-secret'); this.$key = this.$el.querySelector('.key'); this.$qrCode = this.$el.querySelector('.key-qr-code'); this.$form = this.$el.querySelector('form'); this.$closeBtn = this.$el.querySelector('[close]') this.$pairSubmitBtn = this.$el.querySelector('button[type="submit"]'); this.inputKeyContainer = new InputKeyContainer( this.$el.querySelector('.input-key-container'), /\d/, () => this.$pairSubmitBtn.removeAttribute('disabled'), () => this.$pairSubmitBtn.setAttribute('disabled', true), () => this._submit() ); this.$pairDeviceHeaderBtn.addEventListener('click', _ => this._pairDeviceInitiate()); this.$form.addEventListener('submit', e => this._onSubmit(e)); this.$closeBtn.addEventListener('click', _ => this._close()); Events.on('keydown', e => this._onKeyDown(e)); Events.on('ws-disconnected', _ => this.hide()); Events.on('pair-device-initiated', e => this._onPairDeviceInitiated(e.detail)); Events.on('pair-device-joined', e => this._onPairDeviceJoined(e.detail.peerId, e.detail.roomSecret)); Events.on('peers', e => this._onPeers(e.detail.peers, e.detail.roomType, e.detail.roomId)); Events.on('peer-joined', e => this._onPeerJoined(e.detail.peer, e.detail.roomType, e.detail.roomId)); Events.on('pair-device-join-key-invalid', _ => this._onPublicRoomJoinKeyInvalid()); Events.on('pair-device-canceled', e => this._onPairDeviceCanceled(e.detail)); Events.on('evaluate-number-room-secrets', _ => this._evaluateNumberRoomSecrets()) Events.on('secret-room-deleted', e => this._onSecretRoomDeleted(e.detail)); this.$el.addEventListener('paste', e => this._onPaste(e)); this.$qrCode.addEventListener('click', _ => this._copyPairUrl()); this.pairPeer = {}; } _onKeyDown(e) { if (!this.isShown()) return; if (e.code === "Escape") { // Timeout to prevent share mode from getting cancelled simultaneously setTimeout(() => this._close(), 50); } } _onPaste(e) { e.preventDefault(); let pastedKey = e.clipboardData .getData("Text") .replace(/\D/g,'') .substring(0, 6); this.inputKeyContainer._onPaste(pastedKey); } _pairDeviceInitiate() { Events.fire('pair-device-initiate'); } _onPairDeviceInitiated(msg) { this.pairKey = msg.pairKey; this.roomSecret = msg.roomSecret; this._setKeyAndQRCode(); this.inputKeyContainer._enableChars(); this.show(); } _setKeyAndQRCode() { this.$key.innerText = `${this.pairKey.substring(0,3)} ${this.pairKey.substring(3,6)}` // Display the QR code for the url const qr = new QRCode({ content: this._getPairUrl(), width: 130, height: 130, padding: 1, background: 'white', color: 'rgb(18, 18, 18)', ecl: "L", join: true }); this.$qrCode.innerHTML = qr.svg(); } _getPairUrl() { let url = new URL(location.href); url.searchParams.append('pair_key', this.pairKey) return url.href; } _copyPairUrl() { navigator.clipboard.writeText(this._getPairUrl()) .then(_ => { Events.fire('notify-user', Localization.getTranslation("notifications.pair-url-copied-to-clipboard")); }) .catch(_ => { Events.fire('notify-user', Localization.getTranslation("notifications.copied-to-clipboard-error")); }) } _onSubmit(e) { e.preventDefault(); this._submit(); } _submit() { let inputKey = this.inputKeyContainer._getInputKey(); this._pairDeviceJoin(inputKey); } _pairDeviceJoin(pairKey) { if (/^\d{6}$/g.test(pairKey)) { Events.fire('pair-device-join', pairKey); this.inputKeyContainer.focusLastChar(); } } _onPairDeviceJoined(peerId, roomSecret) { // abort if peer is another tab on the same browser and remove room-type from gui if (BrowserTabsConnector.peerIsSameBrowser(peerId)) { this._cleanUp(); this.hide(); Events.fire('room-secrets-deleted', [roomSecret]); Events.fire('notify-user', Localization.getTranslation("notifications.pairing-tabs-error")); return; } // save pairPeer and wait for it to connect to ensure both devices have gotten the roomSecret this.pairPeer = { "peerId": peerId, "roomSecret": roomSecret }; } _onPeers(peers, roomType, roomId) { peers.forEach(messagePeer => { this._evaluateJoinedPeer(messagePeer, roomType, roomId); }); } _onPeerJoined(peer, roomType, roomId) { this._evaluateJoinedPeer(peer, roomType, roomId); } _evaluateJoinedPeer(peer, roomType, roomId) { const noPairPeerSaved = !Object.keys(this.pairPeer); const peerId = peer.id; if (!peerId || !roomType || !roomId || noPairPeerSaved) return; const samePeerId = peerId === this.pairPeer.peerId; const sameRoomSecret = roomId === this.pairPeer.roomSecret; const typeIsSecret = roomType === "secret"; if (!samePeerId || !sameRoomSecret || !typeIsSecret) return; this._onPairPeerJoined(peer, roomId); this.pairPeer = {}; } _onPairPeerJoined(peer, roomSecret) { // if devices are paired that are already connected we must save the names at this point const $peer = $(peer.id); let displayName, deviceName; if ($peer) { displayName = $peer.ui._peer.name.displayName; deviceName = $peer.ui._peer.name.deviceName; } PersistentStorage .addRoomSecret(roomSecret, displayName, deviceName) .then(_ => { Events.fire('notify-user', Localization.getTranslation("notifications.pairing-success")); this._evaluateNumberRoomSecrets(); }) .finally(() => { this._cleanUp(); this.hide(); }) .catch(_ => { Events.fire('notify-user', Localization.getTranslation("notifications.pairing-not-persistent")); PersistentStorage.logBrowserNotCapable(); }); } _onPublicRoomJoinKeyInvalid() { Events.fire('notify-user', Localization.getTranslation("notifications.pairing-key-invalid")); } _close() { this._pairDeviceCancel(); } _pairDeviceCancel() { this.hide(); this._cleanUp(); Events.fire('pair-device-cancel'); } _onPairDeviceCanceled(pairKey) { Events.fire('notify-user', Localization.getTranslation("notifications.pairing-key-invalidated", null, {key: pairKey})); } _cleanUp() { this.roomSecret = null; this.pairKey = null; this.inputKeyContainer._cleanUp(); this.pairPeer = {}; } _onSecretRoomDeleted(roomSecret) { PersistentStorage .deleteRoomSecret(roomSecret) .then(_ => { this._evaluateNumberRoomSecrets(); }); } _evaluateNumberRoomSecrets() { PersistentStorage .getAllRoomSecrets() .then(roomSecrets => { if (roomSecrets.length > 0) { this.$editPairedDevicesHeaderBtn.removeAttribute('hidden'); this.$footerInstructionsPairedDevices.removeAttribute('hidden'); } else { this.$editPairedDevicesHeaderBtn.setAttribute('hidden', true); this.$footerInstructionsPairedDevices.setAttribute('hidden', true); } Events.fire('evaluate-footer-badges'); }); } } class EditPairedDevicesDialog extends Dialog { constructor() { super('edit-paired-devices-dialog'); this.$pairedDevicesWrapper = this.$el.querySelector('.paired-devices-wrapper'); this.$footerBadgePairedDevices = $$('.discovery-wrapper .badge-room-secret'); this.$editPairedDevices = $('edit-paired-devices'); this.$editPairedDevices.addEventListener('click', _ => this._onEditPairedDevices()); this.$footerBadgePairedDevices.addEventListener('click', _ => this._onEditPairedDevices()); Events.on('keydown', e => this._onKeyDown(e)); } _onKeyDown(e) { if (!this.isShown()) return; if (e.code === "Escape") { this.hide(); } } async _initDOM() { const pairedDeviceRemovedString = Localization.getTranslation("dialogs.paired-device-removed"); const unpairString = Localization.getTranslation("dialogs.unpair").toUpperCase(); const autoAcceptString = Localization.getTranslation("dialogs.auto-accept").toLowerCase(); const roomSecretsEntries = await PersistentStorage.getAllRoomSecretEntries(); roomSecretsEntries .forEach(roomSecretsEntry => { let $pairedDevice = document.createElement('div'); $pairedDevice.classList.add("paired-device"); $pairedDevice.setAttribute('placeholder', pairedDeviceRemovedString); $pairedDevice.innerHTML = `
${roomSecretsEntry.display_name}
${roomSecretsEntry.device_name}
${autoAcceptString}
` $pairedDevice .querySelector('input[type="checkbox"]') .addEventListener('click', e => { PersistentStorage .updateRoomSecretAutoAccept(roomSecretsEntry.secret, e.target.checked) .then(roomSecretsEntry => { Events.fire('auto-accept-updated', { 'roomSecret': roomSecretsEntry.entry.secret, 'autoAccept': e.target.checked }); }); }); $pairedDevice .querySelector('button') .addEventListener('click', e => { PersistentStorage .deleteRoomSecret(roomSecretsEntry.secret) .then(roomSecret => { Events.fire('room-secrets-deleted', [roomSecret]); Events.fire('evaluate-number-room-secrets'); $pairedDevice.innerText = ""; }); }) this.$pairedDevicesWrapper.appendChild($pairedDevice) }) } hide() { super.hide(); setTimeout(() => { this.$pairedDevicesWrapper.innerHTML = "" }, 300); } _onEditPairedDevices() { this._initDOM() .then(_ => { this._evaluateOverflowing(this.$pairedDevicesWrapper); this.show(); }); } _clearRoomSecrets() { PersistentStorage .getAllRoomSecrets() .then(roomSecrets => { PersistentStorage .clearRoomSecrets() .finally(() => { Events.fire('room-secrets-deleted', roomSecrets); Events.fire('evaluate-number-room-secrets'); Events.fire('notify-user', Localization.getTranslation("notifications.pairing-cleared")); this.hide(); }) }); } } class PublicRoomDialog extends Dialog { constructor() { super('public-room-dialog'); this.$key = this.$el.querySelector('.key'); this.$qrCode = this.$el.querySelector('.key-qr-code'); this.$form = this.$el.querySelector('form'); this.$closeBtn = this.$el.querySelector('[close]'); this.$leaveBtn = this.$el.querySelector('.leave-room'); this.$joinSubmitBtn = this.$el.querySelector('button[type="submit"]'); this.$headerBtnJoinPublicRoom = $('join-public-room'); this.$footerBadgePublicRoomDevices = $$('.discovery-wrapper .badge-room-public-id'); this.$form.addEventListener('submit', e => this._onSubmit(e)); this.$closeBtn.addEventListener('click', _ => this.hide()); this.$leaveBtn.addEventListener('click', _ => this._leavePublicRoom()) this.$headerBtnJoinPublicRoom.addEventListener('click', _ => this._onHeaderBtnClick()); this.$footerBadgePublicRoomDevices.addEventListener('click', _ => this._onHeaderBtnClick()); this.inputKeyContainer = new InputKeyContainer( this.$el.querySelector('.input-key-container'), /[a-z|A-Z]/, () => this.$joinSubmitBtn.removeAttribute('disabled'), () => this.$joinSubmitBtn.setAttribute('disabled', true), () => this._submit() ); Events.on('keydown', e => this._onKeyDown(e)); Events.on('public-room-created', e => this._onPublicRoomCreated(e.detail)); Events.on('peers', e => this._onPeers(e.detail)); Events.on('peer-joined', e => this._onPeerJoined(e.detail.peer, e.detail.roomId)); Events.on('public-room-id-invalid', e => this._onPublicRoomIdInvalid(e.detail)); Events.on('public-room-left', _ => this._onPublicRoomLeft()); this.$el.addEventListener('paste', e => this._onPaste(e)); this.$qrCode.addEventListener('click', _ => this._copyShareRoomUrl()); Events.on('ws-connected', _ => this._onWsConnected()); Events.on('translation-loaded', _ => this.setFooterBadge()); } _onKeyDown(e) { if (!this.isShown()) return; if (e.code === "Escape") { this.hide(); } } _onPaste(e) { e.preventDefault(); let pastedKey = e.clipboardData.getData("Text"); this.inputKeyContainer._onPaste(pastedKey); } _onHeaderBtnClick() { if (this.roomId) { this.show(); } else { this._createPublicRoom(); } } _createPublicRoom() { Events.fire('create-public-room'); } _onPublicRoomCreated(roomId) { this.roomId = roomId; this._setKeyAndQrCode(); this.show(); sessionStorage.setItem('public_room_id', roomId); } _setKeyAndQrCode() { if (!this.roomId) return; this.$key.innerText = this.roomId.toUpperCase(); // Display the QR code for the url const qr = new QRCode({ content: this._getShareRoomUrl(), width: 130, height: 130, padding: 1, background: 'white', color: 'rgb(18, 18, 18)', ecl: "L", join: true }); this.$qrCode.innerHTML = qr.svg(); this.setFooterBadge(); } setFooterBadge() { if (!this.roomId) return; this.$footerBadgePublicRoomDevices.innerText = Localization.getTranslation("footer.public-room-devices", null, { roomId: this.roomId.toUpperCase() }); this.$footerBadgePublicRoomDevices.removeAttribute('hidden'); Events.fire('evaluate-footer-badges'); } _getShareRoomUrl() { let url = new URL(location.href); url.searchParams.append('room_id', this.roomId) return url.href; } _copyShareRoomUrl() { navigator.clipboard.writeText(this._getShareRoomUrl()) .then(_ => { Events.fire('notify-user', Localization.getTranslation("notifications.room-url-copied-to-clipboard")); }) .catch(_ => { Events.fire('notify-user', Localization.getTranslation("notifications.copied-to-clipboard-error")); }) } _onWsConnected() { let roomId = sessionStorage.getItem('public_room_id'); if (!roomId) return; this.roomId = roomId; this._setKeyAndQrCode(); this._joinPublicRoom(roomId, true); } _onSubmit(e) { e.preventDefault(); this._submit(); } _submit() { let inputKey = this.inputKeyContainer._getInputKey(); this._joinPublicRoom(inputKey); } _joinPublicRoom(roomId, createIfInvalid = false) { roomId = roomId.toLowerCase(); if (/^[a-z]{5}$/g.test(roomId)) { this.roomIdJoin = roomId; this.inputKeyContainer.focusLastChar(); Events.fire('join-public-room', { roomId: roomId, createIfInvalid: createIfInvalid }); } } _onPeers(message) { message.peers.forEach(messagePeer => { this._evaluateJoinedPeer(messagePeer.id, message.roomId); }); } _onPeerJoined(peer, roomId) { this._evaluateJoinedPeer(peer.id, roomId); } _evaluateJoinedPeer(peerId, roomId) { const isInitiatedRoomId = roomId === this.roomId; const isJoinedRoomId = roomId === this.roomIdJoin; if (!peerId || !roomId || (!isInitiatedRoomId && !isJoinedRoomId)) return; this.hide(); sessionStorage.setItem('public_room_id', roomId); if (isJoinedRoomId) { this.roomId = roomId; this.roomIdJoin = false; this._setKeyAndQrCode(); } } _onPublicRoomIdInvalid(roomId) { Events.fire('notify-user', Localization.getTranslation("notifications.public-room-id-invalid")); if (roomId === sessionStorage.getItem('public_room_id')) { sessionStorage.removeItem('public_room_id'); } } _leavePublicRoom() { Events.fire('leave-public-room', this.roomId); } _onPublicRoomLeft() { let publicRoomId = this.roomId.toUpperCase(); this.hide(); this._cleanUp(); Events.fire('notify-user', Localization.getTranslation("notifications.public-room-left", null, {publicRoomId: publicRoomId})); } show() { this.inputKeyContainer._enableChars(); super.show(); } hide() { this.inputKeyContainer._cleanUp(); super.hide(); } _cleanUp() { this.roomId = null; this.inputKeyContainer._cleanUp(); sessionStorage.removeItem('public_room_id'); this.$footerBadgePublicRoomDevices.setAttribute('hidden', true); Events.fire('evaluate-footer-badges'); } } class SendTextDialog extends Dialog { constructor() { super('send-text-dialog'); this.$text = this.$el.querySelector('.textarea'); this.$peerDisplayName = this.$el.querySelector('.display-name'); this.$form = this.$el.querySelector('form'); this.$submit = this.$el.querySelector('button[type="submit"]'); this.$form.addEventListener('submit', e => this._onSubmit(e)); this.$text.addEventListener('input', _ => this._onInput()); Events.on('text-recipient', e => this._onRecipient(e.detail.peerId, e.detail.deviceName)); Events.on('keydown', e => this._onKeyDown(e)); } _onKeyDown(e) { if (!this.isShown()) return; if (e.code === "Escape") { this.hide(); } else if (e.code === "Enter" && (e.ctrlKey || e.metaKey)) { if (this._textEmpty()) return; this._send(); } } _textEmpty() { return !this.$text.innerText || this.$text.innerText === "\n"; } _onInput() { if (this._textEmpty()) { this.$submit.setAttribute('disabled', true); // remove remaining whitespace on Firefox on text deletion this.$text.innerText = ""; } else { this.$submit.removeAttribute('disabled'); } this._evaluateOverflowing(this.$text); } _onRecipient(peerId, deviceName) { this.correspondingPeerId = peerId; this.$peerDisplayName.innerText = deviceName; this.$peerDisplayName.classList.remove(...PeerUI._badgeClassNames); this.$peerDisplayName.classList.add($(peerId).ui._badgeClassName()); this.show(); const range = document.createRange(); const sel = window.getSelection(); range.selectNodeContents(this.$text); sel.removeAllRanges(); sel.addRange(range); } _onSubmit(e) { e.preventDefault(); this._send(); } _send() { Events.fire('send-text', { to: this.correspondingPeerId, text: this.$text.innerText }); this.hide(); setTimeout(() => this.$text.innerText = "", 300); } } class ReceiveTextDialog extends Dialog { constructor() { super('receive-text-dialog'); Events.on('text-received', e => this._onText(e.detail.text, e.detail.peerId)); this.$text = this.$el.querySelector('#text'); this.$copy = this.$el.querySelector('#copy'); this.$close = this.$el.querySelector('#close'); this.$copy.addEventListener('click', _ => this._onCopy()); this.$close.addEventListener('click', _ => this.hide()); Events.on('keydown', e => this._onKeyDown(e)); this.$displayName = this.$el.querySelector('.display-name'); this._receiveTextQueue = []; } selectionEmpty() { return !window.getSelection().toString() } async _onKeyDown(e) { if (!this.isShown()) return if (e.code === "KeyC" && (e.ctrlKey || e.metaKey) && this.selectionEmpty()) { await this._onCopy() } else if (e.code === "Escape") { this.hide(); } } _onText(text, peerId) { window.blop.play(); this._receiveTextQueue.push({text: text, peerId: peerId}); this._setDocumentTitleMessages(); if (this.isShown()) return; this._dequeueRequests(); } _dequeueRequests() { if (!this._receiveTextQueue.length) return; let {text, peerId} = this._receiveTextQueue.shift(); this._showReceiveTextDialog(text, peerId); } _showReceiveTextDialog(text, peerId) { this.$displayName.innerText = $(peerId).ui._displayName(); this.$displayName.classList.remove(...PeerUI._badgeClassNames); this.$displayName.classList.add($(peerId).ui._badgeClassName()); this.$text.innerText = text; this.$text.classList.remove('text-center'); // Beautify text if text is short if (text.length < 2000) { // replace URLs with actual links this.$text.innerHTML = this.$text.innerHTML .replace(/(^|
|\s|")((https?:\/\/|www.)(([a-z]|[A-Z]|[0-9]|[\-_~:\/?#\[\]@!$&'()*+,;=%]){2,}\.)(([a-z]|[A-Z]|[0-9]|[\-_~:\/?#\[\]@!$&'()*+,;=%.]){2,}))/g, (match, whitespace, url) => { let link = url; // prefix www.example.com with http protocol to prevent it from being a relative link if (link.startsWith('www')) { link = "http://" + link } // Check if link is valid if (isUrlValid(link)) { return `${whitespace}${url}`; } else { return match; } }); } this._evaluateOverflowing(this.$text); this._setDocumentTitleMessages(); changeFavicon("images/favicon-96x96-notification.png"); this.show(); } _setDocumentTitleMessages() { document.title = !this._receiveTextQueue.length ? `${ Localization.getTranslation("document-titles.message-received") } - PairDrop` : `${ Localization.getTranslation("document-titles.message-received-plural", null, {count: this._receiveTextQueue.length + 1}) } - PairDrop`; } async _onCopy() { const sanitizedText = this.$text.innerText.replace(/\u00A0/gm, ' '); navigator.clipboard .writeText(sanitizedText) .then(_ => { Events.fire('notify-user', Localization.getTranslation("notifications.copied-to-clipboard")); this.hide(); }) .catch(_ => { Events.fire('notify-user', Localization.getTranslation("notifications.copied-to-clipboard-error")); }); } hide() { super.hide(); setTimeout(() => { this._dequeueRequests(); this.$text.innerHTML = ""; }, 500); } } class ShareTextDialog extends Dialog { constructor() { super('share-text-dialog'); this.$text = this.$el.querySelector('.textarea'); this.$approveMsgBtn = this.$el.querySelector('button[type="submit"]'); this.$checkbox = this.$el.querySelector('input[type="checkbox"]') this.$approveMsgBtn.addEventListener('click', _ => this._approveShareText()); // Only show this per default if user sets checkmark this.$checkbox.checked = localStorage.getItem('approve-share-text') ? ShareTextDialog.isApproveShareTextSet() : false; this._setCheckboxValueToLocalStorage(); this.$checkbox.addEventListener('change', _ => this._setCheckboxValueToLocalStorage()); Events.on('share-text-dialog', e => this._onShareText(e.detail)); Events.on('keydown', e => this._onKeyDown(e)); this.$text.addEventListener('input', _ => this._evaluateEmptyText()); } static isApproveShareTextSet() { return localStorage.getItem('approve-share-text') === "true"; } _setCheckboxValueToLocalStorage() { localStorage.setItem('approve-share-text', this.$checkbox.checked ? "true" : "false"); } _onKeyDown(e) { if (!this.isShown()) return; if (e.code === "Escape") { this._approveShareText(); } else if (e.code === "Enter" && (e.ctrlKey || e.metaKey)) { if (this._textEmpty()) return; this._approveShareText(); } } _textEmpty() { return !this.$text.innerText || this.$text.innerText === "\n"; } _evaluateEmptyText() { if (this._textEmpty()) { this.$approveMsgBtn.setAttribute('disabled', true); // remove remaining whitespace on Firefox on text deletion this.$text.innerText = ""; } else { this.$approveMsgBtn.removeAttribute('disabled'); } this._evaluateOverflowing(this.$text); } _onShareText(text) { this.$text.innerText = text; this._evaluateEmptyText(); this.show(); } _approveShareText() { Events.fire('activate-share-mode', {text: this.$text.innerText}); this.hide(); } hide() { super.hide(); setTimeout(() => this.$text.innerText = "", 500); } } class Base64Dialog extends Dialog { constructor() { super('base64-paste-dialog'); this.$title = this.$el.querySelector('.dialog-title'); this.$pasteBtn = this.$el.querySelector('#base64-paste-btn'); this.$fallbackTextarea = this.$el.querySelector('.textarea'); } async evaluateBase64Text(base64Text, hash) { this.$title.innerText = Localization.getTranslation('dialogs.base64-title-text'); if (base64Text === 'paste') { // ?base64text=paste // base64 encoded string is ready to be pasted from clipboard this.preparePasting('text'); this.show(); } else if (base64Text === 'hash') { // ?base64text=hash#BASE64ENCODED // base64 encoded text is url hash which cannot be seen by the server and is faster (recommended) this.show(); await this.processBase64Text(hash); } else { // ?base64text=BASE64ENCODED // base64 encoded text is part of the url param. Seen by server and slow (not recommended) this.show(); await this.processBase64Text(base64Text); } } async evaluateBase64Zip(base64Zip, hash) { this.$title.innerText = Localization.getTranslation('dialogs.base64-title-files'); if (base64Zip === 'paste') { // ?base64zip=paste || ?base64zip=true this.preparePasting('files'); this.show(); } else if (base64Zip === 'hash') { // ?base64zip=hash#BASE64ENCODED // base64 encoded zip file is url hash which cannot be seen by the server await this.processBase64Zip(hash); } } _setPasteBtnToProcessing() { this.$pasteBtn.style.pointerEvents = "none"; this.$pasteBtn.innerText = Localization.getTranslation("dialogs.base64-processing"); } preparePasting(type) { const translateType = type === 'text' ? Localization.getTranslation("dialogs.base64-text") : Localization.getTranslation("dialogs.base64-files"); if (navigator.clipboard.readText) { this.$pasteBtn.innerText = Localization.getTranslation("dialogs.base64-tap-to-paste", null, {type: translateType}); this._clickCallback = _ => this.processClipboard(type); this.$pasteBtn.addEventListener('click', _ => this._clickCallback()); } else { console.log("`navigator.clipboard.readText()` is not available on your browser.\nOn Firefox you can set `dom.events.asyncClipboard.readText` to true under `about:config` for convenience.") this.$pasteBtn.setAttribute('hidden', true); this.$fallbackTextarea.setAttribute('placeholder', Localization.getTranslation("dialogs.base64-paste-to-send", null, {type: translateType})); this.$fallbackTextarea.removeAttribute('hidden'); this._inputCallback = _ => this.processInput(type); this.$fallbackTextarea.addEventListener('input', _ => this._inputCallback()); this.$fallbackTextarea.focus(); } } async processInput(type) { const base64 = this.$fallbackTextarea.textContent; this.$fallbackTextarea.textContent = ''; await this.processPastedBase64(type, base64); } async processClipboard(type) { const base64 = await navigator.clipboard.readText(); await this.processPastedBase64(type, base64); } async processPastedBase64(type, base64) { try { if (type === 'text') { await this.processBase64Text(base64); } else { await this.processBase64Zip(base64); } } catch(e) { Events.fire('notify-user', Localization.getTranslation("notifications.clipboard-content-incorrect")); console.log("Clipboard content is incorrect.") } this.hide(); } async processBase64Text(base64){ this._setPasteBtnToProcessing(); try { const decodedText = await decodeBase64Text(base64); if (ShareTextDialog.isApproveShareTextSet()) { Events.fire('share-text-dialog', decodedText); } else { Events.fire('activate-share-mode', {text: decodedText}); } } catch (e) { Events.fire('notify-user', Localization.getTranslation("notifications.text-content-incorrect")); console.log("Text content incorrect."); } this.hide(); } async processBase64Zip(base64) { this._setPasteBtnToProcessing(); try { const decodedFiles = await decodeBase64Files(base64); Events.fire('activate-share-mode', {files: decodedFiles}); } catch (e) { Events.fire('notify-user', Localization.getTranslation("notifications.file-content-incorrect")); console.log("File content incorrect."); } this.hide(); } hide() { this.$pasteBtn.removeEventListener('click', _ => this._clickCallback()); this.$fallbackTextarea.removeEventListener('input', _ => this._inputCallback()); this.$fallbackTextarea.setAttribute('disabled', true); this.$fallbackTextarea.blur(); super.hide(); } } class AboutUI { constructor() { this.$donationBtn = $('donation-btn'); this.$twitterBtn = $('twitter-btn'); this.$mastodonBtn = $('mastodon-btn'); this.$blueskyBtn = $('bluesky-btn'); this.$customBtn = $('custom-btn'); this.$privacypolicyBtn = $('privacypolicy-btn'); Events.on('config', e => this._onConfig(e.detail.buttons)); } async _onConfig(btnConfig) { await this._evaluateBtnConfig(this.$donationBtn, btnConfig.donation_button); await this._evaluateBtnConfig(this.$twitterBtn, btnConfig.twitter_button); await this._evaluateBtnConfig(this.$mastodonBtn, btnConfig.mastodon_button); await this._evaluateBtnConfig(this.$blueskyBtn, btnConfig.bluesky_button); await this._evaluateBtnConfig(this.$customBtn, btnConfig.custom_button); await this._evaluateBtnConfig(this.$privacypolicyBtn, btnConfig.privacypolicy_button); } async _evaluateBtnConfig($btn, config) { // if config is not set leave everything as default if (!Object.keys(config).length) return; if (config.active === "false") { $btn.setAttribute('hidden', true); } else { if (config.link) { $btn.setAttribute('href', config.link); } if (config.title) { $btn.setAttribute('title', config.title); // prevent overwriting of custom title when setting different language $btn.removeAttribute('data-i18n-key'); $btn.removeAttribute('data-i18n-attrs'); } if (config.icon) { $btn.setAttribute('title', config.title); // prevent overwriting of custom title when setting different language $btn.removeAttribute('data-i18n-key'); $btn.removeAttribute('data-i18n-attrs'); } $btn.removeAttribute('hidden'); } } } class Toast extends Dialog { constructor() { super('toast'); this.$closeBtn = this.$el.querySelector('.icon-button'); this.$text = this.$el.querySelector('span'); this.$closeBtn.addEventListener('click', _ => this.hide()); Events.on('notify-user', e => this._onNotify(e.detail)); Events.on('share-mode-changed', _ => this.hide()); } _onNotify(message) { if (this.hideTimeout) clearTimeout(this.hideTimeout); this.$text.innerText = typeof message === "object" ? message.message : message; this.show(); if (typeof message === "object" && message.persistent) return; this.hideTimeout = setTimeout(() => this.hide(), 5000); } hide() { if (this.hideTimeout) clearTimeout(this.hideTimeout); super.hide(); } } class Notifications { constructor() { // Check if the browser supports notifications if (!('Notification' in window)) return; this.$headerNotificationButton = $('notification'); this.$downloadBtn = $('download-btn'); this.$headerNotificationButton.addEventListener('click', _ => this._requestPermission()); Events.on('text-received', e => this._messageNotification(e.detail.text, e.detail.peerId)); Events.on('files-received', e => this._downloadNotification(e.detail.files)); Events.on('files-transfer-request', e => this._requestNotification(e.detail.request, e.detail.peerId)); } async _requestPermission() { await Notification. requestPermission(permission => { if (permission !== 'granted') { Events.fire('notify-user', Localization.getTranslation("notifications.notifications-permissions-error")); return; } Events.fire('notify-user', Localization.getTranslation("notifications.notifications-enabled")); this.$headerNotificationButton.setAttribute('hidden', true); }); } _notify(title, body) { const config = { body: body, icon: '/images/logo_transparent_128x128.png', } let notification; try { notification = new Notification(title, config); } catch (e) { // Android doesn't support "new Notification" if service worker is installed if (!serviceWorker || !serviceWorker.showNotification) return; notification = serviceWorker.showNotification(title, config); } // Notification is persistent on Android. We have to close it manually const visibilitychangeHandler = () => { if (document.visibilityState === 'visible') { notification.close(); Events.off('visibilitychange', visibilitychangeHandler); } }; Events.on('visibilitychange', visibilitychangeHandler); return notification; } _messageNotification(message, peerId) { if (document.visibilityState !== 'visible') { const peerDisplayName = $(peerId).ui._displayName(); if (/^((https?:\/\/|www)[abcdefghijklmnopqrstuvwxyz0123456789\-._~:\/?#\[\]@!$&'()*+,;=]+)$/.test(message.toLowerCase())) { const notification = this._notify(Localization.getTranslation("notifications.link-received", null, {name: peerDisplayName}), message); this._bind(notification, _ => window.open(message, '_blank', "noreferrer")); } else { const notification = this._notify(Localization.getTranslation("notifications.message-received", null, {name: peerDisplayName}), message); this._bind(notification, _ => this._copyText(message, notification)); } } } _downloadNotification(files) { if (document.visibilityState !== 'visible') { let imagesOnly = files.every(file => file.type.split('/')[0] === 'image'); let title; if (files.length === 1) { title = `${files[0].name}`; } else { let fileOther; if (files.length === 2) { fileOther = imagesOnly ? Localization.getTranslation("dialogs.file-other-description-image") : Localization.getTranslation("dialogs.file-other-description-file"); } else { fileOther = imagesOnly ? Localization.getTranslation("dialogs.file-other-description-image-plural", null, {count: files.length - 1}) : Localization.getTranslation("dialogs.file-other-description-file-plural", null, {count: files.length - 1}); } title = `${files[0].name} ${fileOther}` } const notification = this._notify(title, Localization.getTranslation("notifications.click-to-download")); this._bind(notification, _ => this._download(notification)); } } _requestNotification(request, peerId) { if (document.visibilityState !== 'visible') { let imagesOnly = request.header.every(header => header.mime.split('/')[0] === 'image'); let displayName = $(peerId).querySelector('.name').textContent; let descriptor; if (request.header.length === 1) { descriptor = imagesOnly ? Localization.getTranslation("dialogs.title-image") : Localization.getTranslation("dialogs.title-file"); } else { descriptor = imagesOnly ? Localization.getTranslation("dialogs.title-image-plural") : Localization.getTranslation("dialogs.title-file-plural"); } let title = Localization .getTranslation("notifications.request-title", null, { name: displayName, count: request.header.length, descriptor: descriptor.toLowerCase() }); const notification = this._notify(title, Localization.getTranslation("notifications.click-to-show")); } } _download(notification) { this.$downloadBtn.click(); notification.close(); } async _copyText(message, notification) { if (await navigator.clipboard.writeText(message)) { notification.close(); this._notify(Localization.getTranslation("notifications.copied-text")); } else { this._notify(Localization.getTranslation("notifications.copied-text-error")); } } _bind(notification, handler) { if (notification.then) { notification.then(_ => { serviceWorker .getNotifications() .then(_ => { serviceWorker.addEventListener('notificationclick', handler); }) }); } else { notification.onclick = handler; } } } class NetworkStatusUI { constructor() { Events.on('offline', _ => this._showOfflineMessage()); Events.on('online', _ => this._showOnlineMessage()); if (!navigator.onLine) this._showOfflineMessage(); } _showOfflineMessage() { Events.fire('notify-user', { message: Localization.getTranslation("notifications.offline"), persistent: true }); } _showOnlineMessage() { Events.fire('notify-user', Localization.getTranslation("notifications.online")); } } class WebShareTargetUI { async evaluateShareTarget(shareTargetType, title, text, url) { if (shareTargetType === "text") { let shareTargetText; if (url) { shareTargetText = url; // we share only the link - no text. } else if (title && text) { shareTargetText = title + '\r\n' + text; } else { shareTargetText = title + text; } if (ShareTextDialog.isApproveShareTextSet()) { Events.fire('share-text-dialog', shareTargetText); } else { Events.fire('activate-share-mode', {text: shareTargetText}); } } else if (shareTargetType === "files") { let openRequest = window.indexedDB.open('pairdrop_store') openRequest.onsuccess = e => { const db = e.target.result; const tx = db.transaction('share_target_files', 'readwrite'); const store = tx.objectStore('share_target_files'); const request = store.getAll(); request.onsuccess = _ => { const fileObjects = request.result; let filesReceived = []; for (let i = 0; i < fileObjects.length; i++) { filesReceived.push(new File([fileObjects[i].buffer], fileObjects[i].name)); } const clearRequest = store.clear() clearRequest.onsuccess = _ => db.close(); Events.fire('activate-share-mode', {files: filesReceived}) } } } } } // Keep for legacy reasons even though this is removed from new PWA installations class WebFileHandlersUI { async evaluateLaunchQueue() { if (!"launchQueue" in window) return; launchQueue.setConsumer(async launchParams => { console.log("Launched with: ", launchParams); if (!launchParams.files.length) return; let files = []; for (let i = 0; i < launchParams.files.length; i++) { if (i !== 0 && await launchParams.files[i].isSameEntry(launchParams.files[i-1])) continue; const file = await launchParams.files[i].getFile(); files.push(file); } Events.fire('activate-share-mode', {files: files}) }); } } class NoSleepUI { constructor() { NoSleepUI._nosleep = new NoSleep(); } static enable() { if (!this._interval) { NoSleepUI._nosleep.enable(); // Disable after 10s if all peers are idle NoSleepUI._interval = setInterval(() => NoSleepUI.disableIfPeersIdle(), 10000); } } static disableIfPeersIdle() { if ($$('x-peer[status]') === null) { clearInterval(NoSleepUI._interval); NoSleepUI._nosleep.disable(); } } }