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:
parent
6707021e04
commit
5525caa766
8 changed files with 581 additions and 204 deletions
|
@ -131,17 +131,28 @@
|
|||
<x-dialog id="receiveDialog">
|
||||
<x-background class="full center">
|
||||
<x-paper shadow="2">
|
||||
<h3>File Received</h3>
|
||||
<div class="font-subheading" id="fileName">Filename</div>
|
||||
<div class="font-body2" id="fileSize"></div>
|
||||
<div class='preview' style="visibility: hidden;"></div>
|
||||
<div class="row">
|
||||
<label for="autoDownload" class="grow">Ask to save each file before downloading</label>
|
||||
<input type="checkbox" id="autoDownload" checked="">
|
||||
<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">
|
||||
<a class="button" id="shareOrDownload" autofocus></a>
|
||||
<button class="button" close>Close</button>
|
||||
</div>
|
||||
<div class="row-reverse">
|
||||
<a class="button" close id="download" title="Download File" autofocus>Save</a>
|
||||
<button class="button" close>Ignore</button>
|
||||
</x-paper>
|
||||
</x-background>
|
||||
</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>
|
||||
</x-paper>
|
||||
</x-background>
|
||||
|
@ -272,11 +283,12 @@
|
|||
</symbol>
|
||||
</svg>
|
||||
<!-- Scripts -->
|
||||
<script src="scripts/zip.min.js" async></script>
|
||||
<script src="scripts/util.js"></script>
|
||||
<script src="scripts/network.js"></script>
|
||||
<script src="scripts/qrcode.js" async></script>
|
||||
<script src="scripts/ui.js"></script>
|
||||
<script src="scripts/theme.js" async></script>
|
||||
<script src="scripts/clipboard.js" async></script>
|
||||
<!-- Sounds -->
|
||||
<audio id="blop" autobuffer="true">
|
||||
<source src="/sounds/blop.mp3" type="audio/mpeg">
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -17,6 +17,8 @@ class ServerConnection {
|
|||
Events.on('pair-device-initiate', _ => this._onPairDeviceInitiate());
|
||||
Events.on('pair-device-join', e => this._onPairDeviceJoin(e.detail));
|
||||
Events.on('pair-device-cancel', _ => this.send({ type: 'pair-device-cancel' }));
|
||||
Events.on('offline', _ => clearTimeout(this._reconnectTimer));
|
||||
Events.on('online', _ => this._connect());
|
||||
}
|
||||
|
||||
_connect() {
|
||||
|
@ -50,7 +52,7 @@ class ServerConnection {
|
|||
|
||||
_onPairDeviceJoin(roomKey) {
|
||||
if (!this._isConnected()) {
|
||||
setTimeout(_ => this._onPairDeviceJoin(roomKey), 200);
|
||||
setTimeout(_ => this._onPairDeviceJoin(roomKey), 1000);
|
||||
return;
|
||||
}
|
||||
this.send({ type: 'pair-device-join', roomKey: roomKey })
|
||||
|
@ -143,9 +145,9 @@ class ServerConnection {
|
|||
|
||||
_onDisconnect() {
|
||||
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);
|
||||
this._reconnectTimer = setTimeout(_ => this._connect(), 5000);
|
||||
this._reconnectTimer = setTimeout(_ => this._connect(), 1000);
|
||||
Events.fire('ws-disconnected');
|
||||
}
|
||||
|
||||
|
@ -187,10 +189,114 @@ class Peer {
|
|||
this._send(JSON.stringify(message));
|
||||
}
|
||||
|
||||
sendFiles(files) {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
this._filesQueue.push(files[i]);
|
||||
async createHeader(file) {
|
||||
let hashHex = await this.getHashHex(file);
|
||||
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;
|
||||
this._dequeueFile();
|
||||
}
|
||||
|
@ -202,14 +308,13 @@ class Peer {
|
|||
this._sendFile(file);
|
||||
}
|
||||
|
||||
_sendFile(file) {
|
||||
async _sendFile(file) {
|
||||
this.sendJSON({
|
||||
type: 'header',
|
||||
name: file.name,
|
||||
mime: file.type,
|
||||
size: file.size
|
||||
size: file.zipFile.size,
|
||||
fileHeader: file.fileHeader
|
||||
});
|
||||
this._chunker = new FileChunker(file,
|
||||
this._chunker = new FileChunker(file.zipFile,
|
||||
chunk => this._send(chunk),
|
||||
offset => this._onPartitionEnd(offset));
|
||||
this._chunker.nextPartition();
|
||||
|
@ -240,8 +345,11 @@ class Peer {
|
|||
message = JSON.parse(message);
|
||||
console.log('RTC:', message);
|
||||
switch (message.type) {
|
||||
case 'request':
|
||||
this._onFilesTransferRequest(message);
|
||||
break;
|
||||
case 'header':
|
||||
this._onFileHeader(message);
|
||||
this._onFilesHeader(message);
|
||||
break;
|
||||
case 'partition':
|
||||
this._onReceivedPartitionEnd(message);
|
||||
|
@ -252,6 +360,9 @@ class Peer {
|
|||
case 'progress':
|
||||
this._onDownloadProgress(message.progress);
|
||||
break;
|
||||
case 'files-transfer-response':
|
||||
this._onFileTransferResponded(message);
|
||||
break;
|
||||
case 'file-transfer-complete':
|
||||
this._onFileTransferCompleted();
|
||||
break;
|
||||
|
@ -264,17 +375,37 @@ class Peer {
|
|||
}
|
||||
}
|
||||
|
||||
_onFileHeader(header) {
|
||||
this._lastProgress = 0;
|
||||
this._digester = new FileDigester({
|
||||
name: header.name,
|
||||
mime: header.mime,
|
||||
size: header.size
|
||||
}, file => this._onFileReceived(file));
|
||||
_onFilesTransferRequest(request) {
|
||||
if (this._requestPending) {
|
||||
// Only accept one request at a time
|
||||
this.sendJSON({type: 'files-transfer-response', accepted: false});
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
if(!(chunk.byteLength || chunk.size)) return;
|
||||
if(!this._digester || !(chunk.byteLength || chunk.size)) return;
|
||||
|
||||
this._digester.unchunk(chunk);
|
||||
const progress = this._digester.progress;
|
||||
|
@ -287,24 +418,58 @@ class Peer {
|
|||
}
|
||||
|
||||
_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) {
|
||||
Events.fire('file-received', proxyFile);
|
||||
this.sendJSON({ type: 'file-transfer-complete' });
|
||||
async _onFileReceived(zipBlob, fileHeader) {
|
||||
Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: 'wait'});
|
||||
|
||||
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() {
|
||||
this._onDownloadProgress(1);
|
||||
this._reader = null;
|
||||
this._digester = null;
|
||||
this._busy = false;
|
||||
this._dequeueFile();
|
||||
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() {
|
||||
Events.fire('notify-user', 'Message transfer completed.');
|
||||
Events.fire('deactivate-paste-mode');
|
||||
}
|
||||
|
||||
sendText(text) {
|
||||
|
@ -477,6 +642,7 @@ class PeersManager {
|
|||
Events.on('signal', e => this._onMessage(e.detail));
|
||||
Events.on('peers', e => this._onPeers(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('peer-joined', e => this._onPeerJoined(e.detail));
|
||||
Events.on('peer-left', e => this._onPeerLeft(e.detail));
|
||||
|
@ -522,8 +688,12 @@ class PeersManager {
|
|||
this.peers[peerId].send(message);
|
||||
}
|
||||
|
||||
_onRespondToFileTransferRequest(detail) {
|
||||
this.peers[detail.to]._respondToFileTransferRequest(detail.header, detail.accepted);
|
||||
}
|
||||
|
||||
_onFilesSelected(message) {
|
||||
this.peers[message.to].sendFiles(message.files);
|
||||
this.peers[message.to].requestFileTransfer(message.files);
|
||||
}
|
||||
|
||||
_onSendText(message) {
|
||||
|
@ -614,12 +784,10 @@ class FileChunker {
|
|||
|
||||
class FileDigester {
|
||||
|
||||
constructor(meta, callback) {
|
||||
constructor(size, callback) {
|
||||
this._buffer = [];
|
||||
this._bytesReceived = 0;
|
||||
this._size = meta.size;
|
||||
this._mime = meta.mime || 'application/octet-stream';
|
||||
this._name = meta.name;
|
||||
this._size = size;
|
||||
this._callback = callback;
|
||||
}
|
||||
|
||||
|
@ -631,13 +799,7 @@ class FileDigester {
|
|||
|
||||
if (this._bytesReceived < this._size) return;
|
||||
// we are done
|
||||
let blob = new Blob(this._buffer, { type: this._mime });
|
||||
this._callback({
|
||||
name: this._name,
|
||||
mime: this._mime,
|
||||
size: this._size,
|
||||
blob: blob
|
||||
});
|
||||
this._callback(new Blob(this._buffer));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
const $ = query => document.getElementById(query);
|
||||
const $$ = query => document.body.querySelector(query);
|
||||
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.iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
|
||||
window.android = /android/i.test(navigator.userAgent);
|
||||
window.pasteMode = {};
|
||||
window.pasteMode.activated = false;
|
||||
|
||||
|
@ -23,11 +23,22 @@ class PeersUI {
|
|||
Events.on('peer-connected', e => this._onPeerConnected(e.detail));
|
||||
Events.on('peer-disconnected', e => this._onPeerDisconnected(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('ws-disconnected', _ => this._clearPeers());
|
||||
Events.on('secret-room-deleted', _ => this._clearPeers('secret'));
|
||||
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) {
|
||||
|
@ -85,18 +96,16 @@ class PeersUI {
|
|||
_onSecretRoomDeleted(roomSecret) {
|
||||
for (const peerId in this.peers) {
|
||||
const peer = this.peers[peerId];
|
||||
console.debug(peer);
|
||||
if (peer.roomSecret === roomSecret) {
|
||||
this._onPeerLeft(peerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_onFileProgress(progress) {
|
||||
const peerId = progress.sender || progress.recipient;
|
||||
const $peer = $(peerId);
|
||||
_onSetProgress(progress) {
|
||||
const $peer = $(progress.peerId);
|
||||
if (!$peer) return;
|
||||
$peer.ui.setProgress(progress.progress);
|
||||
$peer.ui.setProgress(progress.progress, progress.status)
|
||||
}
|
||||
|
||||
_clearPeers(roomType = 'all') {
|
||||
|
@ -161,13 +170,9 @@ class PeersUI {
|
|||
|
||||
const _callback = (e) => this._sendClipboardData(e, files, text);
|
||||
Events.on('paste-pointerdown', _callback);
|
||||
Events.on('deactivate-paste-mode', _ => this._deactivatePasteMode(_callback));
|
||||
|
||||
const _deactivateCallback = (e) => this._deactivatePasteMode(e, _callback)
|
||||
const cancelPasteModeBtn = document.getElementById('cancelPasteModeBtn');
|
||||
cancelPasteModeBtn.addEventListener('click', this._cancelPasteMode)
|
||||
cancelPasteModeBtn.removeAttribute('hidden');
|
||||
|
||||
Events.on('notify-user', _deactivateCallback);
|
||||
this.$cancelPasteModeBtn.removeAttribute('hidden');
|
||||
|
||||
window.pasteMode.descriptor = descriptor;
|
||||
window.pasteMode.activated = true;
|
||||
|
@ -179,10 +184,11 @@ class PeersUI {
|
|||
|
||||
_cancelPasteMode() {
|
||||
Events.fire('notify-user', 'Paste Mode canceled');
|
||||
Events.fire('deactivate-paste-mode');
|
||||
}
|
||||
|
||||
_deactivatePasteMode(e, _callback) {
|
||||
if (window.pasteMode.activated && ['File transfer completed.', 'Message transfer completed.', 'Paste Mode canceled'].includes(e.detail)) {
|
||||
_deactivatePasteMode(_callback) {
|
||||
if (window.pasteMode.activated) {
|
||||
window.pasteMode.descriptor = undefined;
|
||||
window.pasteMode.activated = false;
|
||||
console.log('Paste mode deactivated.')
|
||||
|
@ -328,24 +334,23 @@ class PeerUI {
|
|||
files: files,
|
||||
to: this._peer.id
|
||||
});
|
||||
$input.value = null; // reset input
|
||||
$input.files = null; // reset input
|
||||
}
|
||||
|
||||
setProgress(progress) {
|
||||
if (progress > 0) {
|
||||
this.$el.setAttribute('transfer', '1');
|
||||
}
|
||||
if (progress > 0.5) {
|
||||
setProgress(progress, status) {
|
||||
if (0.5 < progress && progress < 1) {
|
||||
this.$progress.classList.add('over50');
|
||||
} else {
|
||||
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)`;
|
||||
this.$progress.style.setProperty('--progress', degrees);
|
||||
if (progress >= 1) {
|
||||
this.setProgress(0);
|
||||
this.$el.removeAttribute('transfer');
|
||||
}
|
||||
}
|
||||
|
||||
_onDrop(e) {
|
||||
|
@ -410,76 +415,12 @@ class Dialog {
|
|||
}
|
||||
|
||||
class ReceiveDialog extends Dialog {
|
||||
constructor(id, hideOnDisconnect = true) {
|
||||
super(id, hideOnDisconnect);
|
||||
|
||||
constructor() {
|
||||
super('receiveDialog', false);
|
||||
Events.on('file-received', e => {
|
||||
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);
|
||||
this.$fileDescriptionNode = this.$el.querySelector('.file-description');
|
||||
this.$fileSizeNode = this.$el.querySelector('.file-size');
|
||||
this.$previewBox = this.$el.querySelector('.file-preview')
|
||||
}
|
||||
|
||||
_formatFileSize(bytes) {
|
||||
|
@ -493,17 +434,221 @@ class ReceiveDialog extends Dialog {
|
|||
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() {
|
||||
this.$previewBox.style.visibility = 'hidden';
|
||||
this.$shareOrDownloadBtn.href = '';
|
||||
this.$previewBox.style.display = 'none';
|
||||
this.$previewBox.innerHTML = '';
|
||||
super.hide();
|
||||
this._dequeueFile();
|
||||
}
|
||||
}
|
||||
|
||||
class ReceiveRequestDialog extends ReceiveDialog {
|
||||
|
||||
_autoDownload(){
|
||||
return !this.$el.querySelector('#autoDownload').checked
|
||||
constructor() {
|
||||
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) {
|
||||
if (this.$el.attributes["show"] && e.code === "Escape") {
|
||||
this.hide();
|
||||
this._pairDeviceCancel();
|
||||
}
|
||||
if (this.$el.attributes["show"] && e.code === "keyO") {
|
||||
this._onRoomSecretDelete()
|
||||
if (this.$el.attributes["show"]) {
|
||||
if (e.code === "Escape") {
|
||||
this.hide();
|
||||
this._pairDeviceCancel();
|
||||
}
|
||||
if (e.code === "keyO") {
|
||||
this._onRoomSecretDelete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -701,7 +848,6 @@ class PairDeviceDialog extends Dialog {
|
|||
|
||||
_onRoomSecretDelete(roomSecret) {
|
||||
PersistentStorage.deleteRoomSecret(roomSecret).then(_ => {
|
||||
console.debug("then secret: " + roomSecret)
|
||||
Events.fire('room-secret-deleted', roomSecret)
|
||||
this._evaluateNumberRoomSecrets();
|
||||
}).catch((e) => console.error(e));
|
||||
|
@ -867,7 +1013,7 @@ class Notifications {
|
|||
this.$button.addEventListener('click', _ => this._requestPermission());
|
||||
}
|
||||
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() {
|
||||
|
@ -919,7 +1065,7 @@ class Notifications {
|
|||
}
|
||||
}
|
||||
|
||||
_downloadNotification(message) {
|
||||
_downloadNotification() {
|
||||
if (document.visibilityState !== 'visible') {
|
||||
const notification = this._notify(message, 'Click to download');
|
||||
this._bind(notification, _ => this._download(notification));
|
||||
|
@ -927,7 +1073,7 @@ class Notifications {
|
|||
}
|
||||
|
||||
_download(notification) {
|
||||
document.querySelector('x-dialog [download]').click();
|
||||
$('shareOrDownload').click();
|
||||
notification.close();
|
||||
}
|
||||
|
||||
|
@ -1178,7 +1324,8 @@ class Pairdrop {
|
|||
const server = new ServerConnection();
|
||||
const peers = new PeersManager(server);
|
||||
const peersUI = new PeersUI();
|
||||
const receiveDialog = new ReceiveDialog();
|
||||
const receiveFileDialog = new ReceiveFileDialog();
|
||||
const receiveRequestDialog = new ReceiveRequestDialog();
|
||||
const sendTextDialog = new SendTextDialog();
|
||||
const receiveTextDialog = new ReceiveTextDialog();
|
||||
const pairDeviceDialog = new PairDeviceDialog();
|
||||
|
|
79
public/scripts/util.js
Normal file
79
public/scripts/util.js
Normal 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
1
public/scripts/zip.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
@ -1,11 +1,13 @@
|
|||
var CACHE_NAME = 'pairdrop-cache-v2';
|
||||
var CACHE_NAME = 'pairdrop-cache-v3';
|
||||
var urlsToCache = [
|
||||
'index.html',
|
||||
'./',
|
||||
'styles.css',
|
||||
'scripts/network.js',
|
||||
'scripts/ui.js',
|
||||
'scripts/clipboard.js',
|
||||
'scripts/util.js',
|
||||
'scripts/qrcode.js',
|
||||
'scripts/zip.min.js',
|
||||
'scripts/theme.js',
|
||||
'sounds/blop.mp3',
|
||||
'images/favicon-96x96.png'
|
||||
|
|
|
@ -105,7 +105,7 @@ h2 {
|
|||
font-weight: 400;
|
||||
letter-spacing: -.012em;
|
||||
line-height: 32px;
|
||||
}
|
||||
color: var(--primary-color);}
|
||||
|
||||
h3 {
|
||||
font-size: 20px;
|
||||
|
@ -269,12 +269,12 @@ x-peer:not(.type-ip) x-icon {
|
|||
background: #00a69c;
|
||||
}
|
||||
|
||||
x-peer:not([transfer]):hover x-icon,
|
||||
x-peer:not([transfer]):focus x-icon {
|
||||
x-peer:not([status]):hover x-icon,
|
||||
x-peer:not([status]):focus x-icon {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
x-peer[transfer] x-icon {
|
||||
x-peer[status] x-icon {
|
||||
box-shadow: none;
|
||||
opacity: 0.8;
|
||||
transform: scale(1);
|
||||
|
@ -291,15 +291,27 @@ x-peer[transfer] x-icon {
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
x-peer[transfer] .status:before {
|
||||
x-peer[status=transfer] .status:before {
|
||||
content: 'Transferring...';
|
||||
}
|
||||
|
||||
x-peer:not([transfer]) .status,
|
||||
x-peer[transfer] .device-name {
|
||||
x-peer[status=prepare] .status:before {
|
||||
content: 'Preparing...';
|
||||
}
|
||||
|
||||
x-peer[status=wait] .status:before {
|
||||
content: 'Waiting...';
|
||||
}
|
||||
|
||||
x-peer:not([status]) .status,
|
||||
x-peer[status] .device-name {
|
||||
display: none;
|
||||
}
|
||||
|
||||
x-peer[status] {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
x-peer x-icon {
|
||||
animation: pop 600ms ease-out 1;
|
||||
}
|
||||
|
@ -437,7 +449,7 @@ x-dialog .font-subheading {
|
|||
height: 80px;
|
||||
}
|
||||
|
||||
#pairDeviceDialog>*>*>*>hr {
|
||||
#pairDeviceDialog hr {
|
||||
margin-top: 40px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue