implement file receive request with image-thumbnail-preview, share-menu on click additional to download and multiple file support by zipping file(s) to prepare for sending; add status "waiting.." and "preparing..." to UX; lock pointer-input when peer-node busy; tidy-up paste-mode deactivation

This commit is contained in:
schlagmichdoch 2023-01-17 10:41:50 +01:00
parent 6707021e04
commit 5525caa766
8 changed files with 581 additions and 204 deletions

View file

@ -131,17 +131,28 @@
<x-dialog id="receiveDialog"> <x-dialog id="receiveDialog">
<x-background class="full center"> <x-background class="full center">
<x-paper shadow="2"> <x-paper shadow="2">
<h3>File Received</h3> <h2 class="center">Pairdrop</h2>
<div class="font-subheading" id="fileName">Filename</div> <div class="text-center file-description"></div>
<div class="font-body2" id="fileSize"></div> <div class="font-body2 text-center file-size"></div>
<div class='preview' style="visibility: hidden;"></div> <div class="center file-preview"></div>
<div class="row"> <div class="row-reverse space-between">
<label for="autoDownload" class="grow">Ask to save each file before downloading</label> <a class="button" id="shareOrDownload" autofocus></a>
<input type="checkbox" id="autoDownload" checked=""> <button class="button" close>Close</button>
</div> </div>
<div class="row-reverse"> </x-paper>
<a class="button" close id="download" title="Download File" autofocus>Save</a> </x-background>
<button class="button" close>Ignore</button> </x-dialog>
<!-- Receive Dialog -->
<x-dialog id="receiveRequestDialog">
<x-background class="full center">
<x-paper shadow="2">
<h2 class="center">Pairdrop</h2>
<div class="text-center file-description"></div>
<div class="font-body2 text-center file-size"></div>
<div class="center file-preview"></div>
<div class="row-reverse space-between">
<button class="button" id="acceptRequest" title="Accept Request" close autofocus>Accept</button>
<button class="button" id="declineRequest" title="Decline Request" close>Decline</button>
</div> </div>
</x-paper> </x-paper>
</x-background> </x-background>
@ -272,11 +283,12 @@
</symbol> </symbol>
</svg> </svg>
<!-- Scripts --> <!-- Scripts -->
<script src="scripts/zip.min.js" async></script>
<script src="scripts/util.js"></script>
<script src="scripts/network.js"></script> <script src="scripts/network.js"></script>
<script src="scripts/qrcode.js" async></script> <script src="scripts/qrcode.js" async></script>
<script src="scripts/ui.js"></script> <script src="scripts/ui.js"></script>
<script src="scripts/theme.js" async></script> <script src="scripts/theme.js" async></script>
<script src="scripts/clipboard.js" async></script>
<!-- Sounds --> <!-- Sounds -->
<audio id="blop" autobuffer="true"> <audio id="blop" autobuffer="true">
<source src="/sounds/blop.mp3" type="audio/mpeg"> <source src="/sounds/blop.mp3" type="audio/mpeg">

View file

@ -1,38 +0,0 @@
// Polyfill for Navigator.clipboard.writeText
if (!navigator.clipboard) {
navigator.clipboard = {
writeText: text => {
// A <span> contains the text to copy
const span = document.createElement('span');
span.textContent = text;
span.style.whiteSpace = 'pre'; // Preserve consecutive spaces and newlines
// Paint the span outside the viewport
span.style.position = 'absolute';
span.style.left = '-9999px';
span.style.top = '-9999px';
const win = window;
const selection = win.getSelection();
win.document.body.appendChild(span);
const range = win.document.createRange();
selection.removeAllRanges();
range.selectNode(span);
selection.addRange(range);
let success = false;
try {
success = win.document.execCommand('copy');
} catch (err) {
return Promise.error();
}
selection.removeAllRanges();
span.remove();
return Promise.resolve();
}
}
}

View file

@ -17,6 +17,8 @@ class ServerConnection {
Events.on('pair-device-initiate', _ => this._onPairDeviceInitiate()); Events.on('pair-device-initiate', _ => this._onPairDeviceInitiate());
Events.on('pair-device-join', e => this._onPairDeviceJoin(e.detail)); Events.on('pair-device-join', e => this._onPairDeviceJoin(e.detail));
Events.on('pair-device-cancel', _ => this.send({ type: 'pair-device-cancel' })); Events.on('pair-device-cancel', _ => this.send({ type: 'pair-device-cancel' }));
Events.on('offline', _ => clearTimeout(this._reconnectTimer));
Events.on('online', _ => this._connect());
} }
_connect() { _connect() {
@ -50,7 +52,7 @@ class ServerConnection {
_onPairDeviceJoin(roomKey) { _onPairDeviceJoin(roomKey) {
if (!this._isConnected()) { if (!this._isConnected()) {
setTimeout(_ => this._onPairDeviceJoin(roomKey), 200); setTimeout(_ => this._onPairDeviceJoin(roomKey), 1000);
return; return;
} }
this.send({ type: 'pair-device-join', roomKey: roomKey }) this.send({ type: 'pair-device-join', roomKey: roomKey })
@ -143,9 +145,9 @@ class ServerConnection {
_onDisconnect() { _onDisconnect() {
console.log('WS: server disconnected'); console.log('WS: server disconnected');
Events.fire('notify-user', 'Connection lost. Retry in 5 seconds...'); Events.fire('notify-user', 'Connection lost. Retrying...');
clearTimeout(this._reconnectTimer); clearTimeout(this._reconnectTimer);
this._reconnectTimer = setTimeout(_ => this._connect(), 5000); this._reconnectTimer = setTimeout(_ => this._connect(), 1000);
Events.fire('ws-disconnected'); Events.fire('ws-disconnected');
} }
@ -187,10 +189,114 @@ class Peer {
this._send(JSON.stringify(message)); this._send(JSON.stringify(message));
} }
sendFiles(files) { async createHeader(file) {
for (let i = 0; i < files.length; i++) { let hashHex = await this.getHashHex(file);
this._filesQueue.push(files[i]); return {
name: file.name,
mime: file.type,
size: file.size,
hashHex: hashHex
};
}
async getHashHex(file) {
if (!crypto.subtle) {
console.error("PairDrop only works in secure contexts.")
} }
const hashBuffer = await crypto.subtle.digest('SHA-256', await file.arrayBuffer());
// Convert hex to hash, see https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest#converting_a_digest_to_a_hex_string
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); // convert bytes to hex string
return(hashHex);
}
getResizedImageDataUrl(file, width = undefined, height = undefined, quality = 0.7) {
return new Promise((resolve) => {
let image = new Image();
image.src = URL.createObjectURL(file);
image.onload = _ => {
let imageWidth = image.width;
let imageHeight = image.height;
let canvas = document.createElement('canvas');
// resize the canvas and draw the image data into it
if (width && height) {
canvas.width = width;
canvas.height = height;
} else if (width) {
canvas.width = width;
canvas.height = Math.floor(imageHeight * width / imageWidth)
} else if (height) {
canvas.width = Math.floor(imageWidth * height / imageHeight);
canvas.height = height;
} else {
canvas.width = imageWidth;
canvas.height = imageHeight
}
var ctx = canvas.getContext("2d");
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
let dataUrl = canvas.toDataURL("image/jpeg", quality);
resolve(dataUrl);
}
}).then(dataUrl => {
return dataUrl;
})
}
async requestFileTransfer(files) {
Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: 'prepare'})
let header = [];
let allFilesAreImages = true;
let combinedSize = 0;
for (let i=0; i<files.length; i++) {
header.push(await this.createHeader(files[i]));
if (files[i].type.split('/')[0] !== 'image') {
allFilesAreImages = false;
}
combinedSize += files[i].size;
}
this._fileHeaderRequested = header;
let bytesCompleted = 0;
for (let i=0; i<files.length; i++) {
const entry = await zipper.addFile(files[i], {
onprogress: (progress, total) => {
Events.fire('set-progress', {
peerId: this._peerId,
progress: (bytesCompleted + progress) / combinedSize,
status: 'prepare'
})
}
});
bytesCompleted += files[i].size;
}
this.zipFileRequested = await zipper.getZipFile();
if (allFilesAreImages) {
this.getResizedImageDataUrl(files[0], 400, null, 0.9).then(dataUrl => {
this.sendJSON({type: 'request',
header: header,
size: combinedSize,
thumbnailDataUrl: dataUrl
});
})
} else {
this.sendJSON({type: 'request',
header: header,
size: combinedSize,
});
}
Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: 'wait'})
}
async sendFiles() {
console.debug("sendFiles")
console.debug(this.zipFileRequested);
this._filesQueue.push({zipFile: this.zipFileRequested, fileHeader: this._fileHeaderRequested});
this._fileHeaderRequested = null
if (this._busy) return; if (this._busy) return;
this._dequeueFile(); this._dequeueFile();
} }
@ -202,14 +308,13 @@ class Peer {
this._sendFile(file); this._sendFile(file);
} }
_sendFile(file) { async _sendFile(file) {
this.sendJSON({ this.sendJSON({
type: 'header', type: 'header',
name: file.name, size: file.zipFile.size,
mime: file.type, fileHeader: file.fileHeader
size: file.size
}); });
this._chunker = new FileChunker(file, this._chunker = new FileChunker(file.zipFile,
chunk => this._send(chunk), chunk => this._send(chunk),
offset => this._onPartitionEnd(offset)); offset => this._onPartitionEnd(offset));
this._chunker.nextPartition(); this._chunker.nextPartition();
@ -240,8 +345,11 @@ class Peer {
message = JSON.parse(message); message = JSON.parse(message);
console.log('RTC:', message); console.log('RTC:', message);
switch (message.type) { switch (message.type) {
case 'request':
this._onFilesTransferRequest(message);
break;
case 'header': case 'header':
this._onFileHeader(message); this._onFilesHeader(message);
break; break;
case 'partition': case 'partition':
this._onReceivedPartitionEnd(message); this._onReceivedPartitionEnd(message);
@ -252,6 +360,9 @@ class Peer {
case 'progress': case 'progress':
this._onDownloadProgress(message.progress); this._onDownloadProgress(message.progress);
break; break;
case 'files-transfer-response':
this._onFileTransferResponded(message);
break;
case 'file-transfer-complete': case 'file-transfer-complete':
this._onFileTransferCompleted(); this._onFileTransferCompleted();
break; break;
@ -264,17 +375,37 @@ class Peer {
} }
} }
_onFileHeader(header) { _onFilesTransferRequest(request) {
this._lastProgress = 0; if (this._requestPending) {
this._digester = new FileDigester({ // Only accept one request at a time
name: header.name, this.sendJSON({type: 'files-transfer-response', accepted: false});
mime: header.mime, return;
size: header.size }
}, file => this._onFileReceived(file)); this._requestPending = true;
Events.fire('files-transfer-request', {
request: request,
peerId: this._peerId
});
}
_respondToFileTransferRequest(header, accepted) {
this._requestPending = false;
this._acceptedHeader = header;
this.sendJSON({type: 'files-transfer-response', accepted: accepted});
if (accepted) this._busy = true;
}
_onFilesHeader(msg) {
if (JSON.stringify(this._acceptedHeader) === JSON.stringify(msg.fileHeader)) {
this._lastProgress = 0;
this._digester = new FileDigester(msg.size, blob => this._onFileReceived(blob, msg.fileHeader));
this._acceptedHeader = null;
}
} }
_onChunkReceived(chunk) { _onChunkReceived(chunk) {
if(!(chunk.byteLength || chunk.size)) return; if(!this._digester || !(chunk.byteLength || chunk.size)) return;
this._digester.unchunk(chunk); this._digester.unchunk(chunk);
const progress = this._digester.progress; const progress = this._digester.progress;
@ -287,24 +418,58 @@ class Peer {
} }
_onDownloadProgress(progress) { _onDownloadProgress(progress) {
Events.fire('file-progress', { sender: this._peerId, progress: progress }); if (this._busy) {
Events.fire('set-progress', {peerId: this._peerId, progress: progress, status: 'transfer'});
}
} }
_onFileReceived(proxyFile) { async _onFileReceived(zipBlob, fileHeader) {
Events.fire('file-received', proxyFile); Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: 'wait'});
this.sendJSON({ type: 'file-transfer-complete' });
this._busy = false;
this.sendJSON({type: 'file-transfer-complete'});
let zipEntries = await zipper.getEntries(zipBlob);
let files = [];
let hashHexs = [];
for (let i=0; i<zipEntries.length; i++) {
let fileBlob = await zipper.getData(zipEntries[i]);
let hashHex = await this.getHashHex(fileBlob)
if (hashHex !== fileHeader[i].hashHex) {
Events.fire('notify-user', 'Files are malformed.');
Events.fire('set-progress', {peerId: this._peerId, progress: 1, status: 'wait'});
throw new Error("Hash of received file differs from hash of requested file. Abort!");
}
files.push(new File([fileBlob], zipEntries[i].filename, {
type: fileHeader[i].mime,
lastModified: new Date().getTime()
}));
}
Events.fire('files-received', {sender: this._peerId, files: files});
} }
_onFileTransferCompleted() { _onFileTransferCompleted() {
this._onDownloadProgress(1); this._onDownloadProgress(1);
this._reader = null; this._digester = null;
this._busy = false; this._busy = false;
this._dequeueFile(); this._dequeueFile();
Events.fire('notify-user', 'File transfer completed.'); Events.fire('notify-user', 'File transfer completed.');
Events.fire('deactivate-paste-mode');
}
_onFileTransferResponded(message) {
if (!message.accepted) {
Events.fire('set-progress', {peerId: this._peerId, progress: 1, status: 'wait'});
this.zipFile = null;
return;
}
Events.fire('file-transfer-accepted');
this.sendFiles();
} }
_onMessageTransferCompleted() { _onMessageTransferCompleted() {
Events.fire('notify-user', 'Message transfer completed.'); Events.fire('notify-user', 'Message transfer completed.');
Events.fire('deactivate-paste-mode');
} }
sendText(text) { sendText(text) {
@ -477,6 +642,7 @@ class PeersManager {
Events.on('signal', e => this._onMessage(e.detail)); Events.on('signal', e => this._onMessage(e.detail));
Events.on('peers', e => this._onPeers(e.detail)); Events.on('peers', e => this._onPeers(e.detail));
Events.on('files-selected', e => this._onFilesSelected(e.detail)); Events.on('files-selected', e => this._onFilesSelected(e.detail));
Events.on('respond-to-files-transfer-request', e => this._onRespondToFileTransferRequest(e.detail))
Events.on('send-text', e => this._onSendText(e.detail)); Events.on('send-text', e => this._onSendText(e.detail));
Events.on('peer-joined', e => this._onPeerJoined(e.detail)); Events.on('peer-joined', e => this._onPeerJoined(e.detail));
Events.on('peer-left', e => this._onPeerLeft(e.detail)); Events.on('peer-left', e => this._onPeerLeft(e.detail));
@ -522,8 +688,12 @@ class PeersManager {
this.peers[peerId].send(message); this.peers[peerId].send(message);
} }
_onRespondToFileTransferRequest(detail) {
this.peers[detail.to]._respondToFileTransferRequest(detail.header, detail.accepted);
}
_onFilesSelected(message) { _onFilesSelected(message) {
this.peers[message.to].sendFiles(message.files); this.peers[message.to].requestFileTransfer(message.files);
} }
_onSendText(message) { _onSendText(message) {
@ -614,12 +784,10 @@ class FileChunker {
class FileDigester { class FileDigester {
constructor(meta, callback) { constructor(size, callback) {
this._buffer = []; this._buffer = [];
this._bytesReceived = 0; this._bytesReceived = 0;
this._size = meta.size; this._size = size;
this._mime = meta.mime || 'application/octet-stream';
this._name = meta.name;
this._callback = callback; this._callback = callback;
} }
@ -631,13 +799,7 @@ class FileDigester {
if (this._bytesReceived < this._size) return; if (this._bytesReceived < this._size) return;
// we are done // we are done
let blob = new Blob(this._buffer, { type: this._mime }); this._callback(new Blob(this._buffer));
this._callback({
name: this._name,
mime: this._mime,
size: this._size,
blob: blob
});
} }
} }

View file

@ -1,9 +1,9 @@
const $ = query => document.getElementById(query); const $ = query => document.getElementById(query);
const $$ = query => document.body.querySelector(query); const $$ = query => document.body.querySelector(query);
const isURL = text => /^((https?:\/\/|www)[^\s]+)/g.test(text.toLowerCase()); const isURL = text => /^((https?:\/\/|www)[^\s]+)/g.test(text.toLowerCase());
window.isDownloadSupported = (typeof document.createElement('a').download !== 'undefined');
window.isProductionEnvironment = !window.location.host.startsWith('localhost'); window.isProductionEnvironment = !window.location.host.startsWith('localhost');
window.iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; window.iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
window.android = /android/i.test(navigator.userAgent);
window.pasteMode = {}; window.pasteMode = {};
window.pasteMode.activated = false; window.pasteMode.activated = false;
@ -23,11 +23,22 @@ class PeersUI {
Events.on('peer-connected', e => this._onPeerConnected(e.detail)); Events.on('peer-connected', e => this._onPeerConnected(e.detail));
Events.on('peer-disconnected', e => this._onPeerDisconnected(e.detail)); Events.on('peer-disconnected', e => this._onPeerDisconnected(e.detail));
Events.on('peers', e => this._onPeers(e.detail)); Events.on('peers', e => this._onPeers(e.detail));
Events.on('file-progress', e => this._onFileProgress(e.detail)); Events.on('set-progress', e => this._onSetProgress(e.detail));
Events.on('paste', e => this._onPaste(e)); Events.on('paste', e => this._onPaste(e));
Events.on('ws-disconnected', _ => this._clearPeers()); Events.on('ws-disconnected', _ => this._clearPeers());
Events.on('secret-room-deleted', _ => this._clearPeers('secret')); Events.on('secret-room-deleted', _ => this._clearPeers('secret'));
this.peers = {}; this.peers = {};
this.$cancelPasteModeBtn = document.getElementById('cancelPasteModeBtn');
this.$cancelPasteModeBtn.addEventListener('click', this._cancelPasteMode);
Events.on('keydown', e => this._onKeyDown(e));
}
_onKeyDown(e) {
if (window.pasteMode.activated && e.code === "Escape") {
Events.fire('deactivate-paste-mode');
}
} }
_onPeerJoined(msg) { _onPeerJoined(msg) {
@ -85,18 +96,16 @@ class PeersUI {
_onSecretRoomDeleted(roomSecret) { _onSecretRoomDeleted(roomSecret) {
for (const peerId in this.peers) { for (const peerId in this.peers) {
const peer = this.peers[peerId]; const peer = this.peers[peerId];
console.debug(peer);
if (peer.roomSecret === roomSecret) { if (peer.roomSecret === roomSecret) {
this._onPeerLeft(peerId); this._onPeerLeft(peerId);
} }
} }
} }
_onFileProgress(progress) { _onSetProgress(progress) {
const peerId = progress.sender || progress.recipient; const $peer = $(progress.peerId);
const $peer = $(peerId);
if (!$peer) return; if (!$peer) return;
$peer.ui.setProgress(progress.progress); $peer.ui.setProgress(progress.progress, progress.status)
} }
_clearPeers(roomType = 'all') { _clearPeers(roomType = 'all') {
@ -161,13 +170,9 @@ class PeersUI {
const _callback = (e) => this._sendClipboardData(e, files, text); const _callback = (e) => this._sendClipboardData(e, files, text);
Events.on('paste-pointerdown', _callback); Events.on('paste-pointerdown', _callback);
Events.on('deactivate-paste-mode', _ => this._deactivatePasteMode(_callback));
const _deactivateCallback = (e) => this._deactivatePasteMode(e, _callback) this.$cancelPasteModeBtn.removeAttribute('hidden');
const cancelPasteModeBtn = document.getElementById('cancelPasteModeBtn');
cancelPasteModeBtn.addEventListener('click', this._cancelPasteMode)
cancelPasteModeBtn.removeAttribute('hidden');
Events.on('notify-user', _deactivateCallback);
window.pasteMode.descriptor = descriptor; window.pasteMode.descriptor = descriptor;
window.pasteMode.activated = true; window.pasteMode.activated = true;
@ -179,10 +184,11 @@ class PeersUI {
_cancelPasteMode() { _cancelPasteMode() {
Events.fire('notify-user', 'Paste Mode canceled'); Events.fire('notify-user', 'Paste Mode canceled');
Events.fire('deactivate-paste-mode');
} }
_deactivatePasteMode(e, _callback) { _deactivatePasteMode(_callback) {
if (window.pasteMode.activated && ['File transfer completed.', 'Message transfer completed.', 'Paste Mode canceled'].includes(e.detail)) { if (window.pasteMode.activated) {
window.pasteMode.descriptor = undefined; window.pasteMode.descriptor = undefined;
window.pasteMode.activated = false; window.pasteMode.activated = false;
console.log('Paste mode deactivated.') console.log('Paste mode deactivated.')
@ -328,24 +334,23 @@ class PeerUI {
files: files, files: files,
to: this._peer.id to: this._peer.id
}); });
$input.value = null; // reset input $input.files = null; // reset input
} }
setProgress(progress) { setProgress(progress, status) {
if (progress > 0) { if (0.5 < progress && progress < 1) {
this.$el.setAttribute('transfer', '1');
}
if (progress > 0.5) {
this.$progress.classList.add('over50'); this.$progress.classList.add('over50');
} else { } else {
this.$progress.classList.remove('over50'); this.$progress.classList.remove('over50');
} }
if (progress < 1) {
this.$el.setAttribute('status', status);
} else {
this.$el.removeAttribute('status');
progress = 0;
}
const degrees = `rotate(${360 * progress}deg)`; const degrees = `rotate(${360 * progress}deg)`;
this.$progress.style.setProperty('--progress', degrees); this.$progress.style.setProperty('--progress', degrees);
if (progress >= 1) {
this.setProgress(0);
this.$el.removeAttribute('transfer');
}
} }
_onDrop(e) { _onDrop(e) {
@ -410,76 +415,12 @@ class Dialog {
} }
class ReceiveDialog extends Dialog { class ReceiveDialog extends Dialog {
constructor(id, hideOnDisconnect = true) {
super(id, hideOnDisconnect);
constructor() { this.$fileDescriptionNode = this.$el.querySelector('.file-description');
super('receiveDialog', false); this.$fileSizeNode = this.$el.querySelector('.file-size');
Events.on('file-received', e => { this.$previewBox = this.$el.querySelector('.file-preview')
this._nextFile(e.detail);
window.blop.play();
});
this._filesQueue = [];
this.$previewBox = this.$el.querySelector('.preview')
}
_nextFile(nextFile) {
if (nextFile) this._filesQueue.push(nextFile);
if (this._busy) return;
this._busy = true;
const file = this._filesQueue.shift();
this._displayFile(file);
}
_dequeueFile() {
if (!this._filesQueue.length) { // nothing to do
this._busy = false;
return;
}
// dequeue next file
setTimeout(_ => {
this._busy = false;
this._nextFile();
}, 300);
}
_displayFile(file) {
const $a = this.$el.querySelector('#download');
const url = URL.createObjectURL(file.blob);
$a.href = url;
$a.download = file.name;
if(this._autoDownload()){
$a.click()
return
}
let mime = file.mime.split('/')[0]
let previewElement = {
image: 'img',
audio: 'audio',
video: 'video'
}
if(Object.keys(previewElement).indexOf(mime) !== -1){
console.log('the file is able to preview');
let element = document.createElement(previewElement[mime]);
element.src = url;
element.controls = true;
element.classList = 'element-preview'
this.$previewBox.style.visibility = 'inherit';
this.$previewBox.appendChild(element)
}
this.$el.querySelector('#fileName').textContent = file.name;
this.$el.querySelector('#fileSize').textContent = this._formatFileSize(file.size);
this.show();
if (window.isDownloadSupported) return;
// fallback for iOS
$a.target = '_blank';
const reader = new FileReader();
reader.onload = _ => $a.href = reader.result;
reader.readAsDataURL(file.blob);
} }
_formatFileSize(bytes) { _formatFileSize(bytes) {
@ -493,17 +434,221 @@ class ReceiveDialog extends Dialog {
return bytes + ' Bytes'; return bytes + ' Bytes';
} }
} }
}
class ReceiveFileDialog extends ReceiveDialog {
constructor() {
super('receiveDialog', false);
this.$shareOrDownloadBtn = this.$el.querySelector('#shareOrDownload');
Events.on('files-received', e => this._onFilesReceived(e.detail.sender, e.detail.files));
this._filesQueue = [];
}
_onFilesReceived(sender, files) {
this._nextFiles(sender, files);
window.blop.play();
}
_nextFiles(sender, nextFiles) {
if (nextFiles) this._filesQueue.push({peerId: sender, files: nextFiles});
if (this._busy) return;
this._busy = true;
const {peerId, files} = this._filesQueue.shift();
this._displayFiles(peerId, files);
}
_dequeueFile() {
if (!this._filesQueue.length) { // nothing to do
this._busy = false;
return;
}
// dequeue next file
setTimeout(_ => {
this._busy = false;
this._nextFiles();
}, 300);
}
createPreviewElement(file) {
return new Promise((resolve) => {
let mime = file.type.split('/')[0]
let previewElement = {
image: 'img',
audio: 'audio',
video: 'video'
}
if (Object.keys(previewElement).indexOf(mime) === -1) {
resolve(false);
} else {
console.log('the file is able to preview');
let element = document.createElement(previewElement[mime]);
element.src = URL.createObjectURL(file);
element.controls = true;
element.classList = 'element-preview'
this.$previewBox.style.display = 'block';
this.$previewBox.appendChild(element)
element.onload = _ => resolve(true);
}
});
}
async _displayFiles(peerId, files) {
if (this.continueCallback) this.$shareOrDownloadBtn.removeEventListener("click", this.continueCallback);
let url;
let description;
let size;
let filename;
let shareTitle
if (files.length === 1) {
shareTitle = "PairDrop File"
description = files[0].name;
size = this._formatFileSize(files[0].size);
filename = files[0].name;
url = URL.createObjectURL(files[0])
} else {
shareTitle = "PairDrop Files";
let completeSize = 0
for (let i=0; i<files.length; i++) {
completeSize += files[0].size;
}
description = `${files[0].name} and ${files.length-1} more ${files.length>2 ? "files" : "file"}`;
size = this._formatFileSize(completeSize);
for (let i=0; i<files.length; i++) {
await zipper.addFile(files[i]);
}
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;
filename = `PairDrop_files_${year+month+date}_${hours+minutes}.zip`;
}
this.$fileDescriptionNode.textContent = description;
this.$fileSizeNode.textContent = size;
this.$shareOrDownloadBtn.download = filename;
if ((window.iOS || window.android) && !!navigator.share && navigator.canShare({files})) {
this.$shareOrDownloadBtn.innerText = "Share";
this.continueCallback = async _ => {
navigator.share({
title: shareTitle,
text: description,
files: files
}).catch(err => console.error(err));
}
this.$shareOrDownloadBtn.addEventListener("click", this.continueCallback);
} else {
this.$shareOrDownloadBtn.innerText = "Download";
this.$shareOrDownloadBtn.href = url;
}
this.createPreviewElement(files[0]).then(_ => {
this.show()
Events.fire('set-progress', {
peerId: peerId,
progress: 1,
status: 'wait'
})
this.$shareOrDownloadBtn.click();
});
}
hide() { hide() {
this.$previewBox.style.visibility = 'hidden'; this.$shareOrDownloadBtn.href = '';
this.$previewBox.style.display = 'none';
this.$previewBox.innerHTML = ''; this.$previewBox.innerHTML = '';
super.hide(); super.hide();
this._dequeueFile(); this._dequeueFile();
} }
}
class ReceiveRequestDialog extends ReceiveDialog {
_autoDownload(){ constructor() {
return !this.$el.querySelector('#autoDownload').checked super('receiveRequestDialog', true);
this.$acceptRequestBtn = this.$el.querySelector('#acceptRequest');
this.$declineRequestBtn = this.$el.querySelector('#declineRequest');
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('peer-left', e => this._onPeerDisconnectedOrLeft(e.detail))
Events.on('peer-disconnected', e => this._onPeerDisconnectedOrLeft(e.detail))
Events.on('keydown', e => this._onKeyDown(e));
}
_onKeyDown(e) {
if (this.$el.attributes["show"] && e.code === "Escape") {
this._respondToFileTransferRequest(false)
this.hide();
}
}
_onPeerDisconnectedOrLeft(peerId) {
if (peerId === this.requestingPeerId) {
this._respondToFileTransferRequest(false)
this.hide();
}
}
_onRequestFileTransfer(request, peerId) {
this.requestingPeerId = peerId;
this.requestedHeader = request.header;
const peer = $(peerId);
let peerDisplayName = peer.ui._displayName();
let fileDesc = request.header.length === 1
? "a file"
: `${request.header.length} files`
this.$fileDescriptionNode.innerText = `${peerDisplayName} would like to share ${fileDesc}`;
this.$fileSizeNode.innerText = this._formatFileSize(request.size);
if (request.thumbnailDataUrl) {
let element = document.createElement('img');
element.src = request.thumbnailDataUrl;
element.classList = 'element-preview'
this.$previewBox.style.display = 'block';
this.$previewBox.appendChild(element)
}
this.show()
}
_respondToFileTransferRequest(accepted) {
Events.fire('respond-to-files-transfer-request', {
to: this.requestingPeerId,
header: this.requestedHeader,
accepted: accepted
})
this.requestingPeerId = null;
if (accepted) {
Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: 'wait'});
}
}
hide() {
this.$previewBox.style.display = 'none';
this.$previewBox.innerHTML = '';
super.hide();
} }
} }
@ -554,12 +699,14 @@ class PairDeviceDialog extends Dialog {
} }
_onKeyDown(e) { _onKeyDown(e) {
if (this.$el.attributes["show"] && e.code === "Escape") { if (this.$el.attributes["show"]) {
this.hide(); if (e.code === "Escape") {
this._pairDeviceCancel(); this.hide();
} this._pairDeviceCancel();
if (this.$el.attributes["show"] && e.code === "keyO") { }
this._onRoomSecretDelete() if (e.code === "keyO") {
this._onRoomSecretDelete()
}
} }
} }
@ -701,7 +848,6 @@ class PairDeviceDialog extends Dialog {
_onRoomSecretDelete(roomSecret) { _onRoomSecretDelete(roomSecret) {
PersistentStorage.deleteRoomSecret(roomSecret).then(_ => { PersistentStorage.deleteRoomSecret(roomSecret).then(_ => {
console.debug("then secret: " + roomSecret)
Events.fire('room-secret-deleted', roomSecret) Events.fire('room-secret-deleted', roomSecret)
this._evaluateNumberRoomSecrets(); this._evaluateNumberRoomSecrets();
}).catch((e) => console.error(e)); }).catch((e) => console.error(e));
@ -867,7 +1013,7 @@ class Notifications {
this.$button.addEventListener('click', _ => this._requestPermission()); this.$button.addEventListener('click', _ => this._requestPermission());
} }
Events.on('text-received', e => this._messageNotification(e.detail.text)); Events.on('text-received', e => this._messageNotification(e.detail.text));
Events.on('file-received', e => this._downloadNotification(e.detail.name)); Events.on('files-received', _ => this._downloadNotification());
} }
_requestPermission() { _requestPermission() {
@ -919,7 +1065,7 @@ class Notifications {
} }
} }
_downloadNotification(message) { _downloadNotification() {
if (document.visibilityState !== 'visible') { if (document.visibilityState !== 'visible') {
const notification = this._notify(message, 'Click to download'); const notification = this._notify(message, 'Click to download');
this._bind(notification, _ => this._download(notification)); this._bind(notification, _ => this._download(notification));
@ -927,7 +1073,7 @@ class Notifications {
} }
_download(notification) { _download(notification) {
document.querySelector('x-dialog [download]').click(); $('shareOrDownload').click();
notification.close(); notification.close();
} }
@ -1178,7 +1324,8 @@ class Pairdrop {
const server = new ServerConnection(); const server = new ServerConnection();
const peers = new PeersManager(server); const peers = new PeersManager(server);
const peersUI = new PeersUI(); const peersUI = new PeersUI();
const receiveDialog = new ReceiveDialog(); const receiveFileDialog = new ReceiveFileDialog();
const receiveRequestDialog = new ReceiveRequestDialog();
const sendTextDialog = new SendTextDialog(); const sendTextDialog = new SendTextDialog();
const receiveTextDialog = new ReceiveTextDialog(); const receiveTextDialog = new ReceiveTextDialog();
const pairDeviceDialog = new PairDeviceDialog(); const pairDeviceDialog = new PairDeviceDialog();

79
public/scripts/util.js Normal file
View file

@ -0,0 +1,79 @@
// Polyfill for Navigator.clipboard.writeText
if (!navigator.clipboard) {
navigator.clipboard = {
writeText: text => {
// A <span> contains the text to copy
const span = document.createElement('span');
span.textContent = text;
span.style.whiteSpace = 'pre'; // Preserve consecutive spaces and newlines
// Paint the span outside the viewport
span.style.position = 'absolute';
span.style.left = '-9999px';
span.style.top = '-9999px';
const win = window;
const selection = win.getSelection();
win.document.body.appendChild(span);
const range = win.document.createRange();
selection.removeAllRanges();
range.selectNode(span);
selection.addRange(range);
let success = false;
try {
success = win.document.execCommand('copy');
} catch (err) {
return Promise.error();
}
selection.removeAllRanges();
span.remove();
return Promise.resolve();
}
}
}
const zipper = (() => {
let zipWriter;
return {
addFile(file, options) {
if (!zipWriter) {
zipWriter = new zip.ZipWriter(new zip.BlobWriter("application/zip"), { bufferedWrite: true, level: 0 });
}
return zipWriter.add(file.name, new zip.BlobReader(file), options);
},
async getBlobURL() {
if (zipWriter) {
const blobURL = URL.createObjectURL(await zipWriter.close());
zipWriter = null;
return blobURL;
} else {
throw new Error("Zip file closed");
}
},
specifyOnProgress(onprogressCallback) {
zipWriter.onprogress = onprogressCallback;
},
async getZipFile(filename = "archive.zip") {
if (zipWriter) {
const file = new File([await zipWriter.close()], filename, {type: "application/zip"});
zipWriter = null;
return file;
} else {
throw new Error("Zip file closed");
}
},
async getEntries(file, options) {
return await (new zip.ZipReader(new zip.BlobReader(file))).getEntries(options);
},
async getData(entry, options) {
return await entry.getData(new zip.BlobWriter(), options);
},
};
})();

1
public/scripts/zip.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -1,11 +1,13 @@
var CACHE_NAME = 'pairdrop-cache-v2'; var CACHE_NAME = 'pairdrop-cache-v3';
var urlsToCache = [ var urlsToCache = [
'index.html', 'index.html',
'./', './',
'styles.css', 'styles.css',
'scripts/network.js', 'scripts/network.js',
'scripts/ui.js', 'scripts/ui.js',
'scripts/clipboard.js', 'scripts/util.js',
'scripts/qrcode.js',
'scripts/zip.min.js',
'scripts/theme.js', 'scripts/theme.js',
'sounds/blop.mp3', 'sounds/blop.mp3',
'images/favicon-96x96.png' 'images/favicon-96x96.png'

View file

@ -105,7 +105,7 @@ h2 {
font-weight: 400; font-weight: 400;
letter-spacing: -.012em; letter-spacing: -.012em;
line-height: 32px; line-height: 32px;
} color: var(--primary-color);}
h3 { h3 {
font-size: 20px; font-size: 20px;
@ -269,12 +269,12 @@ x-peer:not(.type-ip) x-icon {
background: #00a69c; background: #00a69c;
} }
x-peer:not([transfer]):hover x-icon, x-peer:not([status]):hover x-icon,
x-peer:not([transfer]):focus x-icon { x-peer:not([status]):focus x-icon {
transform: scale(1.05); transform: scale(1.05);
} }
x-peer[transfer] x-icon { x-peer[status] x-icon {
box-shadow: none; box-shadow: none;
opacity: 0.8; opacity: 0.8;
transform: scale(1); transform: scale(1);
@ -291,15 +291,27 @@ x-peer[transfer] x-icon {
white-space: nowrap; white-space: nowrap;
} }
x-peer[transfer] .status:before { x-peer[status=transfer] .status:before {
content: 'Transferring...'; content: 'Transferring...';
} }
x-peer:not([transfer]) .status, x-peer[status=prepare] .status:before {
x-peer[transfer] .device-name { content: 'Preparing...';
}
x-peer[status=wait] .status:before {
content: 'Waiting...';
}
x-peer:not([status]) .status,
x-peer[status] .device-name {
display: none; display: none;
} }
x-peer[status] {
pointer-events: none;
}
x-peer x-icon { x-peer x-icon {
animation: pop 600ms ease-out 1; animation: pop 600ms ease-out 1;
} }
@ -437,7 +449,7 @@ x-dialog .font-subheading {
height: 80px; height: 80px;
} }
#pairDeviceDialog>*>*>*>hr { #pairDeviceDialog hr {
margin-top: 40px; margin-top: 40px;
margin-bottom: 40px; margin-bottom: 40px;
} }