diff --git a/README.md b/README.md index 0602ff0..fc8ce2f 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,12 @@ ## Features [PairDrop](https://pairdrop.net) is a sublime alternative to AirDrop that works on all platforms. -Send images, documents or text via peer to peer connection to devices in the same local network/Wi-Fi or to paired devices. -As it is web based, it runs on all devices. +- File Sharing on your local network + - Send images, documents or text via peer to peer connection to devices on the same local network. +- Internet Transfers + - Join temporary public rooms to transfer files easily over the internet! +- Web-Application + - As it is web based, it runs on all devices. You want to quickly send a file from your phone to your laptop?
You want to share photos in original quality with friends that use a mixture of Android and iOS? @@ -32,14 +36,29 @@ You want to quickly send a file from your phone to your laptop? Developed based on [Snapdrop](https://github.com/RobinLinus/snapdrop) ## Differences to Snapdrop +
Click to expand -### Device Pairing / Internet Transfer -* Pair devices via 6-digit code or QR-Code -* Pair devices outside your local network or in complex network environment (public Wi-Fi, company network, Apple Private Relay, VPN etc.). +### Paired Devices and Public Rooms - Internet Transfer +* Transfer files over the internet between paired devices or by entering temporary public rooms. +* Connect to devices in complex network environments (public Wi-Fi, company network, Apple Private Relay, VPN etc.). * Connect to devices on your mobile hotspot. -* Paired devices will always find each other via shared secrets even after reopening the browser or the Progressive Web App -* You will always discover devices on your local network. Paired devices are shown additionally. -* Paired devices outside your local network that are behind a NAT are connected automatically via the PairDrop TURN server. +* Devices outside your local network that are behind a NAT are connected automatically via the PairDrop TURN server. +* Connect to devices on your mobile hotspot. +* You will always discover devices on your local network. Paired devices and devices in the same public room are shown additionally. + +#### Persistent Device Pairing +* Pair your devices via a 6-digit code or a QR-Code. +* Paired devices will always find each other via shared secrets independently of their local network. +* Paired devices are persistent. You find your devices even after reopening PairDrop. +* You can edit and unpair devices easily +* Ideal to always connect easily to your own devices + +#### Temporary Public Rooms +* Enter a public room via a 5-letter code or a QR-Code. +* Enter a public room to temporarily connect to devices outside your local network. +* All devices in the same public room see each other mutually. +* Public rooms are temporary. Public rooms are left as soon as PairDrop is closed. +* Ideal to connect easily to others in complex network situations or over the internet. ### [Improved UI for sending/receiving files](https://github.com/RobinLinus/snapdrop/issues/560) * Files are transferred only after a request is accepted first. On transfer completion files are downloaded automatically if possible. @@ -66,13 +85,12 @@ Developed based on [Snapdrop](https://github.com/RobinLinus/snapdrop) * Lots of stability fixes (Thanks [@MWY001](https://github.com/MWY001) [@skiby7](https://github.com/skiby7) and [@willstott101](https://github.com/willstott101)) * To host PairDrop on your local network (e.g. on Raspberry Pi): [All peers connected with private IPs are discoverable by each other](https://github.com/RobinLinus/snapdrop/pull/558) * When hosting PairDrop yourself you can [set your own STUN/TURN servers](/docs/host-your-own.md#specify-stunturn-servers) +* Built-in translations + +
## Screenshots -
- -![Pairdrop Preview](/docs/pairdrop_screenshot_mobile.gif) - -
+ ## PairDrop is built with the following awesome technologies: * Vanilla HTML5 / ES6 / CSS3 frontend @@ -82,23 +100,29 @@ Developed based on [Snapdrop](https://github.com/RobinLinus/snapdrop) * [IndexedDB API](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) * [zip.js](https://gildas-lormeau.github.io/zip.js/) * [cyrb53](https://github.com/bryc) super fast hash function +* [Weblate](https://weblate.org/) Web based localization tool Have any questions? Read our [FAQ](/docs/faq.md). You can [host your own instance with Docker](/docs/host-your-own.md). -## Support the Community -PairDrop is free and always will be. Still, we have to pay for the domain and the server. - -To contribute and support:
+## Support PairDrop Buy Me A Coffee +PairDrop is free and always will be. +Still, we have to pay for the domain and the server. + +To contribute and support, please use BuyMeACoffee via the button above. + Thanks a lot for supporting free and open software! -To support the original Snapdrop and its creator go to [his GitHub page](https://github.com/RobinLinus/snapdrop). +## Translate PairDrop + +Translation status + ## How to contribute diff --git a/index.js b/index.js index 029a955..1d08d7d 100644 --- a/index.js +++ b/index.js @@ -130,8 +130,10 @@ class PairDropServer { this._wss = new WebSocket.Server({ server }); this._wss.on('connection', (socket, request) => this._onConnection(new Peer(socket, request))); - this._rooms = {}; - this._roomSecrets = {}; + this._rooms = {}; // { roomId: peers[] } + this._roomSecrets = {}; // { pairKey: roomSecret } + + this._keepAliveTimers = {}; console.log('PairDrop is running on port', port); } @@ -139,7 +141,9 @@ class PairDropServer { _onConnection(peer) { peer.socket.on('message', message => this._onMessage(peer, message)); peer.socket.onerror = e => console.error(e); + this._keepAlive(peer); + this._send(peer, { type: 'rtc-config', config: rtcConfig @@ -170,10 +174,10 @@ class PairDropServer { this._onDisconnect(sender); break; case 'pong': - sender.lastBeat = Date.now(); + this._keepAliveTimers[sender.id].lastBeat = Date.now(); break; case 'join-ip-room': - this._joinRoom(sender); + this._joinIpRoom(sender); break; case 'room-secrets': this._onRoomSecrets(sender, message); @@ -192,9 +196,15 @@ class PairDropServer { break; case 'regenerate-room-secret': this._onRegenerateRoomSecret(sender, message); - break - case 'resend-peers': - this._notifyPeers(sender); + break; + case 'create-public-room': + this._onCreatePublicRoom(sender); + break; + case 'join-public-room': + this._onJoinPublicRoom(sender, message); + break; + case 'leave-public-room': + this._onLeavePublicRoom(sender); break; case 'signal': default: @@ -203,7 +213,9 @@ class PairDropServer { } _signalAndRelay(sender, message) { - const room = message.roomType === 'ip' ? sender.ip : message.roomSecret; + const room = message.roomType === 'ip' + ? sender.ip + : message.roomId; // relay message to recipient if (message.to && Peer.isValidUuid(message.to) && this._rooms[room]) { @@ -223,10 +235,16 @@ class PairDropServer { } _disconnect(sender) { - this._leaveRoom(sender, 'ip', '', true); + this._removePairKey(sender.pairKey); + sender.pairKey = null; + + this._cancelKeepAlive(sender); + delete this._keepAliveTimers[sender.id]; + + this._leaveIpRoom(sender, true); this._leaveAllSecretRooms(sender, true); - this._removeRoomKey(sender.roomKey); - sender.roomKey = null; + this._leavePublicRoom(sender, true); + sender.socket.terminate(); } @@ -255,7 +273,7 @@ class PairDropServer { for (const peerId in room) { const peer = room[peerId]; - this._leaveRoom(peer, 'secret', roomSecret); + this._leaveSecretRoom(peer, roomSecret, true); this._send(peer, { type: 'secret-room-deleted', @@ -266,34 +284,35 @@ class PairDropServer { _onPairDeviceInitiate(sender) { let roomSecret = randomizer.getRandomString(256); - let roomKey = this._createRoomKey(sender, roomSecret); - if (sender.roomKey) this._removeRoomKey(sender.roomKey); - sender.roomKey = roomKey; + let pairKey = this._createPairKey(sender, roomSecret); + + if (sender.pairKey) { + this._removePairKey(sender.pairKey); + } + sender.pairKey = pairKey; + this._send(sender, { type: 'pair-device-initiated', roomSecret: roomSecret, - roomKey: roomKey + pairKey: pairKey }); - this._joinRoom(sender, 'secret', roomSecret); + this._joinSecretRoom(sender, roomSecret); } _onPairDeviceJoin(sender, message) { - // rate limit implementation: max 10 attempts every 10s - if (sender.roomKeyRate >= 10) { - this._send(sender, { type: 'pair-device-join-key-rate-limit' }); + if (sender.rateLimitReached()) { + this._send(sender, { type: 'join-key-rate-limit' }); return; } - sender.roomKeyRate += 1; - setTimeout(_ => sender.roomKeyRate -= 1, 10000); - if (!this._roomSecrets[message.roomKey] || sender.id === this._roomSecrets[message.roomKey].creator.id) { + if (!this._roomSecrets[message.pairKey] || sender.id === this._roomSecrets[message.pairKey].creator.id) { this._send(sender, { type: 'pair-device-join-key-invalid' }); return; } - const roomSecret = this._roomSecrets[message.roomKey].roomSecret; - const creator = this._roomSecrets[message.roomKey].creator; - this._removeRoomKey(message.roomKey); + const roomSecret = this._roomSecrets[message.pairKey].roomSecret; + const creator = this._roomSecrets[message.pairKey].creator; + this._removePairKey(message.pairKey); this._send(sender, { type: 'pair-device-joined', roomSecret: roomSecret, @@ -304,22 +323,53 @@ class PairDropServer { roomSecret: roomSecret, peerId: sender.id }); - this._joinRoom(sender, 'secret', roomSecret); - this._removeRoomKey(sender.roomKey); + this._joinSecretRoom(sender, roomSecret); + this._removePairKey(sender.pairKey); } _onPairDeviceCancel(sender) { - const roomKey = sender.roomKey + const pairKey = sender.pairKey - if (!roomKey) return; + if (!pairKey) return; - this._removeRoomKey(roomKey); + this._removePairKey(pairKey); this._send(sender, { type: 'pair-device-canceled', - roomKey: roomKey, + pairKey: pairKey, }); } + _onCreatePublicRoom(sender) { + let publicRoomId = randomizer.getRandomString(5, true).toLowerCase(); + + this._send(sender, { + type: 'public-room-created', + roomId: publicRoomId + }); + + this._joinPublicRoom(sender, publicRoomId); + } + + _onJoinPublicRoom(sender, message) { + if (sender.rateLimitReached()) { + this._send(sender, { type: 'join-key-rate-limit' }); + return; + } + + if (!this._rooms[message.publicRoomId] && !message.createIfInvalid) { + this._send(sender, { type: 'public-room-id-invalid', publicRoomId: message.publicRoomId }); + return; + } + + this._leavePublicRoom(sender); + this._joinPublicRoom(sender, message.publicRoomId); + } + + _onLeavePublicRoom(sender) { + this._leavePublicRoom(sender, true); + this._send(sender, { type: 'public-room-left' }); + } + _onRegenerateRoomSecret(sender, message) { const oldRoomSecret = message.roomSecret; const newRoomSecret = randomizer.getRandomString(256); @@ -337,122 +387,158 @@ class PairDropServer { delete this._rooms[oldRoomSecret]; } - _createRoomKey(creator, roomSecret) { - let roomKey; + _createPairKey(creator, roomSecret) { + let pairKey; do { // get randomInt until keyRoom not occupied - roomKey = crypto.randomInt(1000000, 1999999).toString().substring(1); // include numbers with leading 0s - } while (roomKey in this._roomSecrets) + pairKey = crypto.randomInt(1000000, 1999999).toString().substring(1); // include numbers with leading 0s + } while (pairKey in this._roomSecrets) - this._roomSecrets[roomKey] = { + this._roomSecrets[pairKey] = { roomSecret: roomSecret, creator: creator } - return roomKey; + return pairKey; } - _removeRoomKey(roomKey) { + _removePairKey(roomKey) { if (roomKey in this._roomSecrets) { this._roomSecrets[roomKey].creator.roomKey = null delete this._roomSecrets[roomKey]; } } - _joinRoom(peer, roomType = 'ip', roomSecret = '') { - const room = roomType === 'ip' ? peer.ip : roomSecret; + _joinIpRoom(peer) { + this._joinRoom(peer, 'ip', peer.ip); + } - if (this._rooms[room] && this._rooms[room][peer.id]) { + _joinSecretRoom(peer, roomSecret) { + this._joinRoom(peer, 'secret', roomSecret); + + // add secret to peer + peer.addRoomSecret(roomSecret); + } + + _joinPublicRoom(peer, publicRoomId) { + // prevent joining of 2 public rooms simultaneously + this._leavePublicRoom(peer); + + this._joinRoom(peer, 'public-id', publicRoomId); + + peer.publicRoomId = publicRoomId; + } + + _joinRoom(peer, roomType, roomId) { + // roomType: 'ip', 'secret' or 'public-id' + if (this._rooms[roomId] && this._rooms[roomId][peer.id]) { // ensures that otherPeers never receive `peer-left` after `peer-joined` on reconnect. - this._leaveRoom(peer, roomType, roomSecret); + this._leaveRoom(peer, roomType, roomId); } // if room doesn't exist, create it - if (!this._rooms[room]) { - this._rooms[room] = {}; + if (!this._rooms[roomId]) { + this._rooms[roomId] = {}; } - this._notifyPeers(peer, roomType, roomSecret); + this._notifyPeers(peer, roomType, roomId); // add peer to room - this._rooms[room][peer.id] = peer; - // add secret to peer - if (roomType === 'secret') { - peer.addRoomSecret(roomSecret); - } + this._rooms[roomId][peer.id] = peer; } - _leaveRoom(peer, roomType = 'ip', roomSecret = '', disconnect = false) { - const room = roomType === 'ip' ? peer.ip : roomSecret; - if (!this._rooms[room] || !this._rooms[room][peer.id]) return; - this._cancelKeepAlive(this._rooms[room][peer.id]); + _leaveIpRoom(peer, disconnect = false) { + this._leaveRoom(peer, 'ip', peer.ip, disconnect); + } - // delete the peer - delete this._rooms[room][peer.id]; + _leaveSecretRoom(peer, roomSecret, disconnect = false) { + this._leaveRoom(peer, 'secret', roomSecret, disconnect) - //if room is empty, delete the room - if (!Object.keys(this._rooms[room]).length) { - delete this._rooms[room]; - } else { - // notify all other peers - for (const otherPeerId in this._rooms[room]) { - const otherPeer = this._rooms[room][otherPeerId]; - this._send(otherPeer, { - type: 'peer-left', - peerId: peer.id, - roomType: roomType, - roomSecret: roomSecret, - disconnect: disconnect - }); - } - } //remove secret from peer - if (roomType === 'secret') { - peer.removeRoomSecret(roomSecret); + peer.removeRoomSecret(roomSecret); + } + + _leavePublicRoom(peer, disconnect = false) { + if (!peer.publicRoomId) return; + + this._leaveRoom(peer, 'public-id', peer.publicRoomId, disconnect); + + peer.publicRoomId = null; + } + + _leaveRoom(peer, roomType, roomId, disconnect = false) { + if (!this._rooms[roomId] || !this._rooms[roomId][peer.id]) return; + + // remove peer from room + delete this._rooms[roomId][peer.id]; + + // delete room if empty and abort + if (!Object.keys(this._rooms[roomId]).length) { + delete this._rooms[roomId]; + return; + } + + // notify all other peers that remain in room that peer left + for (const otherPeerId in this._rooms[roomId]) { + const otherPeer = this._rooms[roomId][otherPeerId]; + + let msg = { + type: 'peer-left', + peerId: peer.id, + roomType: roomType, + roomId: roomId, + disconnect: disconnect + }; + + this._send(otherPeer, msg); } } - _notifyPeers(peer, roomType = 'ip', roomSecret = '') { - const room = roomType === 'ip' ? peer.ip : roomSecret; - if (!this._rooms[room]) return; + _notifyPeers(peer, roomType, roomId) { + if (!this._rooms[roomId]) return; - // notify all other peers - for (const otherPeerId in this._rooms[room]) { + // notify all other peers that peer joined + for (const otherPeerId in this._rooms[roomId]) { if (otherPeerId === peer.id) continue; - const otherPeer = this._rooms[room][otherPeerId]; - this._send(otherPeer, { + const otherPeer = this._rooms[roomId][otherPeerId]; + + let msg = { type: 'peer-joined', peer: peer.getInfo(), roomType: roomType, - roomSecret: roomSecret - }); + roomId: roomId + }; + + this._send(otherPeer, msg); } - // notify peer about the other peers + // notify peer about peers already in the room const otherPeers = []; - for (const otherPeerId in this._rooms[room]) { + for (const otherPeerId in this._rooms[roomId]) { if (otherPeerId === peer.id) continue; - otherPeers.push(this._rooms[room][otherPeerId].getInfo()); + otherPeers.push(this._rooms[roomId][otherPeerId].getInfo()); } - this._send(peer, { + let msg = { type: 'peers', peers: otherPeers, roomType: roomType, - roomSecret: roomSecret - }); + roomId: roomId + }; + + this._send(peer, msg); } _joinSecretRooms(peer, roomSecrets) { for (let i=0; i 2 * timeout) { + + if (Date.now() - this._keepAliveTimers[peer.id].lastBeat > 5 * timeout) { + // Disconnect peer if unresponsive for 10s this._disconnect(peer); return; } this._send(peer, { type: 'ping' }); - peer.timerId = setTimeout(() => this._keepAlive(peer), timeout); + this._keepAliveTimers[peer.id].timer = setTimeout(() => this._keepAlive(peer), timeout); } _cancelKeepAlive(peer) { - if (peer && peer.timerId) { - clearTimeout(peer.timerId); + if (this._keepAliveTimers[peer.id]?.timer) { + clearTimeout(this._keepAliveTimers[peer.id].timer); } } } @@ -506,13 +598,22 @@ class Peer { // set name this._setName(request); - // for keepalive - this.timerId = 0; - this.lastBeat = Date.now(); + this.requestRate = 0; this.roomSecrets = []; this.roomKey = null; - this.roomKeyRate = 0; + + this.publicRoomId = null; + } + + rateLimitReached() { + // rate limit implementation: max 10 attempts every 10s + if (this.requestRate >= 10) { + return true; + } + this.requestRate += 1; + setTimeout(_ => this.requestRate -= 1, 10000); + return false; } _setIP(request) { @@ -688,8 +789,15 @@ const hasher = (() => { })() const randomizer = (() => { + let charCodeLettersOnly = r => 65 <= r && r <= 90; + let charCodeAllPrintableChars = r => r === 45 || 47 <= r && r <= 57 || 64 <= r && r <= 90 || 97 <= r && r <= 122; + return { - getRandomString(length) { + getRandomString(length, lettersOnly = false) { + const charCodeCondition = lettersOnly + ? charCodeLettersOnly + : charCodeAllPrintableChars; + let string = ""; while (string.length < length) { let arr = new Uint16Array(length); @@ -700,7 +808,7 @@ const randomizer = (() => { }) arr = arr.filter(function (r) { /* strip non-printables: if we transform into desirable range we have a probability bias, so I suppose we better skip this character */ - return r === 45 || r >= 47 && r <= 57 || r >= 64 && r <= 90 || r >= 97 && r <= 122; + return charCodeCondition(r); }); string += String.fromCharCode.apply(String, arr); } diff --git a/public/index.html b/public/index.html index ab0c9a2..ced4bff 100644 --- a/public/index.html +++ b/public/index.html @@ -39,62 +39,76 @@
- + +
+ + + +
-
+
-
+
-
+
-
- -

Open PairDrop on other devices to send files

-
Pair devices to be discoverable on other networks
+ +

Open PairDrop on other devices to send files

+
Pair devices or enter a public room to be discoverable on other networks
- +

@@ -103,40 +117,89 @@ -
- You are known as: -
- - - -
-
- You can be discovered by everyone on this network - +
+
+ You are known as: +
+ + + +
+
+
+ You can be discovered: +
+
+ on this network + + +
+
+ + + + +
+

Select Language

+
+
+ + + + + + +
+
+ +
+
+
+
-

Pair Devices

-
-

000 000

-
Input this key on another device
or scan the QR-Code.
-
-
- - - - - - +
+

Pair Devices

-
Enter key from another device to continue.
-
- - +
+
+
+

000 000

+

+ Input this key on another device + or scan the QR-Code. +

+
+
+
+
+
+ OR +
+
+
+
+
+ + + + + + +
+

Enter key from another device here.

+
+
+
+ +
@@ -147,13 +210,70 @@ -

Edit Paired Devices

-
-
-

Activate auto-accept to automatically accept all files sent from that device.

+
+

Edit Paired Devices

-
+
+
+

+ + Activate + + auto-accept + + to automatically accept all files sent from that device. + +

+
+
+ +
+ + + + + + +
+ + +
+
+

Temporary Public Room

+
+
+
+
+
+

IOX9P

+

+ Input this room id on another device + or scan the QR-Code. +

+
+
+
+
+
+ OR +
+
+
+
+
+ + + + + +
+

Enter room id from another device to join.

+
+
+
+ +
@@ -163,24 +283,30 @@ -

-
-
- - would like to share +
+
+

-
- - +
+
+
+
+ + would like to share +
+
+ + +
+
+
+
-
-
-
-
- - +
+ +
@@ -189,24 +315,31 @@ -

-
-
- - has sent +
+
+

-
- - +
+
+
+
+ + has sent +
+
+ + +
+
+
+
-
-
-
- - - +
+ + +
@@ -216,16 +349,27 @@ -

Send Message

-
- Send a Message to - +
+
+

Send Message

+
-
-
-
- - +
+
+
+ Send a Message to + +
+
+
+
+
+
+
+
+
+ +
@@ -235,16 +379,23 @@ -

Message Received

-
- - has sent: +
+

Message Received

-
-
-
- - +
+
+ + has sent: +
+
+
+
+
+
+
+
+ +
@@ -253,20 +404,22 @@ - + - +
+ +
- +
- + @@ -280,24 +433,24 @@

PairDrop

v1.7.7
-
The easiest way to transfer files across devices
+
The easiest way to transfer files across devices
- + - + - + - + @@ -371,8 +524,18 @@ + + + + + + + + + + diff --git a/public/lang/de.json b/public/lang/de.json new file mode 100644 index 0000000..5a6bd0b --- /dev/null +++ b/public/lang/de.json @@ -0,0 +1,156 @@ +{ + "header": { + "about_title": "Über PairDrop", + "notification_title": "Benachrichtigungen aktivieren", + "about_aria-label": "Über PairDrop öffnen", + "install_title": "PairDrop installieren", + "pair-device_title": "Deine Geräte dauerhaft koppeln", + "edit-paired-devices_title": "Gekoppelte Geräte bearbeiten", + "theme-auto_title": "Systemstil verwenden", + "theme-dark_title": "Dunklen Stil verwenden", + "theme-light_title": "Hellen Stil verwenden", + "cancel-paste-mode": "Fertig", + "language-selector_title": "Sprache auswählen", + "join-public-room_title": "Öffentlichen Raum temporär betreten" + }, + "dialogs": { + "share": "Teilen", + "download": "Herunterladen", + "pair-devices-title": "Geräte dauerhaft koppeln", + "input-key-on-this-device": "Gebe diesen Schlüssel auf einem anderen Gerät ein", + "enter-key-from-another-device": "Gebe den Schlüssel von einem anderen Gerät hier ein.", + "pair": "Koppeln", + "cancel": "Abbrechen", + "edit-paired-devices-title": "Gekoppelte Geräte bearbeiten", + "paired-devices-wrapper_data-empty": "Keine gekoppelten Geräte.", + "close": "Schließen", + "accept": "Akzeptieren", + "decline": "Ablehnen", + "title-image": "Bild", + "title-file": "Datei", + "title-image-plural": "Bilder", + "title-file-plural": "Dateien", + "scan-qr-code": "oder scanne den QR-Code.", + "would-like-to-share": "möchte Folgendes teilen", + "send": "Senden", + "copy": "Kopieren", + "receive-text-title": "Textnachricht erhalten", + "file-other-description-image-plural": "und {{count}} andere Bilder", + "file-other-description-file-plural": "und {{count}} andere Dateien", + "auto-accept-instructions-1": "Aktiviere", + "auto-accept": "auto-accept", + "auto-accept-instructions-2": "um automatisch alle Dateien von diesem Gerät zu akzeptieren.", + "has-sent": "hat Folgendes gesendet:", + "send-message-title": "Textnachricht senden", + "send-message-to": "Sende eine Textnachricht an", + "base64-tap-to-paste": "Hier tippen, um {{type}} einzufügen", + "base64-paste-to-send": "Hier einfügen, um {{type}} zu versenden", + "base64-text": "Text", + "base64-files": "Dateien", + "base64-processing": "Bearbeitung läuft…", + "file-other-description-image": "und ein anderes Bild", + "file-other-description-file": "und eine andere Datei", + "receive-title": "{{descriptor}} erhalten", + "download-again": "Erneut herunterladen", + "system-language": "Systemsprache", + "language-selector-title": "Sprache auswählen", + "hr-or": "ODER", + "input-room-id-on-another-device": "Gebe diese Raum ID auf einem anderen Gerät ein", + "unpair": "Entkoppeln" + }, + "about": { + "tweet_title": "Über PairDrop twittern", + "faq_title": "Häufig gestellte Fragen", + "close-about_aria-label": "Schließe Über PairDrop", + "github_title": "PairDrop auf GitHub", + "buy-me-a-coffee_title": "Kauf mir einen Kaffee!", + "claim": "Der einfachste Weg Dateien zwischen Geräten zu teilen" + }, + "footer": { + "known-as": "Du wirst angezeigt als:", + "display-name_title": "Setze einen permanenten Gerätenamen", + "on-this-network": "in diesem Netzwerk", + "paired-devices": "für gekoppelte Geräte", + "traffic": "Datenverkehr wird", + "display-name_placeholder": "Lade…", + "routed": "durch den Server geleitet", + "webrtc": "wenn WebRTC nicht verfügbar ist.", + "display-name_data-placeholder": "Lade…", + "public-room-devices_title": "Du kannst von Geräten in diesem öffentlichen Raum unabhängig von deinem Netzwerk gefunden werden.", + "paired-devices_title": "Du kannst immer von gekoppelten Geräten gefunden werden, egal in welchem Netzwerk.", + "public-room-devices": "in Raum {{roomId}}", + "discovery": "Du bist sichtbar:", + "on-this-network_title": "Du kannst von jedem in diesem Netzwerk gefunden werden." + }, + "notifications": { + "link-received": "Link von {{name}} empfangen - Klicke um ihn zu öffnen", + "message-received": "Nachricht von {{name}} empfangen - Klicke um sie zu kopieren", + "click-to-download": "Klicken zum Download", + "copied-text": "Text in die Zwischenablage kopiert", + "connected": "Verbunden.", + "pairing-success": "Geräte gekoppelt.", + "display-name-random-again": "Anzeigename wird ab jetzt wieder zufällig generiert.", + "pairing-tabs-error": "Es können keine zwei Webbrowser Tabs gekoppelt werden.", + "pairing-not-persistent": "Gekoppelte Geräte sind nicht persistent.", + "pairing-key-invalid": "Ungültiger Schlüssel", + "pairing-key-invalidated": "Schlüssel {{key}} wurde ungültig gemacht.", + "copied-to-clipboard": "In die Zwischenablage kopiert", + "text-content-incorrect": "Textinhalt ist fehlerhaft.", + "clipboard-content-incorrect": "Inhalt der Zwischenablage ist fehlerhaft.", + "copied-text-error": "Konnte nicht in die Zwischenablage schreiben. Kopiere manuell!", + "file-content-incorrect": "Dateiinhalt ist fehlerhaft.", + "notifications-enabled": "Benachrichtigungen aktiviert.", + "offline": "Du bist offline", + "online": "Du bist wieder Online", + "unfinished-transfers-warning": "Es wurden noch nicht alle Übertragungen fertiggestellt. Möchtest du PairDrop wirklich schließen?", + "display-name-changed-permanently": "Anzeigename wurde dauerhaft geändert.", + "download-successful": "{{descriptor}} heruntergeladen", + "pairing-cleared": "Alle Geräte entkoppelt.", + "click-to-show": "Klicken zum Anzeigen", + "online-requirement": "Du musst online sein um Geräte zu koppeln.", + "display-name-changed-temporarily": "Anzeigename wurde nur für diese Sitzung geändert.", + "request-title": "{{name}} möchte {{count}}{{descriptor}} übertragen", + "connecting": "Verbindung wird aufgebaut…", + "files-incorrect": "Dateien sind fehlerhaft.", + "file-transfer-completed": "Dateiübertragung fertiggestellt.", + "message-transfer-completed": "Nachrichtenübertragung fertiggestellt.", + "rate-limit-join-key": "Rate Limit erreicht. Warte 10 Sekunden und versuche es erneut.", + "selected-peer-left": "Ausgewählter Peer ist gegangen.", + "ios-memory-limit": "Für Übertragungen an iOS Geräte beträgt die maximale Dateigröße 200 MB", + "public-room-left": "Öffentlichen Raum {{publicRoomId}} verlassen", + "copied-to-clipboard-error": "Konnte nicht kopieren. Kopiere manuell.", + "public-room-id-invalid": "Ungültige Raum ID", + "online-requirement-pairing": "Du musst online sein, um Geräte zu koppeln.", + "online-requirement-public-room": "Du musst online sein, um öffentliche Räume erstellen zu können." + }, + "instructions": { + "x-instructions_desktop": "Klicke, um Dateien zu Senden oder klicke mit der rechten Maustaste, um Textnachrichten zu senden", + "no-peers-title": "Öffne PairDrop auf anderen Geräten, um Dateien zu senden", + "no-peers_data-drop-bg": "Hier ablegen, um Empfänger auszuwählen", + "no-peers-subtitle": "Kopple Geräte oder besuche einen öffentlichen Raum, damit du in anderen Netzwerken sichtbar bist", + "click-to-send": "Klicke zum Senden von", + "tap-to-send": "Tippe zum Senden von", + "x-instructions_data-drop-peer": "Hier ablegen, um an Peer zu senden", + "x-instructions_data-drop-bg": "Loslassen um Empfänger auszuwählen", + "x-instructions_mobile": "Tippe zum Senden von Dateien oder tippe lange zum Senden von Nachrichten", + "activate-paste-mode-base": "Öffne PairDrop auf anderen Geräten zum Senden von", + "activate-paste-mode-and-other-files": "und {{count}} anderen Dateien", + "activate-paste-mode-shared-text": "freigegebenem Text" + }, + "document-titles": { + "file-transfer-requested": "Datenübertagung angefordert", + "file-received": "Datei erhalten", + "file-received-plural": "{{count}} Dateien erhalten", + "message-received": "Nachricht erhalten", + "message-received-plural": "{{count}} Nachrichten erhalten" + }, + "peer-ui": { + "click-to-send": "Klicke um Dateien zu senden oder nutze einen Rechtsklick um eine Textnachricht zu senden", + "connection-hash": "Um die Ende-zu-Ende Verschlüsselung zu verifizieren, vergleiche die Sicherheitsnummer auf beiden Geräten", + "waiting": "Warte…", + "click-to-send-paste-mode": "Klicken um {{descriptor}} zu senden", + "transferring": "Übertragung läuft…", + "processing": "Bearbeitung läuft…", + "preparing": "Vorbereitung läuft…" + } +} diff --git a/public/lang/en.json b/public/lang/en.json new file mode 100644 index 0000000..70b49bd --- /dev/null +++ b/public/lang/en.json @@ -0,0 +1,154 @@ +{ + "header": { + "about_title": "About PairDrop", + "language-selector_title": "Select Language", + "about_aria-label": "Open About PairDrop", + "theme-auto_title": "Adapt Theme to System", + "theme-light_title": "Always Use Light-Theme", + "theme-dark_title": "Always Use Dark-Theme", + "notification_title": "Enable Notifications", + "install_title": "Install PairDrop", + "pair-device_title": "Pair Your Devices Permanently", + "edit-paired-devices_title": "Edit Paired Devices", + "join-public-room_title": "Join Public Room Temporarily", + "cancel-paste-mode": "Done" + }, + "instructions": { + "no-peers_data-drop-bg": "Release to select recipient", + "no-peers-title": "Open PairDrop on other devices to send files", + "no-peers-subtitle": "Pair devices or enter a public room to be discoverable on other networks", + "x-instructions_desktop": "Click to send files or right click to send a message", + "x-instructions_mobile": "Tap to send files or long tap to send a message", + "x-instructions_data-drop-peer": "Release to send to peer", + "x-instructions_data-drop-bg": "Release to select recipient", + "click-to-send": "Click to send", + "tap-to-send": "Tap to send", + "activate-paste-mode-base": "Open PairDrop on other devices to send", + "activate-paste-mode-and-other-files": "and {{count}} other files", + "activate-paste-mode-shared-text": "shared text" + }, + "footer": { + "known-as": "You are known as:", + "display-name_data-placeholder": "Loading…", + "display-name_title": "Edit your device name permanently", + "discovery": "You can be discovered:", + "on-this-network": "on this network", + "on-this-network_title": "You can be discovered by everyone on this network.", + "paired-devices": "by paired devices", + "paired-devices_title": "You can be discovered by paired devices at all times independent of the network.", + "public-room-devices": "in room {{roomId}}", + "public-room-devices_title": "You can be discovered by devices in this public room independent of the network.", + "traffic": "Traffic is", + "routed": "routed through the server", + "webrtc": "if WebRTC is not available." + }, + "dialogs": { + "pair-devices-title": "Pair Devices Permanently", + "input-key-on-this-device": "Input this key on another device", + "scan-qr-code": "or scan the QR-code.", + "enter-key-from-another-device": "Enter key from another device here.", + "input-room-id-on-another-device": "Input this room id on another device", + "hr-or": "OR", + "pair": "Pair", + "cancel": "Cancel", + "edit-paired-devices-title": "Edit Paired Devices", + "unpair": "Unpair", + "paired-devices-wrapper_data-empty": "No paired devices.", + "auto-accept-instructions-1": "Activate", + "auto-accept": "auto-accept", + "auto-accept-instructions-2": "to automatically accept all files sent from that device.", + "close": "Close", + "would-like-to-share": "would like to share", + "accept": "Accept", + "decline": "Decline", + "has-sent": "has sent:", + "share": "Share", + "download": "Download", + "send-message-title": "Send Message", + "send-message-to": "Send a Message to", + "send": "Send", + "receive-text-title": "Message Received", + "copy": "Copy", + "base64-processing": "Processing…", + "base64-tap-to-paste": "Tap here to paste {{type}}", + "base64-paste-to-send": "Paste here to send {{type}}", + "base64-text": "text", + "base64-files": "files", + "file-other-description-image": "and 1 other image", + "file-other-description-file": "and 1 other file", + "file-other-description-image-plural": "and {{count}} other images", + "file-other-description-file-plural": "and {{count}} other files", + "title-image": "Image", + "title-file": "File", + "title-image-plural": "Images", + "title-file-plural": "Files", + "receive-title": "{{descriptor}} Received", + "download-again": "Download again", + "language-selector-title": "Select Language", + "system-language": "System Language" + }, + "about": { + "close-about_aria-label": "Close About PairDrop", + "claim": "The easiest way to transfer files across devices", + "github_title": "PairDrop on GitHub", + "buy-me-a-coffee_title": "Buy me a coffee!", + "tweet_title": "Tweet about PairDrop", + "faq_title": "Frequently asked questions" + }, + "notifications": { + "display-name-changed-permanently": "Display name is changed permanently.", + "display-name-changed-temporarily": "Display name is changed only for this session.", + "display-name-random-again": "Display name is randomly generated again.", + "download-successful": "{{descriptor}} downloaded", + "pairing-tabs-error": "Pairing two web browser tabs is impossible.", + "pairing-success": "Devices paired.", + "pairing-not-persistent": "Paired devices are not persistent.", + "pairing-key-invalid": "Invalid key", + "pairing-key-invalidated": "Key {{key}} invalidated.", + "pairing-cleared": "All Devices unpaired.", + "public-room-id-invalid": "Invalid room id", + "public-room-left": "Left public room {{publicRoomId}}", + "copied-to-clipboard": "Copied to clipboard", + "copied-to-clipboard-error": "Copying not possible. Copy manually.", + "text-content-incorrect": "Text content is incorrect.", + "file-content-incorrect": "File content is incorrect.", + "clipboard-content-incorrect": "Clipboard content is incorrect.", + "notifications-enabled": "Notifications enabled.", + "link-received": "Link received by {{name}} - Click to open", + "message-received": "Message received by {{name}} - Click to copy", + "click-to-download": "Click to download", + "request-title": "{{name}} would like to transfer {{count}} {{descriptor}}", + "click-to-show": "Click to show", + "copied-text": "Copied text to clipboard", + "copied-text-error": "Writing to clipboard failed. Copy manually!", + "offline": "You are offline", + "online": "You are back online", + "connected": "Connected.", + "online-requirement-pairing": "You need to be online to pair devices.", + "online-requirement-public-room": "You need to be online to create a public room.", + "connecting": "Connecting…", + "files-incorrect": "Files are incorrect.", + "file-transfer-completed": "File transfer completed.", + "ios-memory-limit": "Sending files to iOS is only possible up to 200 MB at once", + "message-transfer-completed": "Message transfer completed.", + "unfinished-transfers-warning": "There are unfinished transfers. Are you sure you want to close PairDrop?", + "rate-limit-join-key": "Rate limit reached. Wait 10 seconds and try again.", + "selected-peer-left": "Selected peer left." + }, + "document-titles": { + "file-received": "File Received", + "file-received-plural": "{{count}} Files Received", + "file-transfer-requested": "File Transfer Requested", + "message-received": "Message Received", + "message-received-plural": "{{count}} Messages Received" + }, + "peer-ui": { + "click-to-send-paste-mode": "Click to send {{descriptor}}", + "click-to-send": "Click to send files or right click to send a message", + "connection-hash": "To verify the security of the end-to-end encryption, compare this security number on both devices", + "preparing": "Preparing…", + "waiting": "Waiting…", + "processing": "Processing…", + "transferring": "Transferring…" + } +} diff --git a/public/lang/nb.json b/public/lang/nb.json new file mode 100644 index 0000000..e63448f --- /dev/null +++ b/public/lang/nb.json @@ -0,0 +1,138 @@ +{ + "header": { + "edit-paired-devices_title": "Rediger sammenkoblede enheter", + "about_title": "Om PairDrop", + "about_aria-label": "Åpne «Om PairDrop»", + "theme-auto_title": "Juster drakt til system", + "theme-light_title": "Alltid bruk lys drakt", + "theme-dark_title": "Alltid bruk mørk drakt", + "notification_title": "Skru på merknader", + "cancel-paste-mode": "Ferdig", + "install_title": "Installer PairDrop", + "pair-device_title": "Sammenkoble enhet" + }, + "footer": { + "webrtc": "hvis WebRTC ikke er tilgjengelig.", + "display-name_data-placeholder": "Laster inn…", + "display-name_title": "Rediger det vedvarende enhetsnavnet ditt", + "traffic": "Trafikken", + "on-this-network": "på dette nettverket", + "known-as": "Du er kjent som:", + "paired-devices": "sammenkoblede enheter", + "routed": "Sendes gjennom tjeneren" + }, + "instructions": { + "x-instructions_desktop": "Klikk for å sende filer, eller høyreklikk for å sende en melding", + "x-instructions_mobile": "Trykk for å sende filer, eller lang-trykk for å sende en melding", + "x-instructions_data-drop-bg": "Slipp for å velge mottager", + "click-to-send": "Klikk for å sende", + "no-peers_data-drop-bg": "Slipp for å velge mottager", + "no-peers-title": "Åpne PairDrop på andre enheter for å sende filer", + "no-peers-subtitle": "Sammenkoble enheter for å kunne oppdages på andre nettverk", + "x-instructions_data-drop-peer": "Slipp for å sende til likemann", + "tap-to-send": "Trykk for å sende", + "activate-paste-mode-base": "Åpne PairDrop på andre enheter for å sende", + "activate-paste-mode-and-other-files": "og {{count}} andre filer", + "activate-paste-mode-shared-text": "delt tekst" + }, + "dialogs": { + "input-key-on-this-device": "Skriv inn denne nøkkelen på en annen enhet", + "pair-devices-title": "Sammenkoble enheter", + "would-like-to-share": "ønsker å dele", + "auto-accept-instructions-2": "for å godkjenne alle filer sendt fra den enheten automatisk.", + "paired-devices-wrapper_data-empty": "Ingen sammenkoblede enheter", + "enter-key-from-another-device": "Skriv inn nøkkel fra en annen enhet for å fortsette.", + "edit-paired-devices-title": "Rediger sammenkoblede enheter", + "accept": "Godta", + "has-sent": "har sendt:", + "base64-paste-to-send": "Trykk her for å sende {{type}}", + "base64-text": "tekst", + "base64-files": "filer", + "file-other-description-image-plural": "og {{count}} andre bilder", + "receive-title": "{{descriptor}} mottatt", + "send-message-title": "Send melding", + "base64-processing": "Behandler…", + "close": "Lukk", + "decline": "Avslå", + "download": "Last ned", + "copy": "Kopier", + "pair": "Sammenkoble", + "cancel": "Avbryt", + "scan-qr-code": "eller skann QR-koden.", + "auto-accept-instructions-1": "Aktiver", + "receive-text-title": "Melding mottatt", + "auto-accept": "auto-godkjenn", + "share": "Del", + "send-message-to": "Send en melding til", + "send": "Send", + "base64-tap-to-paste": "Trykk her for å lime inn {{type}}", + "file-other-description-image": "og ett annet bilde", + "file-other-description-file-plural": "og {{count}} andre filer", + "title-file-plural": "Filer", + "download-again": "Last ned igjen", + "file-other-description-file": "og én annen fil", + "title-image": "Bilde", + "title-file": "Fil", + "title-image-plural": "Bilder" + }, + "about": { + "close-about_aria-label": "Lukk «Om PairDrop»", + "faq_title": "Ofte stilte spørsmål", + "claim": "Den enkleste måten å overføre filer mellom enheter", + "buy-me-a-coffee_title": "Spander drikke.", + "tweet_title": "Tvitre om PairDrop", + "github_title": "PairDrop på GitHub" + }, + "notifications": { + "copied-to-clipboard": "Kopiert til utklippstavlen", + "pairing-tabs-error": "Sammenkobling av to nettleserfaner er ikke mulig.", + "notifications-enabled": "Merknader påskrudd.", + "click-to-show": "Klikk for å vise", + "copied-text": "Tekst kopiert til utklippstavlen", + "connected": "Tilkoblet.", + "online": "Du er tilbake på nett", + "file-transfer-completed": "Filoverføring utført.", + "selected-peer-left": "Valgt likemann dro.", + "pairing-key-invalid": "Ugyldig nøkkel", + "connecting": "Kobler til …", + "pairing-not-persistent": "Sammenkoblede enheter er ikke vedvarende.", + "offline": "Du er frakoblet", + "online-requirement": "Du må være på nett for å koble sammen enheter.", + "display-name-random-again": "Visningsnavnet er tilfeldig generert igjen.", + "display-name-changed-permanently": "Visningsnavnet er endret for godt.", + "display-name-changed-temporarily": "Visningsnavnet er endret kun for denne økten.", + "text-content-incorrect": "Tekstinnholdet er uriktig.", + "file-content-incorrect": "Filinnholdet er uriktig.", + "click-to-download": "Klikk for å laste ned", + "message-transfer-completed": "Meldingsoverføring utført.", + "download-successful": "{{descriptor}} nedlastet", + "pairing-success": "Enheter sammenkoblet.", + "pairing-cleared": "Sammenkobling av alle enheter opphevet.", + "pairing-key-invalidated": "Nøkkel {{key}} ugyldiggjort.", + "copied-text-error": "Kunne ikke legge innhold i utklkippstavlen. Kopier manuelt!", + "clipboard-content-incorrect": "Utklippstavleinnholdet er uriktig.", + "link-received": "Lenke mottatt av {{name}} - Klikk for å åpne.", + "request-title": "{{name}} ønsker å overføre {{count}} {{descriptor}}", + "message-received": "Melding mottatt av {{name}} - Klikk for å åpne.", + "files-incorrect": "Filene er uriktige.", + "ios-memory-limit": "Forsendelse av filer til iOS er kun mulig opptil 200 MB av gangen.", + "unfinished-transfers-warning": "Lukk med ufullførte overføringer?", + "rate-limit-join-key": "Forsøksgrense overskredet. Vent 10 sek. og prøv igjen." + }, + "document-titles": { + "file-received": "Fil mottatt", + "file-received-plural": "{{count}} filer mottatt", + "message-received": "Melding mottatt", + "file-transfer-requested": "Filoverføring forespurt", + "message-received-plural": "{{count}} meldinger mottatt" + }, + "peer-ui": { + "preparing": "Forbereder …", + "waiting": "Venter…", + "processing": "Behandler …", + "transferring": "Overfører …", + "click-to-send": "Klikk for å sende filer, eller høyreklikk for å sende en melding", + "click-to-send-paste-mode": "Klikk for å sende {{descriptor}}", + "connection-hash": "Sammenlign dette sikkerhetsnummeret på begge enhetene for å bekrefte ende-til-ende -krypteringen." + } +} diff --git a/public/lang/ru.json b/public/lang/ru.json new file mode 100644 index 0000000..a4476d3 --- /dev/null +++ b/public/lang/ru.json @@ -0,0 +1,156 @@ +{ + "header": { + "about_aria-label": "Открыть страницу \"О сервисе\"", + "pair-device_title": "Связать ваши устройства навсегда", + "install_title": "Установить PairDrop", + "cancel-paste-mode": "Выполнено", + "edit-paired-devices_title": "Редактировать сопряженные устройства", + "notification_title": "Включить уведомления", + "about_title": "О сервисе", + "theme-auto_title": "Адаптировать тему к системной", + "theme-dark_title": "Всегда использовать темную тему", + "theme-light_title": "Всегда использовать светлую тему", + "join-public-room_title": "Войти на время в публичную комнату", + "language-selector_title": "Выбрать язык" + }, + "instructions": { + "x-instructions_desktop": "Нажмите, чтобы отправить файлы, или щелкните правой кнопкой мыши, чтобы отправить сообщение", + "no-peers_data-drop-bg": "Отпустите, чтобы выбрать получателя", + "click-to-send": "Нажмите, чтобы отправить", + "x-instructions_data-drop-bg": "Отпустите, чтобы выбрать получателя", + "tap-to-send": "Прикоснитесь, чтобы отправить", + "x-instructions_data-drop-peer": "Отпустите, чтобы послать узлу", + "x-instructions_mobile": "Прикоснитесь коротко, чтобы отправить файлы, или долго, чтобы отправить сообщение", + "no-peers-title": "Откройте PairDrop на других устройствах, чтобы отправить файлы", + "no-peers-subtitle": "Сопрягите устройства или войдите в публичную комнату, чтобы вас могли обнаружить из других сетей", + "activate-paste-mode-and-other-files": "и {{count}} других файлов", + "activate-paste-mode-base": "Откройте PairDrop на других устройствах, чтобы отправить", + "activate-paste-mode-shared-text": "общий текст" + }, + "footer": { + "display-name_data-placeholder": "Загрузка…", + "routed": "направляется через сервер", + "webrtc": ", если WebRTC недоступен.", + "traffic": "Трафик", + "paired-devices": "сопряженными устройствами", + "known-as": "Вы известны под именем:", + "on-this-network": "в этой сети", + "display-name_title": "Изменить имя вашего устройства навсегда", + "public-room-devices_title": "Вы можете быть обнаружены устройствами в этой публичной комнате вне зависимости от сети.", + "paired-devices_title": "Вы можете быть обнаружены сопряженными устройствами в любое время вне зависимости от сети.", + "public-room-devices": "в комнате {{roomId}}", + "discovery": "Вы можете быть обнаружены:", + "on-this-network_title": "Вы можете быть обнаружены кем угодно в этой сети." + }, + "dialogs": { + "edit-paired-devices-title": "Редактировать сопряженные устройства", + "auto-accept": "автоприем", + "close": "Закрыть", + "decline": "Отклонить", + "share": "Поделиться", + "would-like-to-share": "хотел бы поделиться", + "has-sent": "отправил:", + "paired-devices-wrapper_data-empty": "Нет сопряженных устройств.", + "download": "Скачать", + "receive-text-title": "Сообщение получено", + "send": "Отправить", + "send-message-to": "Отправить сообщение", + "send-message-title": "Отправить сообщение", + "copy": "Копировать", + "base64-files": "файлы", + "base64-paste-to-send": "Вставьте здесь, чтобы отправить {{type}}", + "base64-processing": "Обработка…", + "base64-tap-to-paste": "Прикоснитесь здесь, чтобы вставить {{type}}", + "base64-text": "текст", + "title-file": "Файл", + "title-file-plural": "Файлы", + "title-image": "Изображение", + "title-image-plural": "Изображения", + "download-again": "Скачать еще раз", + "auto-accept-instructions-2": ", чтобы автоматически принимать все файлы, отправленные с того устройства.", + "enter-key-from-another-device": "Введите сюда ключ с другого устройства.", + "pair-devices-title": "Сопрягите устройства навсегда", + "input-key-on-this-device": "Введите этот ключ на другом устройстве", + "scan-qr-code": "или отсканируйте QR-код.", + "cancel": "Отменить", + "pair": "Подключить", + "accept": "Принять", + "auto-accept-instructions-1": "Активировать", + "file-other-description-file": "и 1 другой файл", + "file-other-description-image-plural": "и {{count}} других изображений", + "file-other-description-image": "и 1 другое изображение", + "file-other-description-file-plural": "и {{count}} других файлов", + "receive-title": "{{descriptor}} получен", + "system-language": "Язык системы", + "unpair": "Разорвать сопряжение", + "language-selector-title": "Выберите язык", + "hr-or": "ИЛИ", + "input-room-id-on-another-device": "Введите этот ID комнаты на другом устройстве" + }, + "about": { + "close-about-aria-label": "Закрыть страницу \"О сервисе\"", + "claim": "Самый простой способ передачи файлов между устройствами", + "close-about_aria-label": "Закрыть страницу \"О сервисе\"", + "buy-me-a-coffee_title": "Купить мне кофе!", + "github_title": "PairDrop на GitHub", + "tweet_title": "Твит о PairDrop", + "faq_title": "Часто задаваемые вопросы" + }, + "notifications": { + "display-name-changed-permanently": "Отображаемое имя было изменено навсегда.", + "display-name-random-again": "Отображаемое имя сгенерировалось случайным образом снова.", + "pairing-success": "Устройства сопряжены.", + "pairing-tabs-error": "Сопряжение двух вкладок браузера невозможно.", + "copied-to-clipboard": "Скопировано в буфер обмена", + "pairing-not-persistent": "Сопряженные устройства непостоянны.", + "link-received": "Получена ссылка от {{name}} - нажмите, чтобы открыть", + "notifications-enabled": "Уведомления включены.", + "text-content-incorrect": "Содержание текста неверно.", + "message-received": "Получено сообщение от {{name}} - нажмите, чтобы скопировать", + "connected": "Подключено.", + "copied-text": "Текст скопирован в буфер обмена", + "online": "Вы снова в сети", + "offline": "Вы находитесь вне сети", + "online-requirement": "Для сопряжения устройств вам нужно быть в сети.", + "files-incorrect": "Файлы неверны.", + "message-transfer-completed": "Передача сообщения завершена.", + "ios-memory-limit": "Отправка файлов на iOS устройства возможна только до 200 МБ за один раз", + "selected-peer-left": "Выбранный узел вышел.", + "request-title": "{{name}} хотел бы передать {{count}} {{descriptor}}", + "rate-limit-join-key": "Достигнут предел скорости. Подождите 10 секунд и повторите попытку.", + "unfinished-transfers-warning": "Есть незавершенные передачи. Вы уверены, что хотите закрыть PairDrop?", + "copied-text-error": "Запись в буфер обмена не удалась. Скопируйте вручную!", + "pairing-cleared": "Все устройства не сопряжены.", + "pairing-key-invalid": "Неверный ключ", + "pairing-key-invalidated": "Ключ {{key}} признан недействительным.", + "click-to-download": "Нажмите, чтобы скачать", + "clipboard-content-incorrect": "Содержание буфера обмена неверно.", + "click-to-show": "Нажмите, чтобы показать", + "connecting": "Подключение…", + "download-successful": "{{descriptor}} загружен", + "display-name-changed-temporarily": "Отображаемое имя было изменено только для этой сессии.", + "file-content-incorrect": "Содержимое файла неверно.", + "file-transfer-completed": "Передача файла завершена.", + "public-room-left": "Покинуть публичную комнату {{publicRoomId}}", + "copied-to-clipboard-error": "Копирование невозможно. Скопируйте вручную.", + "public-room-id-invalid": "Неверный ID комнаты", + "online-requirement-pairing": "Для сопряжения устройств необходимо находиться быть онлайн.", + "online-requirement-public-room": "Для создания публичной комнаты необходимо быть онлайн." + }, + "peer-ui": { + "click-to-send-paste-mode": "Нажмите, чтобы отправить {{descriptor}}", + "preparing": "Подготовка…", + "transferring": "Передача…", + "processing": "Обработка…", + "waiting": "Ожидание…", + "connection-hash": "Чтобы проверить безопасность сквозного шифрования, сравните этот номер безопасности на обоих устройствах", + "click-to-send": "Нажмите, чтобы отправить файлы, или щелкните правой кнопкой мыши, чтобы отправить сообщение" + }, + "document-titles": { + "file-received-plural": "{{count}} файлов получено", + "message-received-plural": "{{count}} сообщений получено", + "file-received": "Файл получен", + "file-transfer-requested": "Запрошена передача файлов", + "message-received": "Сообщение получено" + } +} diff --git a/public/lang/tr.json b/public/lang/tr.json new file mode 100644 index 0000000..87608f2 --- /dev/null +++ b/public/lang/tr.json @@ -0,0 +1,25 @@ +{ + "header": { + "about_title": "PairDrop Hakkında", + "about_aria-label": "PairDrop Hakkında Aç", + "theme-auto_title": "Temayı Sisteme Uyarla", + "theme-light_title": "Daima Açık Tema Kullan", + "theme-dark_title": "Daima Koyu Tema Kullan", + "notification_title": "Bildirimleri Etkinleştir", + "install_title": "PairDrop'u Yükle", + "pair-device_title": "Cihaz Eşleştir", + "edit-paired-devices_title": "Eşleştirilmiş Cihazları Düzenle", + "cancel-paste-mode": "Bitti" + }, + "instructions": { + "no-peers_data-drop-bg": "Alıcıyı seçmek için bırakın" + }, + "footer": { + "display-name_data-placeholder": "Yükleniyor…", + "display-name_title": "Cihazının adını kalıcı olarak düzenle" + }, + "dialogs": { + "cancel": "İptal", + "edit-paired-devices-title": "Eşleştirilmiş Cihazları Düzenle" + } +} diff --git a/public/lang/zh-CN.json b/public/lang/zh-CN.json new file mode 100644 index 0000000..c8b07af --- /dev/null +++ b/public/lang/zh-CN.json @@ -0,0 +1,155 @@ +{ + "header": { + "about_title": "关于 PairDrop", + "about_aria-label": "打开 关于 PairDrop", + "theme-light_title": "总是使用明亮主题", + "install_title": "安装 PairDrop", + "pair-device_title": "长久配对您的设备", + "theme-auto_title": "主题适应系统", + "theme-dark_title": "总是使用暗黑主题", + "notification_title": "开启通知", + "edit-paired-devices_title": "管理已配对设备", + "cancel-paste-mode": "完成", + "join-public-room_title": "暂时加入公共房间", + "language-selector_title": "选择语言" + }, + "instructions": { + "x-instructions_data-drop-peer": "释放以发送到此设备", + "no-peers_data-drop-bg": "释放来选择接收者", + "no-peers-subtitle": "配对新设备 或 加入一个公共房间 使在其他网络上可见", + "no-peers-title": "在其他设备上打开 PairDrop 来发送文件", + "x-instructions_desktop": "点击以发送文件 或 右键来发送信息", + "x-instructions_mobile": "轻触以发送文件 或 长按来发送信息", + "x-instructions_data-drop-bg": "释放来选择接收者", + "click-to-send": "点击发送", + "tap-to-send": "轻触发送", + "activate-paste-mode-base": "在其他设备上打开 PairDrop 来发送", + "activate-paste-mode-and-other-files": "和 {{count}} 个其他的文件", + "activate-paste-mode-shared-text": "分享文本" + }, + "footer": { + "routed": "途径服务器", + "webrtc": "如果 WebRTC 不可用。", + "known-as": "你的名字是:", + "display-name_data-placeholder": "加载中…", + "display-name_title": "修改你的默认设备名", + "on-this-network": "在此网络上", + "paired-devices": "已配对的设备", + "traffic": "流量将", + "public-room-devices_title": "您可以被这个独立于网络的公共房间中的设备发现。", + "paired-devices_title": "您可以在任何时候被已配对的设备发现,而不依赖于网络。", + "public-room-devices": "在房间 {{roomId}} 中", + "discovery": "您可以被发现:", + "on-this-network_title": "您可以被这个网络上的每个人发现。" + }, + "dialogs": { + "pair-devices-title": "配对新设备(常驻)", + "input-key-on-this-device": "在另一个设备上输入这串数字", + "base64-text": "信息", + "enter-key-from-another-device": "在此处输入从另一个设备上获得的数字。", + "edit-paired-devices-title": "管理已配对的设备", + "pair": "配对", + "cancel": "取消", + "scan-qr-code": "或者 扫描二维码。", + "paired-devices-wrapper_data-empty": "无已配对设备。", + "auto-accept-instructions-1": "启用", + "auto-accept": "自动接收", + "decline": "拒绝", + "base64-processing": "处理中…", + "base64-tap-to-paste": "轻触此处粘贴{{type}}", + "base64-paste-to-send": "粘贴到此处以发送 {{type}}", + "auto-accept-instructions-2": "以无需同意而自动接收从那个设备上发送的所有文件。", + "would-like-to-share": "想要分享", + "accept": "接收", + "close": "关闭", + "share": "分享", + "download": "保存", + "send": "发送", + "receive-text-title": "收到信息", + "copy": "复制", + "send-message-title": "发送信息", + "send-message-to": "发了一条信息给", + "has-sent": "发送了:", + "base64-files": "文件", + "file-other-description-file": "和 1 个其他的文件", + "file-other-description-image": "和 1 个其他的图片", + "file-other-description-image-plural": "和 {{count}} 个其他的图片", + "file-other-description-file-plural": "和 {{count}} 个其他的文件", + "title-image-plural": "图片", + "receive-title": "收到 {{descriptor}}", + "title-image": "图片", + "title-file": "文件", + "title-file-plural": "文件", + "download-again": "再次保存", + "system-language": "跟随系统语言", + "unpair": "取消配对", + "language-selector-title": "选择语言", + "hr-or": "或者", + "input-room-id-on-another-device": "在另一个设备上输入这串房间号" + }, + "about": { + "faq_title": "常见问题", + "close-about_aria-label": "关闭 关于 PairDrop", + "github_title": "PairDrop 在 GitHub 上开源", + "claim": "最简单的跨设备传输方案", + "buy-me-a-coffee_title": "帮我买杯咖啡!", + "tweet_title": "关于 PairDrop 的推特" + }, + "notifications": { + "display-name-changed-permanently": "展示的名字已经长久变更。", + "display-name-changed-temporarily": "展示的名字已经变更 仅在此会话中。", + "display-name-random-again": "展示的名字再次随机生成。", + "download-successful": "{{descriptor}} 已下载", + "pairing-tabs-error": "无法配对两个浏览器标签页。", + "pairing-success": "新设备已配对。", + "pairing-not-persistent": "配对的设备不是持久的。", + "pairing-key-invalid": "无效配对码", + "pairing-key-invalidated": "配对码 {{key}} 已失效。", + "text-content-incorrect": "文本内容不合法。", + "file-content-incorrect": "文件内容不合法。", + "clipboard-content-incorrect": "剪贴板内容不合法。", + "link-received": "收到来自 {{name}} 的链接 - 点击打开", + "message-received": "收到来自 {{name}} 的信息 - 点击复制", + "request-title": "{{name}} 想要发送 {{count}} 个 {{descriptor}}", + "click-to-show": "点击展示", + "copied-text": "复制到剪贴板", + "selected-peer-left": "选择的设备已离开。", + "pairing-cleared": "所有设备已解除配对。", + "copied-to-clipboard": "已复制到剪贴板", + "notifications-enabled": "通知已启用。", + "copied-text-error": "写入剪贴板失败。请手动复制!", + "click-to-download": "点击以保存", + "unfinished-transfers-warning": "还有未完成的传输任务。你确定要关闭 PairDrop 吗?", + "message-transfer-completed": "信息传输已完成。", + "offline": "你未连接到网络", + "online": "你已重新连接到网络", + "connected": "已连接。", + "online-requirement": "你需要连接网络来配对新设备。", + "files-incorrect": "文件不合法。", + "file-transfer-completed": "文件传输已完成。", + "connecting": "连接中…", + "ios-memory-limit": "向 iOS 发送文件 一次最多只能发送 200 MB", + "rate-limit-join-key": "已达连接限制。请等待 10秒 后再试。", + "public-room-left": "已退出公共房间 {{publicRoomId}}", + "copied-to-clipboard-error": "无法复制。请手动复制。", + "public-room-id-invalid": "无效的房间号", + "online-requirement-pairing": "您需要连接到互联网来配对新设备。", + "online-requirement-public-room": "您需要连接到互联网来创建一个公共房间。" + }, + "document-titles": { + "message-received": "收到信息", + "message-received-plural": "收到 {{count}} 条信息", + "file-transfer-requested": "文件传输请求", + "file-received-plural": "收到 {{count}} 个文件", + "file-received": "收到文件" + }, + "peer-ui": { + "click-to-send-paste-mode": "点击发送 {{descriptor}}", + "click-to-send": "点击以发送文件 或 右键来发送信息", + "connection-hash": "若要验证端到端加密的安全性,请在两个设备上比较此安全编号", + "preparing": "准备中…", + "waiting": "请等待…", + "transferring": "传输中…", + "processing": "处理中…" + } +} diff --git a/public/scripts/localization.js b/public/scripts/localization.js new file mode 100644 index 0000000..60d3a75 --- /dev/null +++ b/public/scripts/localization.js @@ -0,0 +1,144 @@ +class Localization { + constructor() { + Localization.defaultLocale = "en"; + Localization.supportedLocales = ["en", "nb", "ru", "zh-CN", "de"]; + Localization.translations = {}; + Localization.defaultTranslations = {}; + + Localization.systemLocale = Localization.supportedOrDefault(navigator.languages); + + let storedLanguageCode = localStorage.getItem("language-code"); + + Localization.initialLocale = storedLanguageCode && Localization.isSupported(storedLanguageCode) + ? storedLanguageCode + : Localization.systemLocale; + + Localization.setTranslation(Localization.initialLocale) + .then(_ => { + console.log("Initial translation successful."); + Events.fire("initial-translation-loaded"); + }); + } + + static isSupported(locale) { + return Localization.supportedLocales.indexOf(locale) > -1; + } + + static supportedOrDefault(locales) { + return locales.find(Localization.isSupported) || Localization.defaultLocale; + } + + static async setTranslation(locale) { + if (!locale) locale = Localization.systemLocale; + + await Localization.setLocale(locale) + await Localization.translatePage(); + + console.log("Page successfully translated", + `System language: ${Localization.systemLocale}`, + `Selected language: ${locale}` + ); + + Events.fire("translation-loaded"); + } + + static async setLocale(newLocale) { + if (newLocale === Localization.locale) return false; + + Localization.defaultTranslations = await Localization.fetchTranslationsFor(Localization.defaultLocale); + + const newTranslations = await Localization.fetchTranslationsFor(newLocale); + + if(!newTranslations) return false; + + Localization.locale = newLocale; + Localization.translations = newTranslations; + } + + static getLocale() { + return Localization.locale; + } + + static isSystemLocale() { + return !localStorage.getItem('language-code'); + } + + static async fetchTranslationsFor(newLocale) { + const response = await fetch(`lang/${newLocale}.json`) + + if (response.redirected === true || response.status !== 200) return false; + + return await response.json(); + } + + static async translatePage() { + document + .querySelectorAll("[data-i18n-key]") + .forEach(element => Localization.translateElement(element)); + } + + static async translateElement(element) { + const key = element.getAttribute("data-i18n-key"); + const attrs = element.getAttribute("data-i18n-attrs").split(" "); + + for (let i in attrs) { + let attr = attrs[i]; + if (attr === "text") { + element.innerText = Localization.getTranslation(key); + } else { + if (attr.startsWith("data-")) { + let dataAttr = attr.substring(5); + element.dataset.dataAttr = Localization.getTranslation(key, attr); + } { + element.setAttribute(attr, Localization.getTranslation(key, attr)); + } + } + } + } + + static getTranslation(key, attr=null, data={}, useDefault=false) { + const keys = key.split("."); + + let translationCandidates = useDefault + ? Localization.defaultTranslations + : Localization.translations; + + let translation; + + try { + for (let i = 0; i < keys.length - 1; i++) { + translationCandidates = translationCandidates[keys[i]] + } + + let lastKey = keys[keys.length - 1]; + + if (attr) lastKey += "_" + attr; + + translation = translationCandidates[lastKey]; + + for (let j in data) { + translation = translation.replace(`{{${j}}}`, data[j]); + } + } catch (e) { + translation = ""; + } + + if (!translation) { + if (!useDefault) { + translation = this.getTranslation(key, attr, data, true); + console.warn(`Missing translation entry for your language ${Localization.locale.toUpperCase()}. Using ${Localization.defaultLocale.toUpperCase()} instead.`, key, attr); + console.warn("Help translating PairDrop: https://hosted.weblate.org/projects/pairdrop/pairdrop-spa/"); + } else { + console.warn("Missing translation in default language:", key, attr); + } + } + + return Localization.escapeHTML(translation); + } + + static escapeHTML(unsafeText) { + let div = document.createElement('div'); + div.innerText = unsafeText; + return div.innerHTML; + } +} diff --git a/public/scripts/network.js b/public/scripts/network.js index 517aea3..7a197b6 100644 --- a/public/scripts/network.js +++ b/public/scripts/network.js @@ -23,10 +23,14 @@ class ServerConnection { Events.on('join-ip-room', e => this.send({ type: 'join-ip-room'})); Events.on('room-secrets-deleted', e => this.send({ type: 'room-secrets-deleted', roomSecrets: e.detail})); Events.on('regenerate-room-secret', e => this.send({ type: 'regenerate-room-secret', roomSecret: e.detail})); - Events.on('resend-peers', _ => this.send({ type: 'resend-peers'})); 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('create-public-room', _ => this._onCreatePublicRoom()); + Events.on('join-public-room', e => this._onJoinPublicRoom(e.detail.roomId, e.detail.createIfInvalid)); + Events.on('leave-public-room', _ => this._onLeavePublicRoom()); + Events.on('offline', _ => clearTimeout(this._reconnectTimer)); Events.on('online', _ => this._connect()); } @@ -46,23 +50,47 @@ class ServerConnection { _onOpen() { console.log('WS: server connected'); Events.fire('ws-connected'); - if (this._isReconnect) Events.fire('notify-user', 'Connected.'); + if (this._isReconnect) Events.fire('notify-user', Localization.getTranslation("notifications.connected")); } _onPairDeviceInitiate() { if (!this._isConnected()) { - Events.fire('notify-user', 'You need to be online to pair devices.'); + Events.fire('notify-user', Localization.getTranslation("notifications.online-requirement-pairing")); return; } - this.send({ type: 'pair-device-initiate' }) + this.send({ type: 'pair-device-initiate' }); } - _onPairDeviceJoin(roomKey) { + _onPairDeviceJoin(pairKey) { if (!this._isConnected()) { - setTimeout(_ => this._onPairDeviceJoin(roomKey), 1000); + setTimeout(_ => this._onPairDeviceJoin(pairKey), 1000); return; } - this.send({ type: 'pair-device-join', roomKey: roomKey }) + this.send({ type: 'pair-device-join', pairKey: pairKey }); + } + + _onCreatePublicRoom() { + if (!this._isConnected()) { + Events.fire('notify-user', Localization.getTranslation("notifications.online-requirement-public-room")); + return; + } + this.send({ type: 'create-public-room' }); + } + + _onJoinPublicRoom(roomId, createIfInvalid) { + if (!this._isConnected()) { + setTimeout(_ => this._onJoinPublicRoom(roomId), 1000); + return; + } + this.send({ type: 'join-public-room', publicRoomId: roomId, createIfInvalid: createIfInvalid }); + } + + _onLeavePublicRoom() { + if (!this._isConnected()) { + setTimeout(_ => this._onLeavePublicRoom(), 1000); + return; + } + this.send({ type: 'leave-public-room' }); } _setRtcConfig(config) { @@ -104,10 +132,10 @@ class ServerConnection { Events.fire('pair-device-join-key-invalid'); break; case 'pair-device-canceled': - Events.fire('pair-device-canceled', msg.roomKey); + Events.fire('pair-device-canceled', msg.pairKey); break; - case 'pair-device-join-key-rate-limit': - Events.fire('notify-user', 'Rate limit reached. Wait 10 seconds and try again.'); + case 'join-key-rate-limit': + Events.fire('notify-user', Localization.getTranslation("notifications.rate-limit-join-key")); break; case 'secret-room-deleted': Events.fire('secret-room-deleted', msg.roomSecret); @@ -115,6 +143,15 @@ class ServerConnection { case 'room-secret-regenerated': Events.fire('room-secret-regenerated', msg); break; + case 'public-room-id-invalid': + Events.fire('public-room-id-invalid', msg.publicRoomId); + break; + case 'public-room-created': + Events.fire('public-room-created', msg.roomId); + break; + case 'public-room-left': + Events.fire('public-room-left'); + break; default: console.error('WS receive: unknown message type', msg); } @@ -132,8 +169,8 @@ class ServerConnection { _onDisplayName(msg) { // Add peerId and peerIdHash to sessionStorage to authenticate as the same device on page reload - sessionStorage.setItem("peerId", msg.message.peerId); - sessionStorage.setItem("peerIdHash", msg.message.peerIdHash); + sessionStorage.setItem('peer_id', msg.message.peerId); + sessionStorage.setItem('peer_id_hash', msg.message.peerIdHash); // Add peerId to localStorage to mark it for other PairDrop tabs on the same browser BrowserTabsConnector.addPeerIdToLocalStorage().then(peerId => { @@ -155,8 +192,8 @@ class ServerConnection { const protocol = location.protocol.startsWith('https') ? 'wss' : 'ws'; const webrtc = window.isRtcSupported ? '/webrtc' : '/fallback'; let ws_url = new URL(protocol + '://' + location.host + location.pathname + 'server' + webrtc); - const peerId = sessionStorage.getItem("peerId"); - const peerIdHash = sessionStorage.getItem("peerIdHash"); + const peerId = sessionStorage.getItem('peer_id'); + const peerIdHash = sessionStorage.getItem('peer_id_hash'); if (peerId && peerIdHash) { ws_url.searchParams.append('peer_id', peerId); ws_url.searchParams.append('peer_id_hash', peerIdHash); @@ -167,7 +204,7 @@ class ServerConnection { _disconnect() { this.send({ type: 'disconnect' }); - const peerId = sessionStorage.getItem("peerId"); + const peerId = sessionStorage.getItem('peer_id'); BrowserTabsConnector.removePeerIdFromLocalStorage(peerId).then(_ => { console.log("successfully removed peerId from localStorage"); }); @@ -183,7 +220,7 @@ class ServerConnection { _onDisconnect() { console.log('WS: server disconnected'); - Events.fire('notify-user', 'Connecting..'); + Events.fire('notify-user', Localization.getTranslation("notifications.connecting")); clearTimeout(this._reconnectTimer); this._reconnectTimer = setTimeout(_ => this._connect(), 1000); Events.fire('ws-disconnected'); @@ -215,12 +252,13 @@ class ServerConnection { class Peer { - constructor(serverConnection, isCaller, peerId, roomType, roomSecret) { + constructor(serverConnection, isCaller, peerId, roomType, roomId) { this._server = serverConnection; this._isCaller = isCaller; this._peerId = peerId; - this._roomType = roomType; - this._updateRoomSecret(roomSecret); + + this._roomIds = {}; + this._updateRoomIds(roomType, roomId); this._filesQueue = []; this._busy = false; @@ -241,34 +279,58 @@ class Peer { return BrowserTabsConnector.peerIsSameBrowser(this._peerId); } - _updateRoomSecret(roomSecret) { + _isPaired() { + return !!this._roomIds['secret']; + } + + _getPairSecret() { + return this._roomIds['secret']; + } + + _getRoomTypes() { + return Object.keys(this._roomIds); + } + + _updateRoomIds(roomType, roomId) { // if peer is another browser tab, peer is not identifiable with roomSecret as browser tabs share all roomSecrets // -> do not delete duplicates and do not regenerate room secrets - if (!this._isSameBrowser() && this._roomSecret && this._roomSecret !== roomSecret) { - // remove old roomSecrets to prevent multiple pairings with same peer - PersistentStorage.deleteRoomSecret(this._roomSecret).then(deletedRoomSecret => { - if (deletedRoomSecret) console.log("Successfully deleted duplicate room secret with same peer: ", deletedRoomSecret); - }) + if (!this._isSameBrowser() && roomType === "secret" && this._isPaired() && this._getPairSecret() !== roomId) { + // multiple roomSecrets with same peer -> delete old roomSecret + PersistentStorage.deleteRoomSecret(this._getPairSecret()) + .then(deletedRoomSecret => { + if (deletedRoomSecret) console.log("Successfully deleted duplicate room secret with same peer: ", deletedRoomSecret); + }); } - this._roomSecret = roomSecret; + this._roomIds[roomType] = roomId; - if (!this._isSameBrowser() && this._roomSecret && this._roomSecret.length !== 256 && this._isCaller) { - // increase security by increasing roomSecret length + if (!this._isSameBrowser() && roomType === "secret" && this._isPaired() && this._getPairSecret().length !== 256 && this._isCaller) { + // increase security by initiating the increase of the roomSecret length from 64 chars ( { - const autoAccept = roomSecretEntry ? roomSecretEntry.entry.auto_accept : false; + const autoAccept = roomSecretEntry + ? roomSecretEntry.entry.auto_accept + : false; this._setAutoAccept(autoAccept); }) .catch(_ => { @@ -277,7 +339,9 @@ class Peer { } _setAutoAccept(autoAccept) { - this._autoAccept = autoAccept; + this._autoAccept = !this._isSameBrowser() + ? autoAccept + : false; } getResizedImageDataUrl(file, width = undefined, height = undefined, quality = 0.7) { @@ -488,7 +552,7 @@ class Peer { _abortTransfer() { Events.fire('set-progress', {peerId: this._peerId, progress: 1, status: 'wait'}); - Events.fire('notify-user', 'Files are incorrect.'); + Events.fire('notify-user', Localization.getTranslation("notifications.files-incorrect")); this._filesReceived = []; this._requestAccepted = null; this._digester = null; @@ -536,7 +600,7 @@ class Peer { if (!this._requestAccepted.header.length) { this._busy = false; Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: 'process'}); - Events.fire('files-received', {sender: this._peerId, files: this._filesReceived, imagesOnly: this._requestAccepted.imagesOnly, totalSize: this._requestAccepted.totalSize}); + Events.fire('files-received', {peerId: this._peerId, files: this._filesReceived, imagesOnly: this._requestAccepted.imagesOnly, totalSize: this._requestAccepted.totalSize}); this._filesReceived = []; this._requestAccepted = null; } @@ -546,7 +610,7 @@ class Peer { this._chunker = null; if (!this._filesQueue.length) { this._busy = false; - Events.fire('notify-user', 'File transfer completed.'); + Events.fire('notify-user', Localization.getTranslation("notifications.file-transfer-completed")); Events.fire('files-sent'); // used by 'Snapdrop & PairDrop for Android' app } else { this._dequeueFile(); @@ -558,7 +622,7 @@ class Peer { Events.fire('set-progress', {peerId: this._peerId, progress: 1, status: 'wait'}); this._filesRequested = null; if (message.reason === 'ios-memory-limit') { - Events.fire('notify-user', "Sending files to iOS is only possible up to 200MB at once"); + Events.fire('notify-user', Localization.getTranslation("notifications.ios-memory-limit")); } return; } @@ -568,7 +632,7 @@ class Peer { } _onMessageTransferCompleted() { - Events.fire('notify-user', 'Message transfer completed.'); + Events.fire('notify-user', Localization.getTranslation("notifications.message-transfer-completed")); } sendText(text) { @@ -599,8 +663,8 @@ class Peer { class RTCPeer extends Peer { - constructor(serverConnection, isCaller, peerId, roomType, roomSecret) { - super(serverConnection, isCaller, peerId, roomType, roomSecret); + constructor(serverConnection, isCaller, peerId, roomType, roomId) { + super(serverConnection, isCaller, peerId, roomType, roomId); this.rtcSupported = true; if (!this._isCaller) return; // we will listen for a caller this._connect(); @@ -626,13 +690,17 @@ class RTCPeer extends Peer { _openChannel() { if (!this._conn) return; + const channel = this._conn.createDataChannel('data-channel', { ordered: true, reliable: true // Obsolete. See https://developer.mozilla.org/en-US/docs/Web/API/RTCDataChannel/reliable }); channel.onopen = e => this._onChannelOpened(e); channel.onerror = e => this._onError(e); - this._conn.createOffer().then(d => this._onDescription(d)).catch(e => this._onError(e)); + + this._conn.createOffer() + .then(d => this._onDescription(d)) + .catch(e => this._onError(e)); } _onDescription(description) { @@ -713,7 +781,7 @@ class RTCPeer extends Peer { _onBeforeUnload(e) { if (this._busy) { e.preventDefault(); - return "There are unfinished transfers. Are you sure you want to close?"; + return Localization.getTranslation("notifications.unfinished-transfers-warning"); } } @@ -772,8 +840,8 @@ class RTCPeer extends Peer { _sendSignal(signal) { signal.type = 'signal'; signal.to = this._peerId; - signal.roomType = this._roomType; - signal.roomSecret = this._roomSecret; + signal.roomType = this._getRoomTypes()[0]; + signal.roomId = this._roomIds[this._getRoomTypes()[0]]; this._server.send(signal); } @@ -815,7 +883,14 @@ class PeersManager { Events.on('peer-joined', e => this._onPeerJoined(e.detail)); Events.on('peer-connected', e => this._onPeerConnected(e.detail.peerId)); Events.on('peer-disconnected', e => this._onPeerDisconnected(e.detail)); + + // this device closes connection + Events.on('room-secrets-deleted', e => this._onRoomSecretsDeleted(e.detail)); + Events.on('leave-public-room', e => this._onLeavePublicRoom(e.detail)); + + // peer closes connection Events.on('secret-room-deleted', e => this._onSecretRoomDeleted(e.detail)); + Events.on('room-secret-regenerated', e => this._onRoomSecretRegenerated(e.detail)); Events.on('display-name', e => this._onDisplayName(e.detail.message.displayName)); Events.on('self-display-name-changed', e => this._notifyPeersDisplayNameChanged(e.detail)); @@ -828,47 +903,43 @@ class PeersManager { this.peers[peerId].onServerMessage(message); } - _refreshPeer(peer, roomType, roomSecret) { + _refreshPeer(peer, roomType, roomId) { if (!peer) return false; - const roomTypeIsSecret = roomType === "secret"; - const roomSecretsDiffer = peer._roomSecret !== roomSecret; + const roomTypesDiffer = Object.keys(peer._roomIds)[0] !== roomType; + const roomIdsDiffer = peer._roomIds[roomType] !== roomId; - // if roomSecrets differs peer is already connected -> abort but update roomSecret and reevaluate auto accept - if (roomTypeIsSecret && roomSecretsDiffer) { - peer._updateRoomSecret(roomSecret); + // if roomType or roomId for roomType differs peer is already connected + // -> only update roomSecret and reevaluate auto accept + if (roomTypesDiffer || roomIdsDiffer) { + peer._updateRoomIds(roomType, roomId); peer._evaluateAutoAccept(); return true; } - const roomTypesDiffer = peer._roomType !== roomType; - - // if roomTypes differ peer is already connected -> abort - if (roomTypesDiffer) return true; - peer.refresh(); return true; } - _createOrRefreshPeer(isCaller, peerId, roomType, roomSecret) { + _createOrRefreshPeer(isCaller, peerId, roomType, roomId) { const peer = this.peers[peerId]; if (peer) { - this._refreshPeer(peer, roomType, roomSecret); + this._refreshPeer(peer, roomType, roomId); return; } - this.peers[peerId] = new RTCPeer(this._server, isCaller, peerId, roomType, roomSecret); + this.peers[peerId] = new RTCPeer(this._server, isCaller, peerId, roomType, roomId); } _onPeerJoined(message) { - this._createOrRefreshPeer(false, message.peer.id, message.roomType, message.roomSecret); + this._createOrRefreshPeer(false, message.peer.id, message.roomType, message.roomId); } _onPeers(message) { message.peers.forEach(peer => { - this._createOrRefreshPeer(true, peer.id, message.roomType, message.roomSecret); + this._createOrRefreshPeer(true, peer.id, message.roomType, message.roomId); }) } @@ -899,7 +970,7 @@ class PeersManager { _onPeerLeft(message) { if (message.disconnect === true) { // if user actively disconnected from PairDrop server, disconnect all peer to peer connections immediately - Events.fire('peer-disconnected', message.peerId); + this._disconnectOrRemoveRoomTypeByPeerId(message.peerId, message.roomType); // If no peers are connected anymore, we can safely assume that no other tab on the same browser is connected: // Tidy up peerIds in localStorage @@ -923,14 +994,42 @@ class PeersManager { if (peer._channel) peer._channel.onclose = null; peer._conn.close(); peer._busy = false; + peer._roomIds = {}; + } + + _onRoomSecretsDeleted(roomSecrets) { + for (let i=0; i 1) { + peer._removeRoomType(roomType); + } else { + Events.fire('peer-disconnected', peerId); } } @@ -961,20 +1060,26 @@ class PeersManager { } _onAutoAcceptUpdated(roomSecret, autoAccept) { - const peerId = this._getPeerIdFromRoomSecret(roomSecret); + const peerId = this._getPeerIdsFromRoomId(roomSecret)[0]; + if (!peerId) return; + this.peers[peerId]._setAutoAccept(autoAccept); } - _getPeerIdFromRoomSecret(roomSecret) { + _getPeerIdsFromRoomId(roomId) { + if (!roomId) return []; + + let peerIds = [] for (const peerId in this.peers) { const peer = this.peers[peerId]; - // peer must have same roomSecret and not be on the same browser. - if (peer._roomSecret === roomSecret && !peer._isSameBrowser()) { - return peer._peerId; + + // peer must have same roomId. + if (Object.values(peer._roomIds).includes(roomId)) { + peerIds.push(peer._peerId); } } - return false; + return peerIds; } } @@ -1060,7 +1165,7 @@ class FileDigester { } class Events { - static fire(type, detail) { + static fire(type, detail = {}) { window.dispatchEvent(new CustomEvent(type, { detail: detail })); } diff --git a/public/scripts/ui.js b/public/scripts/ui.js index b494f58..e253f36 100644 --- a/public/scripts/ui.js +++ b/public/scripts/ui.js @@ -1,8 +1,8 @@ const $ = query => document.getElementById(query); const $$ = query => document.body.querySelector(query); -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.isMobile = window.iOS || window.android; window.pasteMode = {}; window.pasteMode.activated = false; @@ -22,7 +22,7 @@ class PeersUI { Events.on('peers', e => this._onPeers(e.detail)); Events.on('set-progress', e => this._onSetProgress(e.detail)); Events.on('paste', e => this._onPaste(e)); - Events.on('secret-room-deleted', e => this._onSecretRoomDeleted(e.detail)); + Events.on('room-type-removed', e => this._onRoomTypeRemoved(e.detail.peerId, e.detail.roomType)); Events.on('activate-paste-mode', e => this._activatePasteMode(e.detail.files, e.detail.text)); this.peers = {}; @@ -45,6 +45,8 @@ class PeersUI { this.$displayName = $('display-name'); + this.$displayName.setAttribute("placeholder", this.$displayName.dataset.placeholder); + this.$displayName.addEventListener('keydown', e => this._onKeyDownDisplayName(e)); this.$displayName.addEventListener('keyup', e => this._onKeyUpDisplayName(e)); this.$displayName.addEventListener('blur', e => this._saveDisplayName(e.target.innerText)); @@ -89,12 +91,12 @@ class PeersUI { if (newDisplayName) { PersistentStorage.set('editedDisplayName', newDisplayName) .then(_ => { - Events.fire('notify-user', 'Device name is changed permanently.'); + Events.fire('notify-user', Localization.getTranslation("notifications.display-name-changed-permanently")); }) .catch(_ => { console.log("This browser does not support IndexedDB. Use localStorage instead."); localStorage.setItem('editedDisplayName', newDisplayName); - Events.fire('notify-user', 'Device name is changed only for this session.'); + Events.fire('notify-user', Localization.getTranslation("notifications.display-name-changed-temporarily")); }) .finally(_ => { Events.fire('self-display-name-changed', newDisplayName); @@ -105,10 +107,9 @@ class PeersUI { .catch(_ => { console.log("This browser does not support IndexedDB. Use localStorage instead.") localStorage.removeItem('editedDisplayName'); - Events.fire('notify-user', 'Random Display name is used again.'); }) .finally(_ => { - Events.fire('notify-user', 'Device name is randomly generated again.'); + Events.fire('notify-user', Localization.getTranslation("notifications.display-name-random-again")); Events.fire('self-display-name-changed', ''); Events.fire('broadcast-send', {type: 'self-display-name-changed', detail: ''}); }); @@ -149,32 +150,22 @@ class PeersUI { } _onPeerJoined(msg) { - this._joinPeer(msg.peer, msg.roomType, msg.roomSecret); + this._joinPeer(msg.peer, msg.roomType, msg.roomId); } - _joinPeer(peer, roomType, roomSecret) { + _joinPeer(peer, roomType, roomId) { const existingPeer = this.peers[peer.id]; if (existingPeer) { - // peer already exists. Abort but add roomType to GUI and update roomSecret - // skip if peer is a tab on the same browser - if (!existingPeer.sameBrowser()) { - // add roomType to PeerUI - if (!existingPeer.roomTypes.includes(roomType)) { - existingPeer.roomTypes.push(roomType); - } - this._redrawPeerRoomTypes(peer.id); - - if (roomType === "secret") existingPeer.roomSecret = roomSecret; - } + // peer already exists. Abort but add roomType to GUI + existingPeer._roomIds[roomType] = roomId; + this._redrawPeerRoomTypes(peer.id); return; } - peer.sameBrowser = _ => BrowserTabsConnector.peerIsSameBrowser(peer.id); - if (!(roomType === "secret" && peer.sameBrowser())) { - peer.roomTypes = [roomType]; - peer.roomSecret = roomSecret; - } + peer._isSameBrowser = _ => BrowserTabsConnector.peerIsSameBrowser(peer.id); + peer._roomIds = {}; + peer._roomIds[roomType] = roomId; this.peers[peer.id] = peer; } @@ -187,13 +178,18 @@ class PeersUI { } _redrawPeerRoomTypes(peerId) { - const peer = this.peers[peerId] + const peer = this.peers[peerId]; const peerNode = $(peerId); - if (!peerNode) return; - peerNode.classList.remove('type-ip', 'type-secret'); - if (!peer.sameBrowser()) { - peer.roomTypes.forEach(roomType => peerNode.classList.add(`type-${roomType}`)); + + if (!peer || !peerNode) return; + + peerNode.classList.remove('type-ip', 'type-secret', 'type-public-id', 'type-same-browser'); + + if (peer._isSameBrowser()) { + peerNode.classList.add(`type-same-browser`); } + + Object.keys(peer._roomIds).forEach(roomType => peerNode.classList.add(`type-${roomType}`)); } evaluateOverflowing() { @@ -205,7 +201,7 @@ class PeersUI { } _onPeers(msg) { - msg.peers.forEach(peer => this._joinPeer(peer, msg.roomType, msg.roomSecret)); + msg.peers.forEach(peer => this._joinPeer(peer, msg.roomType, msg.roomId)); } _onPeerDisconnected(peerId) { @@ -215,23 +211,14 @@ class PeersUI { this.evaluateOverflowing(); } - _onSecretRoomDeleted(roomSecret) { - for (const peerId in this.peers) { - const peer = this.peers[peerId]; - if (peer.roomSecret === roomSecret) { - let index = peer.roomTypes.indexOf('secret'); - peer.roomTypes.splice(index, 1); - peer.roomSecret = ""; + _onRoomTypeRemoved(peerId, roomType) { + const peer = this.peers[peerId]; - if (peer.roomTypes.length) { - this._redrawPeerRoomTypes(peerId) - return; - } + if (!peer) return; - this._onPeerDisconnected(peerId); - return; - } - } + delete peer._roomIds[roomType]; + + this._redrawPeerRoomTypes(peerId) } _onSetProgress(progress) { @@ -272,26 +259,28 @@ class PeersUI { _activatePasteMode(files, text) { if (!window.pasteMode.activated && (files.length > 0 || text.length > 0)) { + const openPairDrop = Localization.getTranslation("instructions.activate-paste-mode-base"); + const andOtherFiles = Localization.getTranslation("instructions.activate-paste-mode-and-other-files", null, {count: files.length-1}); + const sharedText = Localization.getTranslation("instructions.activate-paste-mode-shared-text"); + const clickToSend = Localization.getTranslation("instructions.click-to-send") + const tapToSend = Localization.getTranslation("instructions.tap-to-send") + let descriptor; - let noPeersMessage; if (files.length === 1) { - descriptor = files[0].name; - noPeersMessage = `Open PairDrop on other devices to send
${descriptor}`; + descriptor = `${files[0].name}`; } else if (files.length > 1) { - descriptor = `${files[0].name} and ${files.length-1} other files`; - noPeersMessage = `Open PairDrop on other devices to send
${descriptor}`; + descriptor = `${files[0].name}
${andOtherFiles}`; } else { - descriptor = "shared text"; - noPeersMessage = `Open PairDrop on other devices to send
${descriptor}`; + descriptor = sharedText; } this.$xInstructions.querySelector('p').innerHTML = `${descriptor}`; this.$xInstructions.querySelector('p').style.display = 'block'; - this.$xInstructions.setAttribute('desktop', `Click to send`); - this.$xInstructions.setAttribute('mobile', `Tap to send`); + this.$xInstructions.setAttribute('desktop', clickToSend); + this.$xInstructions.setAttribute('mobile', tapToSend); - this.$xNoPeers.querySelector('h2').innerHTML = noPeersMessage; + this.$xNoPeers.querySelector('h2').innerHTML = `${openPairDrop}
${descriptor}`; const _callback = (e) => this._sendClipboardData(e, files, text); Events.on('paste-pointerdown', _callback); @@ -320,10 +309,10 @@ class PeersUI { this.$xInstructions.querySelector('p').innerText = ''; this.$xInstructions.querySelector('p').style.display = 'none'; - this.$xInstructions.setAttribute('desktop', 'Click to send files or right click to send a message'); - this.$xInstructions.setAttribute('mobile', 'Tap to send files or long tap to send a message'); + this.$xInstructions.setAttribute('desktop', Localization.getTranslation("instructions.x-instructions", "desktop")); + this.$xInstructions.setAttribute('mobile', Localization.getTranslation("instructions.x-instructions", "mobile")); - this.$xNoPeers.querySelector('h2').innerHTML = 'Open PairDrop on other devices to send files'; + this.$xNoPeers.querySelector('h2').innerHTML = Localization.getTranslation("instructions.no-peers-title"); this.$cancelPasteModeBtn.setAttribute('hidden', ""); @@ -368,20 +357,22 @@ class PeerUI { let title; let input = ''; if (window.pasteMode.activated) { - title = `Click to send ${window.pasteMode.descriptor}`; + title = Localization.getTranslation("peer-ui.click-to-send-paste-mode", null, {descriptor: window.pasteMode.descriptor}); } else { - title = 'Click to send files or right click to send a message'; + title = Localization.getTranslation("peer-ui.click-to-send"); input = ''; } this.$el.innerHTML = ` -
-
` $pairedDevice.querySelector('input[type="checkbox"]').addEventListener('click', e => { @@ -1238,6 +1433,7 @@ class EditPairedDevicesDialog extends Dialog { }); }) + this.$pairedDevicesWrapper.html = ""; this.$pairedDevicesWrapper.appendChild($pairedDevice) }) @@ -1260,7 +1456,7 @@ class EditPairedDevicesDialog extends Dialog { PersistentStorage.clearRoomSecrets().finally(_ => { Events.fire('room-secrets-deleted', roomSecrets); Events.fire('evaluate-number-room-secrets'); - Events.fire('notify-user', 'All Devices unpaired.'); + Events.fire('notify-user', Localization.getTranslation("notifications.pairing-cleared")); this.hide(); }) }); @@ -1274,14 +1470,237 @@ class EditPairedDevicesDialog extends Dialog { const peer = peerNode.ui._peer; - if (!peer.roomSecret) return; + if (!peer || !peer._roomIds["secret"]) return; - PersistentStorage.updateRoomSecretNames(peer.roomSecret, peer.name.displayName, peer.name.deviceName).then(roomSecretEntry => { + PersistentStorage.updateRoomSecretNames(peer._roomIds["secret"], peer.name.displayName, peer.name.deviceName).then(roomSecretEntry => { console.log(`Successfully updated DisplayName and DeviceName for roomSecretEntry ${roomSecretEntry.key}`); }) } } +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.$footerInstructionsPublicRoomDevices = $$('.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.$footerInstructionsPublicRoomDevices.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", ""), + () => 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)); + 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.evaluateUrlAttributes(); + + Events.on('ws-connected', _ => this._onWsConnected()); + Events.on('translation-loaded', _ => this.setFooterBadge()); + } + + _onKeyDown(e) { + if (this.isShown() && 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.setIdAndQrCode(); + + this.show(); + + sessionStorage.setItem('public_room_id', roomId); + } + + setIdAndQrCode() { + 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: 150, + height: 150, + padding: 0, + background: "transparent", + color: `rgb(var(--text-color))`, + ecl: "L", + join: true + }); + this.$qrCode.innerHTML = qr.svg(); + + this.setFooterBadge(); + } + + setFooterBadge() { + if (!this.roomId) return; + + this.$footerInstructionsPublicRoomDevices.innerText = Localization.getTranslation("footer.public-room-devices", null, { + roomId: this.roomId.toUpperCase() + }); + this.$footerInstructionsPublicRoomDevices.removeAttribute('hidden'); + + super.evaluateFooterBadges(); + } + + _getShareRoomURL() { + let url = new URL(location.href); + url.searchParams.append('room_key', this.roomId) + return url.href; + } + + evaluateUrlAttributes() { + const urlParams = new URLSearchParams(window.location.search); + if (urlParams.has('room_key')) { + this._joinPublicRoom(urlParams.get('room_key')); + const url = getUrlWithoutArguments(); + window.history.replaceState({}, "Rewrite URL", url); //remove pair_key from url + } + } + + _onWsConnected() { + let roomId = sessionStorage.getItem('public_room_id'); + + if (!roomId) return; + + this.roomId = roomId; + this.setIdAndQrCode(); + + 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(message) { + this._evaluateJoinedPeer(message.peer.id, message.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.setIdAndQrCode(); + } + } + + _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.$footerInstructionsPublicRoomDevices.setAttribute('hidden', ''); + super.evaluateFooterBadges(); + } +} + class SendTextDialog extends Dialog { constructor() { super('send-text-dialog'); @@ -1292,7 +1711,7 @@ class SendTextDialog extends Dialog { this.$submit = this.$el.querySelector('button[type="submit"]'); this.$form.addEventListener('submit', e => this._onSubmit(e)); this.$text.addEventListener('input', e => this._onChange(e)); - Events.on("keydown", e => this._onKeyDown(e)); + Events.on('keydown', e => this._onKeyDown(e)); } async _onKeyDown(e) { @@ -1321,6 +1740,9 @@ class SendTextDialog extends Dialog { _onRecipient(peerId, deviceName) { this.correspondingPeerId = peerId; this.$peerDisplayName.innerText = deviceName; + this.$peerDisplayName.classList.remove("badge-room-ip", "badge-room-secret", "badge-room-public-id"); + this.$peerDisplayName.classList.add($(peerId).ui._badgeClassName()); + this.show(); const range = document.createRange(); @@ -1358,9 +1780,9 @@ class ReceiveTextDialog extends Dialog { this.$copy.addEventListener('click', _ => this._onCopy()); this.$close.addEventListener('click', _ => this.hide()); - Events.on("keydown", e => this._onKeyDown(e)); + Events.on('keydown', e => this._onKeyDown(e)); - this.$displayNameNode = this.$el.querySelector('.display-name'); + this.$displayName = this.$el.querySelector('.display-name'); this._receiveTextQueue = []; } @@ -1390,7 +1812,9 @@ class ReceiveTextDialog extends Dialog { } _showReceiveTextDialog(text, peerId) { - this.$displayNameNode.innerText = $(peerId).ui._displayName(); + this.$displayName.innerText = $(peerId).ui._displayName(); + this.$displayName.classList.remove("badge-room-ip", "badge-room-secret", "badge-room-public-id"); + this.$displayName.classList.add($(peerId).ui._badgeClassName()); this.$text.innerText = text; this.$text.classList.remove('text-center'); @@ -1401,10 +1825,6 @@ class ReceiveTextDialog extends Dialog { this.$text.innerHTML = this.$text.innerHTML.replace(/((https?:\/\/|www)[ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789\-._~:\/?#\[\]@!$&'()*+,;=]+)/g, url => { return `
${url}`; }); - - if (!/\s/.test(text)) { - this.$text.classList.add('text-center'); - } } this._setDocumentTitleMessages(); @@ -1415,15 +1835,20 @@ class ReceiveTextDialog extends Dialog { _setDocumentTitleMessages() { document.title = !this._receiveTextQueue.length - ? 'Message Received - PairDrop' - : `${this._receiveTextQueue.length + 1} Messages Received - PairDrop`; + ? `${ 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, ' '); - await navigator.clipboard.writeText(sanitizedText); - Events.fire('notify-user', 'Copied to clipboard'); - this.hide(); + 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() { @@ -1446,16 +1871,16 @@ class Base64ZipDialog extends Dialog { if (base64Text) { this.show(); - if (base64Text === "paste") { + if (base64Text === 'paste') { // ?base64text=paste // base64 encoded string is ready to be pasted from clipboard - this.preparePasting("text"); - } else if (base64Text === "hash") { + this.preparePasting('text'); + } else if (base64Text === 'hash') { // ?base64text=hash#BASE64ENCODED // base64 encoded string is url hash which is never sent to server and faster (recommended) this.processBase64Text(base64Hash) .catch(_ => { - Events.fire('notify-user', 'Text content is incorrect.'); + Events.fire('notify-user', Localization.getTranslation("notifications.text-content-incorrect")); console.log("Text content incorrect."); }).finally(_ => { this.hide(); @@ -1465,7 +1890,7 @@ class Base64ZipDialog extends Dialog { // base64 encoded string was part of url param (not recommended) this.processBase64Text(base64Text) .catch(_ => { - Events.fire('notify-user', 'Text content is incorrect.'); + Events.fire('notify-user', Localization.getTranslation("notifications.text-content-incorrect")); console.log("Text content incorrect."); }).finally(_ => { this.hide(); @@ -1478,7 +1903,7 @@ class Base64ZipDialog extends Dialog { // base64 encoded zip file is url hash which is never sent to the server this.processBase64Zip(base64Hash) .catch(_ => { - Events.fire('notify-user', 'File content is incorrect.'); + Events.fire('notify-user', Localization.getTranslation("notifications.file-content-incorrect")); console.log("File content incorrect."); }).finally(_ => { this.hide(); @@ -1492,18 +1917,22 @@ class Base64ZipDialog extends Dialog { _setPasteBtnToProcessing() { this.$pasteBtn.style.pointerEvents = "none"; - this.$pasteBtn.innerText = "Processing..."; + 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 = `Tap here to paste ${type}`; + 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', ''); - this.$fallbackTextarea.setAttribute('placeholder', `Paste here to send ${type}`); + 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()); @@ -1537,13 +1966,13 @@ class Base64ZipDialog extends Dialog { if (!base64 || !this.isValidBase64(base64)) return; this._setPasteBtnToProcessing(); try { - if (type === "text") { + if (type === 'text') { await this.processBase64Text(base64); } else { await this.processBase64Zip(base64); } } catch(_) { - Events.fire('notify-user', 'Clipboard content is incorrect.'); + Events.fire('notify-user', Localization.getTranslation("notifications.clipboard-content-incorrect")); console.log("Clipboard content is incorrect.") } this.hide(); @@ -1626,7 +2055,7 @@ class Notifications { Events.fire('notify-user', Notifications.PERMISSION_ERROR || 'Error'); return; } - Events.fire('notify-user', 'Notifications enabled.'); + Events.fire('notify-user', Localization.getTranslation("notifications.notifications-enabled")); this.$button.setAttribute('hidden', 1); }); } @@ -1661,10 +2090,10 @@ class Notifications { if (document.visibilityState !== 'visible') { const peerDisplayName = $(peerId).ui._displayName(); if (/^((https?:\/\/|www)[abcdefghijklmnopqrstuvwxyz0123456789\-._~:\/?#\[\]@!$&'()*+,;=]+)$/.test(message.toLowerCase())) { - const notification = this._notify(`Link received by ${peerDisplayName} - Click to open`, message); + const notification = this._notify(Localization.getTranslation("notifications.link-received", null, {name: peerDisplayName}), message); this._bind(notification, _ => window.open(message, '_blank', null, true)); } else { - const notification = this._notify(`Message received by ${peerDisplayName} - Click to copy`, message); + const notification = this._notify(Localization.getTranslation("notifications.message-received", null, {name: peerDisplayName}), message); this._bind(notification, _ => this._copyText(message, notification)); } } @@ -1679,13 +2108,23 @@ class Notifications { break; } } - let title = files[0].name; - if (files.length >= 2) { - title += ` and ${files.length - 1} other `; - title += imagesOnly ? 'image' : 'file'; - if (files.length > 2) title += "s"; + 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, 'Click to download'); + const notification = this._notify(title, Localization.getTranslation("notifications.click-to-download")); this._bind(notification, _ => this._download(notification)); } } @@ -1699,15 +2138,27 @@ class Notifications { break; } } - let descriptor; - if (request.header.length > 1) { - descriptor = imagesOnly ? ' images' : ' files'; - } else { - descriptor = imagesOnly ? ' image' : ' file'; - } + let displayName = $(peerId).querySelector('.name').textContent - let title = `${displayName} would like to transfer ${request.header.length} ${descriptor}`; - const notification = this._notify(title, 'Click to show'); + + 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")); } } @@ -1719,10 +2170,9 @@ class Notifications { _copyText(message, notification) { if (navigator.clipboard.writeText(message)) { notification.close(); - this._notify('Copied text to clipboard'); + this._notify(Localization.getTranslation("notifications.copied-text")); } else { - this._notify('Writing to clipboard failed. Copy manually!'); - + this._notify(Localization.getTranslation("notifications.copied-text-error")); } } @@ -1746,11 +2196,11 @@ class NetworkStatusUI { } _showOfflineMessage() { - Events.fire('notify-user', 'You are offline'); + Events.fire('notify-user', Localization.getTranslation("notifications.offline")); } _showOnlineMessage() { - Events.fire('notify-user', 'You are back online'); + Events.fire('notify-user', Localization.getTranslation("notifications.online")); } } @@ -2166,58 +2616,60 @@ class BrowserTabsConnector { } static peerIsSameBrowser(peerId) { - let peerIdsBrowser = JSON.parse(localStorage.getItem("peerIdsBrowser")); + let peerIdsBrowser = JSON.parse(localStorage.getItem("peer_ids_browser")); return peerIdsBrowser ? peerIdsBrowser.indexOf(peerId) !== -1 : false; } static async addPeerIdToLocalStorage() { - const peerId = sessionStorage.getItem("peerId"); + const peerId = sessionStorage.getItem("peer_id"); if (!peerId) return false; let peerIdsBrowser = []; - let peerIdsBrowserOld = JSON.parse(localStorage.getItem("peerIdsBrowser")); + let peerIdsBrowserOld = JSON.parse(localStorage.getItem("peer_ids_browser")); if (peerIdsBrowserOld) peerIdsBrowser.push(...peerIdsBrowserOld); peerIdsBrowser.push(peerId); peerIdsBrowser = peerIdsBrowser.filter(onlyUnique); - localStorage.setItem("peerIdsBrowser", JSON.stringify(peerIdsBrowser)); + localStorage.setItem("peer_ids_browser", JSON.stringify(peerIdsBrowser)); return peerIdsBrowser; } static async removePeerIdFromLocalStorage(peerId) { - let peerIdsBrowser = JSON.parse(localStorage.getItem("peerIdsBrowser")); + let peerIdsBrowser = JSON.parse(localStorage.getItem("peer_ids_browser")); const index = peerIdsBrowser.indexOf(peerId); peerIdsBrowser.splice(index, 1); - localStorage.setItem("peerIdsBrowser", JSON.stringify(peerIdsBrowser)); + localStorage.setItem("peer_ids_browser", JSON.stringify(peerIdsBrowser)); return peerId; } static async removeOtherPeerIdsFromLocalStorage() { - const peerId = sessionStorage.getItem("peerId"); + const peerId = sessionStorage.getItem("peer_id"); if (!peerId) return false; let peerIdsBrowser = [peerId]; - localStorage.setItem("peerIdsBrowser", JSON.stringify(peerIdsBrowser)); + localStorage.setItem("peer_ids_browser", JSON.stringify(peerIdsBrowser)); return peerIdsBrowser; } } class PairDrop { constructor() { - Events.on('load', _ => { + Events.on('initial-translation-loaded', _ => { const server = new ServerConnection(); const peers = new PeersManager(server); const peersUI = new PeersUI(); + const languageSelectDialog = new LanguageSelectDialog(); const receiveFileDialog = new ReceiveFileDialog(); const receiveRequestDialog = new ReceiveRequestDialog(); const sendTextDialog = new SendTextDialog(); const receiveTextDialog = new ReceiveTextDialog(); const pairDeviceDialog = new PairDeviceDialog(); const clearDevicesDialog = new EditPairedDevicesDialog(); + const publicRoomDialog = new PublicRoomDialog(); const base64ZipDialog = new Base64ZipDialog(); const toast = new Toast(); const notifications = new Notifications(); @@ -2232,6 +2684,7 @@ class PairDrop { const persistentStorage = new PersistentStorage(); const pairDrop = new PairDrop(); +const localization = new Localization(); if ('serviceWorker' in navigator) { @@ -2264,8 +2717,8 @@ Events.on('load', () => { let oldOffset = offset w = document.documentElement.clientWidth; h = document.documentElement.clientHeight; - offset = $$('footer').offsetHeight - 33; - if (h > 800) offset += 16; + offset = $$('footer').offsetHeight - 27; + if (h > 800) offset += 10; if (oldW === w && oldH === h && oldOffset === offset) return; // nothing has changed diff --git a/public/styles.css b/public/styles.css index db86b60..ac980dd 100644 --- a/public/styles.css +++ b/public/styles.css @@ -4,6 +4,8 @@ --icon-size: 24px; --primary-color: #4285f4; --paired-device-color: #00a69c; + --public-room-color: #db8500; + --accent-color: var(--primary-color); --peer-width: 120px; color-scheme: light dark; } @@ -23,6 +25,7 @@ body { -webkit-user-select: none; -moz-user-select: none; user-select: none; + transition: color 300ms; } body { @@ -40,6 +43,10 @@ html { min-height: fill-available; } +.fw { + width: 100%; +} + .row-reverse { display: flex; flex-direction: row-reverse; @@ -51,7 +58,6 @@ html { .row { display: flex; - justify-content: center; flex-direction: row; } @@ -78,6 +84,10 @@ html { bottom: 0; } +.pointer { + cursor: pointer; +} + header { position: absolute; align-items: baseline; @@ -215,10 +225,6 @@ a, cursor: pointer; } -hr { - color: white; -} - input { cursor: pointer; } @@ -275,8 +281,6 @@ x-noscript { margin-top: 56px; flex-direction: column-reverse; flex-grow: 1; - --footer-height: 132px; - max-height: calc(100vh - 56px - var(--footer-height)); justify-content: space-around; align-items: center; overflow-x: hidden; @@ -284,11 +288,6 @@ x-noscript { overscroll-behavior-x: none; } -@media screen and (max-width: 425px) { - header:has(#edit-pair-devices:not([hidden]))~#center { - --footer-height: 150px; - } -} /* Peers List */ @@ -442,7 +441,7 @@ x-no-peers::before { } x-no-peers[drop-bg]::before { - content: "Release to select recipient"; + content: attr(data-drop-bg); } x-no-peers[drop-bg] * { @@ -461,7 +460,6 @@ x-peer { x-peer label { width: var(--peer-width); - cursor: pointer; touch-action: manipulation; -webkit-tap-highlight-color: rgba(0, 0, 0, 0); position: relative; @@ -490,10 +488,14 @@ x-peer .icon-wrapper { display: flex; } -x-peer:not(.type-ip).type-secret .icon-wrapper { +x-peer.type-secret .icon-wrapper { background: var(--paired-device-color); } +x-peer:not(.type-ip):not(.type-secret).type-public-id .icon-wrapper { + background: var(--public-room-color); +} + x-peer x-icon > .highlight-wrapper { align-self: center; align-items: center; @@ -502,17 +504,29 @@ x-peer x-icon > .highlight-wrapper { } x-peer x-icon > .highlight-wrapper > .highlight { - width: 6px; + width: 15px; height: 6px; - border-radius: 50%; + border-radius: 4px; + margin-left: 1px; + margin-right: 1px; display: none; } -x-peer.type-secret x-icon > .highlight-wrapper > .highlight { +x-peer.type-ip x-icon > .highlight-wrapper > .highlight.highlight-room-ip { + background-color: var(--primary-color); + display: inline; +} + +x-peer.type-secret x-icon > .highlight-wrapper > .highlight.highlight-room-secret { background-color: var(--paired-device-color); display: inline; } +x-peer.type-public-id x-icon > .highlight-wrapper > .highlight.highlight-room-public-id { + background-color: var(--public-room-color); + display: inline; +} + x-peer:not([status]):hover x-icon, x-peer:not([status]):focus x-icon { transform: scale(1.05); @@ -553,22 +567,6 @@ x-peer[status] x-icon { white-space: nowrap; } -x-peer[status=transfer] .status:before { - content: 'Transferring...'; -} - -x-peer[status=prepare] .status:before { - content: 'Preparing...'; -} - -x-peer[status=wait] .status:before { - content: 'Waiting...'; -} - -x-peer[status=process] .status:before { - content: 'Processing...'; -} - x-peer:not([status]) .status, x-peer[status] .device-name { display: none; @@ -602,13 +600,11 @@ x-peer[drop] x-icon { footer { position: relative; - margin-top: auto; z-index: 2; align-items: center; - padding: 0 0 16px 0; text-align: center; - transition: color 300ms; cursor: default; + margin: auto 5px 5px; } footer .logo { @@ -618,43 +614,71 @@ footer .logo { margin-top: -10px; } -footer .font-body2 { - color: var(--primary-color); - margin: auto 18px; +.discovery-wrapper { + font-size: 12px; + margin: 10px auto auto; + border: 3px solid var(--border-color); + border-radius: 0.5rem; + padding: 2px; + background-color: rgb(var(--bg-color)); + transition: background-color 0.5s ease; } -#on-this-network { - border-bottom: solid 4px var(--primary-color); - padding-bottom: 1px; +/*You can be discovered wrapper*/ +.discovery-wrapper > div:first-of-type { + padding-left: 4px; + padding-right: 4px; } -#paired-devices { - border-bottom: solid 4px var(--paired-device-color); - padding-bottom: 1px; + +.discovery-wrapper .badge { + word-break: keep-all; + margin: 2px; +} + +.badge { + border-radius: 0.3rem/0.3rem; + padding-right: 0.3rem; + padding-left: 0.3em; + background-color: var(--badge-color); + color: white; + transition: background-color 0.5s ease; + white-space: nowrap; +} + +.badge-room-ip { + background-color: var(--primary-color); + border-color: var(--primary-color); +} + +.badge-room-secret { + background-color: var(--paired-device-color); + border-color: var(--paired-device-color); +} + +.badge-room-public-id { + background-color: var(--public-room-color); + border-color: var(--public-room-color); } #display-name { + position: relative; display: inline-block; text-align: left; border: none; outline: none; max-width: 15em; text-overflow: ellipsis; - white-space: nowrap; cursor: text; margin-left: -1rem; margin-bottom: -6px; - padding-right: 0.3rem; - padding-left: 0.3em; padding-bottom: 0.1rem; border-radius: 1.3rem/30%; border-right: solid 1rem transparent; border-left: solid 1rem transparent; background-clip: padding-box; - background-color: rgba(var(--text-color), 43%); - color: white; - transition: background-color 0.5s ease; overflow: hidden; + z-index: 1; } #edit-pen { @@ -663,7 +687,6 @@ footer .font-body2 { margin-left: -1rem; margin-bottom: -2px; position: relative; - z-index: -1; } /* Dialog */ @@ -681,7 +704,6 @@ x-dialog x-paper { z-index: 3; background: white; border-radius: 8px; - padding: 16px 24px; width: 100%; max-width: 400px; overflow: hidden; @@ -690,14 +712,33 @@ x-dialog x-paper { will-change: transform; } -#pair-device-dialog x-paper { +x-paper > .row:first-of-type { + background-color: var(--accent-color); + border-bottom: solid 4px var(--border-color); + margin-bottom: 10px; +} + +x-paper > .row:first-of-type h2 { + color: white; +} + +#pair-device-dialog, +#edit-paired-devices-dialog { + --accent-color: var(--paired-device-color); +} + +#public-room-dialog { + --accent-color: var(--public-room-color); +} + +#pair-device-dialog x-paper, +#public-room-dialog x-paper { display: flex; flex-direction: column; position: absolute; top: max(50%, 350px); margin-top: -328.5px; width: calc(100vw - 20px); - height: 625px; } #pair-device-dialog ::-moz-selection, @@ -706,6 +747,12 @@ x-dialog x-paper { background: var(--paired-device-color); } +#public-room-dialog ::-moz-selection, +#public-room-dialog ::selection { + color: black; + background: var(--public-room-color); +} + x-dialog:not([show]) { pointer-events: none; } @@ -723,24 +770,21 @@ x-dialog a { color: var(--primary-color); } -x-dialog .font-subheading { - margin-bottom: 5px; -} - /* Pair Devices Dialog */ -#key-input-container { +.input-key-container { width: 100%; display: flex; justify-content: center; } -#key-input-container > input { +.input-key-container > input { width: 45px; height: 45px; font-size: 30px; padding: 0; text-align: center; + text-transform: uppercase; display: -webkit-box !important; display: -webkit-flex !important; display: -moz-flex !important; @@ -751,15 +795,15 @@ x-dialog .font-subheading { justify-content: center; } -#key-input-container > input + * { +.input-key-container > input + * { margin-left: 6px; } -#key-input-container > input:nth-of-type(4) { +.input-key-container.six-chars > input:nth-of-type(4) { margin-left: 5%; } -#room-key { +.key { -webkit-user-select: text; -moz-user-select: text; user-select: text; @@ -770,13 +814,48 @@ x-dialog .font-subheading { margin: 15px -15px; } -#room-key-qr-code { +.key-qr-code { margin: 16px; } +.key-instructions { + flex-direction: column; +} + +x-dialog h2 { + margin-top: 5px; + margin-bottom: 0; +} + x-dialog hr { - margin: 40px -24px 30px -24px; - border: solid 1.25px var(--border-color); + height: 3px; + border: none; + width: 100%; + background-color: var(--border-color); +} + +.hr-note { + margin-top: 10px; + margin-bottom: 10px; +} + +.hr-note hr { + margin-bottom: -2px; +} + +.hr-note > div { + height: 0; + transform: translateY(-10px); +} + + +.hr-note > div > span { + padding: 3px 10px; + border-radius: 10px; + color: rgb(var(--text-color)); + background-color: rgb(var(--bg-color)); + border: var(--border-color) solid 3px; + text-transform: uppercase; } #pair-device-dialog x-background { @@ -785,7 +864,7 @@ x-dialog hr { /* Edit Paired Devices Dialog */ .paired-devices-wrapper:empty:before { - content: "No paired devices."; + content: attr(data-empty); } .paired-devices-wrapper:empty { @@ -870,39 +949,34 @@ x-dialog hr { text-overflow: ellipsis; } -.paired-device > .auto-accept { - cursor: pointer; -} - /* Receive Dialog */ -x-dialog .row { - margin-top: 24px; - margin-bottom: 8px; +x-paper > .row { + padding: 10px; } /* button row*/ -x-paper > div:last-child { - margin: auto -24px -15px; - border-top: solid 2.5px var(--border-color); +x-paper > .button-row { + border-top: solid 3px var(--border-color); height: 50px; + margin-top: 10px; } -x-paper > div:last-child > .button { +x-paper > .button-row > .button { height: 100%; width: 100%; } -x-paper > div:last-child > .button:not(:last-child) { - border-left: solid 2.5px var(--border-color); +x-paper > .button-row > .button:not(:first-child) { + border-right: solid 1.5px var(--border-color); +} + +x-paper > .button-row > .button:not(:last-child) { + border-left: solid 1.5px var(--border-color); } .file-description { - margin-bottom: 25px; -} - -.file-description .row { - margin: 0 + max-width: 100%; } .file-description span { @@ -913,23 +987,29 @@ x-paper > div:last-child > .button:not(:last-child) { .file-name { font-style: italic; max-width: 100%; + margin-top: 5px; } .file-stem { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + padding-right: 1px; } /* Send Text Dialog */ -/* Todo: add pair underline to send / receive dialogs displayName */ x-dialog .dialog-subheader { - margin-bottom: 25px; + padding-top: 16px; + padding-bottom: 16px; +} + +#send-text-dialog .display-name-wrapper { + padding-bottom: 0; } #text-input { min-height: 200px; - margin: 14px auto; + width: 100%; } /* Receive Text Dialog */ @@ -944,7 +1024,6 @@ x-dialog .dialog-subheader { -moz-user-select: text; user-select: text; white-space: pre-wrap; - padding: 15px 0; } #receive-text-dialog #text a { @@ -971,12 +1050,11 @@ x-dialog .dialog-subheader { width: 100%; height: 40vh; border: solid 12px #438cff; - text-align: center; + border-radius: 8px; } #base64-paste-dialog .textarea { display: flex; - flex-direction: column; align-items: center; justify-content: center; text-align: center; @@ -988,21 +1066,9 @@ x-dialog .dialog-subheader { color: var(--primary-color); font-weight: 700; text-transform: uppercase; - content: attr(placeholder); + white-space: pre-wrap; } -#base64-paste-dialog button { - margin: auto; - border-radius: 8px; -} - -#base64-paste-dialog button[close] { - margin-top: 20px; -} - -#base64-paste-dialog button[close]:before { - border-radius: 8px; -} /* Button */ @@ -1019,12 +1085,13 @@ x-dialog .dialog-subheader { cursor: pointer; user-select: none; background: inherit; - color: var(--primary-color); + color: var(--accent-color); overflow: hidden; } .button[disabled] { color: #5B5B66; + cursor: not-allowed; } @@ -1058,6 +1125,11 @@ x-dialog .dialog-subheader { opacity: 0.1; } +.button[selected], +.icon-button[selected] { + opacity: 0.1; +} + #cancel-paste-mode { z-index: 2; margin: 0; @@ -1100,8 +1172,7 @@ button::-moz-focus-inner { border: none; outline: none; padding: 16px 24px; - border-radius: 16px; - margin: 10px 0; + border-radius: 8px; font-size: 14px; font-family: inherit; background: #f1f3f4; @@ -1288,11 +1359,11 @@ x-instructions:not([drop-peer]):not([drop-bg]):before { } x-instructions[drop-peer]:before { - content: "Release to send to peer"; + content: attr(data-drop-peer); } x-instructions[drop-bg]:not([drop-peer]):before { - content: "Release to select recipient"; + content: attr(data-drop-bg); } x-instructions p { @@ -1311,14 +1382,6 @@ x-peers:empty~x-instructions { } /* Responsive Styles */ -@media screen and (max-width: 360px) { - x-dialog x-paper { - padding: 15px; - } - x-paper > div:last-child { - margin: auto -15px -15px; - } -} @media screen and (min-height: 800px) { footer { @@ -1341,8 +1404,9 @@ body { --text-color: 51,51,51; --bg-color: 250,250,250; /*rgb code*/ --bg-color-test: 18,18,18; - --bg-color-secondary: #f1f3f4; - --border-color: #e7e8e8; + --bg-color-secondary: #e4e4e4; + --border-color: rgb(169, 169, 169); + --badge-color: #a5a5a5; } /* Dark theme colors */ @@ -1350,7 +1414,8 @@ body.dark-theme { --text-color: 238,238,238; --bg-color: 18,18,18; /*rgb code*/ --bg-color-secondary: #333; - --border-color: #252525; + --border-color: rgb(238,238,238); + --badge-color: #717171; } /* Colored Elements */ @@ -1384,7 +1449,7 @@ x-dialog x-paper { /* Image/Video/Audio Preview */ .file-preview { - margin: 10px -24px 40px -24px; + margin-bottom: 15px; } .file-preview:empty { @@ -1408,15 +1473,17 @@ x-dialog x-paper { --text-color: 238,238,238; --bg-color: 18,18,18; /*rgb code*/ --bg-color-secondary: #333; - --border-color: #252525; + --border-color: rgb(238,238,238); + --badge-color: #717171; } /* Override dark mode with light mode styles if the user decides to swap */ body.light-theme { --text-color: 51,51,51; --bg-color: 250,250,250; /*rgb code*/ - --bg-color-secondary: #f1f3f4; - --border-color: #e7e8e8; + --bg-color-secondary: #e4e4e4; + --border-color: rgb(169, 169, 169); + --badge-color: #a5a5a5; } } diff --git a/public_included_ws_fallback/index.html b/public_included_ws_fallback/index.html index 838d8d2..e52a0d4 100644 --- a/public_included_ws_fallback/index.html +++ b/public_included_ws_fallback/index.html @@ -39,107 +39,172 @@
- + +
+ + + +
-
+
-
+
-
+
-
- -

Open PairDrop on other devices to send files

-
Pair devices to be discoverable on other networks
+ +

Open PairDrop on other devices to send files

+
Pair devices or enter a public room to be discoverable on other networks
- +

+
+ Traffic is + routed through the server + if WebRTC is not available. +
-
- You are known as: -
- - - -
-
- You can be discovered by everyone on this network - -
-
- Traffic is routed through the server if WebRTC is not available. +
+
+ You are known as: +
+ + + +
+
+
+ You can be discovered: +
+
+ on this network + + +
+
+ + + + +
+

Select Language

+
+
+ + + + + + +
+
+ +
+
+
+
-

Pair Devices

-
-

000 000

-
Input this key on another device
or scan the QR-Code.
-
-
- - - - - - +
+

Pair Devices

-
Enter key from another device to continue.
-
- - +
+
+
+

000 000

+

+ Input this key on another device + or scan the QR-Code. +

+
+
+
+
+
+ OR +
+
+
+
+
+ + + + + + +
+

Enter key from another device here.

+
+
+
+ +
@@ -150,13 +215,70 @@ -

Edit Paired Devices

-
-
-

Activate auto-accept to automatically accept all files sent from that device.

+
+

Edit Paired Devices

-
+
+
+

+ + Activate + + auto-accept + + to automatically accept all files sent from that device. + +

+
+
+ +
+ + + + + + +
+ + +
+
+

Temporary Public Room

+
+
+
+
+
+

IOX9P

+

+ Input this room id on another device + or scan the QR-Code. +

+
+
+
+
+
+ OR +
+
+
+
+
+ + + + + +
+

Enter room id from another device to join.

+
+
+
+ +
@@ -166,24 +288,30 @@ -

-
-
- - would like to share +
+
+

-
- - +
+
+
+
+ + would like to share +
+
+ + +
+
+
+
-
-
-
-
- - +
+ +
@@ -192,24 +320,31 @@ -

-
-
- - has sent +
+
+

-
- - +
+
+
+
+ + has sent +
+
+ + +
+
+
+
-
-
-
- - - +
+ + +
@@ -219,16 +354,27 @@ -

Send Message

-
- Send a Message to - +
+
+

Send Message

+
-
-
-
- - +
+
+
+ Send a Message to + +
+
+
+
+
+
+
+
+
+ +
@@ -238,16 +384,23 @@ -

Message Received

-
- - has sent: +
+

Message Received

-
-
-
- - +
+
+ + has sent: +
+
+
+
+
+
+
+
+ +
@@ -256,20 +409,22 @@ - + - +
+ +
- +
- + @@ -283,24 +438,24 @@

PairDrop

v1.7.7
-
The easiest way to transfer files across devices
+
The easiest way to transfer files across devices
- + - + - + - + @@ -374,8 +529,18 @@ + + + + + + + + + + diff --git a/public_included_ws_fallback/lang/de.json b/public_included_ws_fallback/lang/de.json new file mode 100644 index 0000000..5a6bd0b --- /dev/null +++ b/public_included_ws_fallback/lang/de.json @@ -0,0 +1,156 @@ +{ + "header": { + "about_title": "Über PairDrop", + "notification_title": "Benachrichtigungen aktivieren", + "about_aria-label": "Über PairDrop öffnen", + "install_title": "PairDrop installieren", + "pair-device_title": "Deine Geräte dauerhaft koppeln", + "edit-paired-devices_title": "Gekoppelte Geräte bearbeiten", + "theme-auto_title": "Systemstil verwenden", + "theme-dark_title": "Dunklen Stil verwenden", + "theme-light_title": "Hellen Stil verwenden", + "cancel-paste-mode": "Fertig", + "language-selector_title": "Sprache auswählen", + "join-public-room_title": "Öffentlichen Raum temporär betreten" + }, + "dialogs": { + "share": "Teilen", + "download": "Herunterladen", + "pair-devices-title": "Geräte dauerhaft koppeln", + "input-key-on-this-device": "Gebe diesen Schlüssel auf einem anderen Gerät ein", + "enter-key-from-another-device": "Gebe den Schlüssel von einem anderen Gerät hier ein.", + "pair": "Koppeln", + "cancel": "Abbrechen", + "edit-paired-devices-title": "Gekoppelte Geräte bearbeiten", + "paired-devices-wrapper_data-empty": "Keine gekoppelten Geräte.", + "close": "Schließen", + "accept": "Akzeptieren", + "decline": "Ablehnen", + "title-image": "Bild", + "title-file": "Datei", + "title-image-plural": "Bilder", + "title-file-plural": "Dateien", + "scan-qr-code": "oder scanne den QR-Code.", + "would-like-to-share": "möchte Folgendes teilen", + "send": "Senden", + "copy": "Kopieren", + "receive-text-title": "Textnachricht erhalten", + "file-other-description-image-plural": "und {{count}} andere Bilder", + "file-other-description-file-plural": "und {{count}} andere Dateien", + "auto-accept-instructions-1": "Aktiviere", + "auto-accept": "auto-accept", + "auto-accept-instructions-2": "um automatisch alle Dateien von diesem Gerät zu akzeptieren.", + "has-sent": "hat Folgendes gesendet:", + "send-message-title": "Textnachricht senden", + "send-message-to": "Sende eine Textnachricht an", + "base64-tap-to-paste": "Hier tippen, um {{type}} einzufügen", + "base64-paste-to-send": "Hier einfügen, um {{type}} zu versenden", + "base64-text": "Text", + "base64-files": "Dateien", + "base64-processing": "Bearbeitung läuft…", + "file-other-description-image": "und ein anderes Bild", + "file-other-description-file": "und eine andere Datei", + "receive-title": "{{descriptor}} erhalten", + "download-again": "Erneut herunterladen", + "system-language": "Systemsprache", + "language-selector-title": "Sprache auswählen", + "hr-or": "ODER", + "input-room-id-on-another-device": "Gebe diese Raum ID auf einem anderen Gerät ein", + "unpair": "Entkoppeln" + }, + "about": { + "tweet_title": "Über PairDrop twittern", + "faq_title": "Häufig gestellte Fragen", + "close-about_aria-label": "Schließe Über PairDrop", + "github_title": "PairDrop auf GitHub", + "buy-me-a-coffee_title": "Kauf mir einen Kaffee!", + "claim": "Der einfachste Weg Dateien zwischen Geräten zu teilen" + }, + "footer": { + "known-as": "Du wirst angezeigt als:", + "display-name_title": "Setze einen permanenten Gerätenamen", + "on-this-network": "in diesem Netzwerk", + "paired-devices": "für gekoppelte Geräte", + "traffic": "Datenverkehr wird", + "display-name_placeholder": "Lade…", + "routed": "durch den Server geleitet", + "webrtc": "wenn WebRTC nicht verfügbar ist.", + "display-name_data-placeholder": "Lade…", + "public-room-devices_title": "Du kannst von Geräten in diesem öffentlichen Raum unabhängig von deinem Netzwerk gefunden werden.", + "paired-devices_title": "Du kannst immer von gekoppelten Geräten gefunden werden, egal in welchem Netzwerk.", + "public-room-devices": "in Raum {{roomId}}", + "discovery": "Du bist sichtbar:", + "on-this-network_title": "Du kannst von jedem in diesem Netzwerk gefunden werden." + }, + "notifications": { + "link-received": "Link von {{name}} empfangen - Klicke um ihn zu öffnen", + "message-received": "Nachricht von {{name}} empfangen - Klicke um sie zu kopieren", + "click-to-download": "Klicken zum Download", + "copied-text": "Text in die Zwischenablage kopiert", + "connected": "Verbunden.", + "pairing-success": "Geräte gekoppelt.", + "display-name-random-again": "Anzeigename wird ab jetzt wieder zufällig generiert.", + "pairing-tabs-error": "Es können keine zwei Webbrowser Tabs gekoppelt werden.", + "pairing-not-persistent": "Gekoppelte Geräte sind nicht persistent.", + "pairing-key-invalid": "Ungültiger Schlüssel", + "pairing-key-invalidated": "Schlüssel {{key}} wurde ungültig gemacht.", + "copied-to-clipboard": "In die Zwischenablage kopiert", + "text-content-incorrect": "Textinhalt ist fehlerhaft.", + "clipboard-content-incorrect": "Inhalt der Zwischenablage ist fehlerhaft.", + "copied-text-error": "Konnte nicht in die Zwischenablage schreiben. Kopiere manuell!", + "file-content-incorrect": "Dateiinhalt ist fehlerhaft.", + "notifications-enabled": "Benachrichtigungen aktiviert.", + "offline": "Du bist offline", + "online": "Du bist wieder Online", + "unfinished-transfers-warning": "Es wurden noch nicht alle Übertragungen fertiggestellt. Möchtest du PairDrop wirklich schließen?", + "display-name-changed-permanently": "Anzeigename wurde dauerhaft geändert.", + "download-successful": "{{descriptor}} heruntergeladen", + "pairing-cleared": "Alle Geräte entkoppelt.", + "click-to-show": "Klicken zum Anzeigen", + "online-requirement": "Du musst online sein um Geräte zu koppeln.", + "display-name-changed-temporarily": "Anzeigename wurde nur für diese Sitzung geändert.", + "request-title": "{{name}} möchte {{count}}{{descriptor}} übertragen", + "connecting": "Verbindung wird aufgebaut…", + "files-incorrect": "Dateien sind fehlerhaft.", + "file-transfer-completed": "Dateiübertragung fertiggestellt.", + "message-transfer-completed": "Nachrichtenübertragung fertiggestellt.", + "rate-limit-join-key": "Rate Limit erreicht. Warte 10 Sekunden und versuche es erneut.", + "selected-peer-left": "Ausgewählter Peer ist gegangen.", + "ios-memory-limit": "Für Übertragungen an iOS Geräte beträgt die maximale Dateigröße 200 MB", + "public-room-left": "Öffentlichen Raum {{publicRoomId}} verlassen", + "copied-to-clipboard-error": "Konnte nicht kopieren. Kopiere manuell.", + "public-room-id-invalid": "Ungültige Raum ID", + "online-requirement-pairing": "Du musst online sein, um Geräte zu koppeln.", + "online-requirement-public-room": "Du musst online sein, um öffentliche Räume erstellen zu können." + }, + "instructions": { + "x-instructions_desktop": "Klicke, um Dateien zu Senden oder klicke mit der rechten Maustaste, um Textnachrichten zu senden", + "no-peers-title": "Öffne PairDrop auf anderen Geräten, um Dateien zu senden", + "no-peers_data-drop-bg": "Hier ablegen, um Empfänger auszuwählen", + "no-peers-subtitle": "Kopple Geräte oder besuche einen öffentlichen Raum, damit du in anderen Netzwerken sichtbar bist", + "click-to-send": "Klicke zum Senden von", + "tap-to-send": "Tippe zum Senden von", + "x-instructions_data-drop-peer": "Hier ablegen, um an Peer zu senden", + "x-instructions_data-drop-bg": "Loslassen um Empfänger auszuwählen", + "x-instructions_mobile": "Tippe zum Senden von Dateien oder tippe lange zum Senden von Nachrichten", + "activate-paste-mode-base": "Öffne PairDrop auf anderen Geräten zum Senden von", + "activate-paste-mode-and-other-files": "und {{count}} anderen Dateien", + "activate-paste-mode-shared-text": "freigegebenem Text" + }, + "document-titles": { + "file-transfer-requested": "Datenübertagung angefordert", + "file-received": "Datei erhalten", + "file-received-plural": "{{count}} Dateien erhalten", + "message-received": "Nachricht erhalten", + "message-received-plural": "{{count}} Nachrichten erhalten" + }, + "peer-ui": { + "click-to-send": "Klicke um Dateien zu senden oder nutze einen Rechtsklick um eine Textnachricht zu senden", + "connection-hash": "Um die Ende-zu-Ende Verschlüsselung zu verifizieren, vergleiche die Sicherheitsnummer auf beiden Geräten", + "waiting": "Warte…", + "click-to-send-paste-mode": "Klicken um {{descriptor}} zu senden", + "transferring": "Übertragung läuft…", + "processing": "Bearbeitung läuft…", + "preparing": "Vorbereitung läuft…" + } +} diff --git a/public_included_ws_fallback/lang/en.json b/public_included_ws_fallback/lang/en.json new file mode 100644 index 0000000..70b49bd --- /dev/null +++ b/public_included_ws_fallback/lang/en.json @@ -0,0 +1,154 @@ +{ + "header": { + "about_title": "About PairDrop", + "language-selector_title": "Select Language", + "about_aria-label": "Open About PairDrop", + "theme-auto_title": "Adapt Theme to System", + "theme-light_title": "Always Use Light-Theme", + "theme-dark_title": "Always Use Dark-Theme", + "notification_title": "Enable Notifications", + "install_title": "Install PairDrop", + "pair-device_title": "Pair Your Devices Permanently", + "edit-paired-devices_title": "Edit Paired Devices", + "join-public-room_title": "Join Public Room Temporarily", + "cancel-paste-mode": "Done" + }, + "instructions": { + "no-peers_data-drop-bg": "Release to select recipient", + "no-peers-title": "Open PairDrop on other devices to send files", + "no-peers-subtitle": "Pair devices or enter a public room to be discoverable on other networks", + "x-instructions_desktop": "Click to send files or right click to send a message", + "x-instructions_mobile": "Tap to send files or long tap to send a message", + "x-instructions_data-drop-peer": "Release to send to peer", + "x-instructions_data-drop-bg": "Release to select recipient", + "click-to-send": "Click to send", + "tap-to-send": "Tap to send", + "activate-paste-mode-base": "Open PairDrop on other devices to send", + "activate-paste-mode-and-other-files": "and {{count}} other files", + "activate-paste-mode-shared-text": "shared text" + }, + "footer": { + "known-as": "You are known as:", + "display-name_data-placeholder": "Loading…", + "display-name_title": "Edit your device name permanently", + "discovery": "You can be discovered:", + "on-this-network": "on this network", + "on-this-network_title": "You can be discovered by everyone on this network.", + "paired-devices": "by paired devices", + "paired-devices_title": "You can be discovered by paired devices at all times independent of the network.", + "public-room-devices": "in room {{roomId}}", + "public-room-devices_title": "You can be discovered by devices in this public room independent of the network.", + "traffic": "Traffic is", + "routed": "routed through the server", + "webrtc": "if WebRTC is not available." + }, + "dialogs": { + "pair-devices-title": "Pair Devices Permanently", + "input-key-on-this-device": "Input this key on another device", + "scan-qr-code": "or scan the QR-code.", + "enter-key-from-another-device": "Enter key from another device here.", + "input-room-id-on-another-device": "Input this room id on another device", + "hr-or": "OR", + "pair": "Pair", + "cancel": "Cancel", + "edit-paired-devices-title": "Edit Paired Devices", + "unpair": "Unpair", + "paired-devices-wrapper_data-empty": "No paired devices.", + "auto-accept-instructions-1": "Activate", + "auto-accept": "auto-accept", + "auto-accept-instructions-2": "to automatically accept all files sent from that device.", + "close": "Close", + "would-like-to-share": "would like to share", + "accept": "Accept", + "decline": "Decline", + "has-sent": "has sent:", + "share": "Share", + "download": "Download", + "send-message-title": "Send Message", + "send-message-to": "Send a Message to", + "send": "Send", + "receive-text-title": "Message Received", + "copy": "Copy", + "base64-processing": "Processing…", + "base64-tap-to-paste": "Tap here to paste {{type}}", + "base64-paste-to-send": "Paste here to send {{type}}", + "base64-text": "text", + "base64-files": "files", + "file-other-description-image": "and 1 other image", + "file-other-description-file": "and 1 other file", + "file-other-description-image-plural": "and {{count}} other images", + "file-other-description-file-plural": "and {{count}} other files", + "title-image": "Image", + "title-file": "File", + "title-image-plural": "Images", + "title-file-plural": "Files", + "receive-title": "{{descriptor}} Received", + "download-again": "Download again", + "language-selector-title": "Select Language", + "system-language": "System Language" + }, + "about": { + "close-about_aria-label": "Close About PairDrop", + "claim": "The easiest way to transfer files across devices", + "github_title": "PairDrop on GitHub", + "buy-me-a-coffee_title": "Buy me a coffee!", + "tweet_title": "Tweet about PairDrop", + "faq_title": "Frequently asked questions" + }, + "notifications": { + "display-name-changed-permanently": "Display name is changed permanently.", + "display-name-changed-temporarily": "Display name is changed only for this session.", + "display-name-random-again": "Display name is randomly generated again.", + "download-successful": "{{descriptor}} downloaded", + "pairing-tabs-error": "Pairing two web browser tabs is impossible.", + "pairing-success": "Devices paired.", + "pairing-not-persistent": "Paired devices are not persistent.", + "pairing-key-invalid": "Invalid key", + "pairing-key-invalidated": "Key {{key}} invalidated.", + "pairing-cleared": "All Devices unpaired.", + "public-room-id-invalid": "Invalid room id", + "public-room-left": "Left public room {{publicRoomId}}", + "copied-to-clipboard": "Copied to clipboard", + "copied-to-clipboard-error": "Copying not possible. Copy manually.", + "text-content-incorrect": "Text content is incorrect.", + "file-content-incorrect": "File content is incorrect.", + "clipboard-content-incorrect": "Clipboard content is incorrect.", + "notifications-enabled": "Notifications enabled.", + "link-received": "Link received by {{name}} - Click to open", + "message-received": "Message received by {{name}} - Click to copy", + "click-to-download": "Click to download", + "request-title": "{{name}} would like to transfer {{count}} {{descriptor}}", + "click-to-show": "Click to show", + "copied-text": "Copied text to clipboard", + "copied-text-error": "Writing to clipboard failed. Copy manually!", + "offline": "You are offline", + "online": "You are back online", + "connected": "Connected.", + "online-requirement-pairing": "You need to be online to pair devices.", + "online-requirement-public-room": "You need to be online to create a public room.", + "connecting": "Connecting…", + "files-incorrect": "Files are incorrect.", + "file-transfer-completed": "File transfer completed.", + "ios-memory-limit": "Sending files to iOS is only possible up to 200 MB at once", + "message-transfer-completed": "Message transfer completed.", + "unfinished-transfers-warning": "There are unfinished transfers. Are you sure you want to close PairDrop?", + "rate-limit-join-key": "Rate limit reached. Wait 10 seconds and try again.", + "selected-peer-left": "Selected peer left." + }, + "document-titles": { + "file-received": "File Received", + "file-received-plural": "{{count}} Files Received", + "file-transfer-requested": "File Transfer Requested", + "message-received": "Message Received", + "message-received-plural": "{{count}} Messages Received" + }, + "peer-ui": { + "click-to-send-paste-mode": "Click to send {{descriptor}}", + "click-to-send": "Click to send files or right click to send a message", + "connection-hash": "To verify the security of the end-to-end encryption, compare this security number on both devices", + "preparing": "Preparing…", + "waiting": "Waiting…", + "processing": "Processing…", + "transferring": "Transferring…" + } +} diff --git a/public_included_ws_fallback/lang/nb.json b/public_included_ws_fallback/lang/nb.json new file mode 100644 index 0000000..e63448f --- /dev/null +++ b/public_included_ws_fallback/lang/nb.json @@ -0,0 +1,138 @@ +{ + "header": { + "edit-paired-devices_title": "Rediger sammenkoblede enheter", + "about_title": "Om PairDrop", + "about_aria-label": "Åpne «Om PairDrop»", + "theme-auto_title": "Juster drakt til system", + "theme-light_title": "Alltid bruk lys drakt", + "theme-dark_title": "Alltid bruk mørk drakt", + "notification_title": "Skru på merknader", + "cancel-paste-mode": "Ferdig", + "install_title": "Installer PairDrop", + "pair-device_title": "Sammenkoble enhet" + }, + "footer": { + "webrtc": "hvis WebRTC ikke er tilgjengelig.", + "display-name_data-placeholder": "Laster inn…", + "display-name_title": "Rediger det vedvarende enhetsnavnet ditt", + "traffic": "Trafikken", + "on-this-network": "på dette nettverket", + "known-as": "Du er kjent som:", + "paired-devices": "sammenkoblede enheter", + "routed": "Sendes gjennom tjeneren" + }, + "instructions": { + "x-instructions_desktop": "Klikk for å sende filer, eller høyreklikk for å sende en melding", + "x-instructions_mobile": "Trykk for å sende filer, eller lang-trykk for å sende en melding", + "x-instructions_data-drop-bg": "Slipp for å velge mottager", + "click-to-send": "Klikk for å sende", + "no-peers_data-drop-bg": "Slipp for å velge mottager", + "no-peers-title": "Åpne PairDrop på andre enheter for å sende filer", + "no-peers-subtitle": "Sammenkoble enheter for å kunne oppdages på andre nettverk", + "x-instructions_data-drop-peer": "Slipp for å sende til likemann", + "tap-to-send": "Trykk for å sende", + "activate-paste-mode-base": "Åpne PairDrop på andre enheter for å sende", + "activate-paste-mode-and-other-files": "og {{count}} andre filer", + "activate-paste-mode-shared-text": "delt tekst" + }, + "dialogs": { + "input-key-on-this-device": "Skriv inn denne nøkkelen på en annen enhet", + "pair-devices-title": "Sammenkoble enheter", + "would-like-to-share": "ønsker å dele", + "auto-accept-instructions-2": "for å godkjenne alle filer sendt fra den enheten automatisk.", + "paired-devices-wrapper_data-empty": "Ingen sammenkoblede enheter", + "enter-key-from-another-device": "Skriv inn nøkkel fra en annen enhet for å fortsette.", + "edit-paired-devices-title": "Rediger sammenkoblede enheter", + "accept": "Godta", + "has-sent": "har sendt:", + "base64-paste-to-send": "Trykk her for å sende {{type}}", + "base64-text": "tekst", + "base64-files": "filer", + "file-other-description-image-plural": "og {{count}} andre bilder", + "receive-title": "{{descriptor}} mottatt", + "send-message-title": "Send melding", + "base64-processing": "Behandler…", + "close": "Lukk", + "decline": "Avslå", + "download": "Last ned", + "copy": "Kopier", + "pair": "Sammenkoble", + "cancel": "Avbryt", + "scan-qr-code": "eller skann QR-koden.", + "auto-accept-instructions-1": "Aktiver", + "receive-text-title": "Melding mottatt", + "auto-accept": "auto-godkjenn", + "share": "Del", + "send-message-to": "Send en melding til", + "send": "Send", + "base64-tap-to-paste": "Trykk her for å lime inn {{type}}", + "file-other-description-image": "og ett annet bilde", + "file-other-description-file-plural": "og {{count}} andre filer", + "title-file-plural": "Filer", + "download-again": "Last ned igjen", + "file-other-description-file": "og én annen fil", + "title-image": "Bilde", + "title-file": "Fil", + "title-image-plural": "Bilder" + }, + "about": { + "close-about_aria-label": "Lukk «Om PairDrop»", + "faq_title": "Ofte stilte spørsmål", + "claim": "Den enkleste måten å overføre filer mellom enheter", + "buy-me-a-coffee_title": "Spander drikke.", + "tweet_title": "Tvitre om PairDrop", + "github_title": "PairDrop på GitHub" + }, + "notifications": { + "copied-to-clipboard": "Kopiert til utklippstavlen", + "pairing-tabs-error": "Sammenkobling av to nettleserfaner er ikke mulig.", + "notifications-enabled": "Merknader påskrudd.", + "click-to-show": "Klikk for å vise", + "copied-text": "Tekst kopiert til utklippstavlen", + "connected": "Tilkoblet.", + "online": "Du er tilbake på nett", + "file-transfer-completed": "Filoverføring utført.", + "selected-peer-left": "Valgt likemann dro.", + "pairing-key-invalid": "Ugyldig nøkkel", + "connecting": "Kobler til …", + "pairing-not-persistent": "Sammenkoblede enheter er ikke vedvarende.", + "offline": "Du er frakoblet", + "online-requirement": "Du må være på nett for å koble sammen enheter.", + "display-name-random-again": "Visningsnavnet er tilfeldig generert igjen.", + "display-name-changed-permanently": "Visningsnavnet er endret for godt.", + "display-name-changed-temporarily": "Visningsnavnet er endret kun for denne økten.", + "text-content-incorrect": "Tekstinnholdet er uriktig.", + "file-content-incorrect": "Filinnholdet er uriktig.", + "click-to-download": "Klikk for å laste ned", + "message-transfer-completed": "Meldingsoverføring utført.", + "download-successful": "{{descriptor}} nedlastet", + "pairing-success": "Enheter sammenkoblet.", + "pairing-cleared": "Sammenkobling av alle enheter opphevet.", + "pairing-key-invalidated": "Nøkkel {{key}} ugyldiggjort.", + "copied-text-error": "Kunne ikke legge innhold i utklkippstavlen. Kopier manuelt!", + "clipboard-content-incorrect": "Utklippstavleinnholdet er uriktig.", + "link-received": "Lenke mottatt av {{name}} - Klikk for å åpne.", + "request-title": "{{name}} ønsker å overføre {{count}} {{descriptor}}", + "message-received": "Melding mottatt av {{name}} - Klikk for å åpne.", + "files-incorrect": "Filene er uriktige.", + "ios-memory-limit": "Forsendelse av filer til iOS er kun mulig opptil 200 MB av gangen.", + "unfinished-transfers-warning": "Lukk med ufullførte overføringer?", + "rate-limit-join-key": "Forsøksgrense overskredet. Vent 10 sek. og prøv igjen." + }, + "document-titles": { + "file-received": "Fil mottatt", + "file-received-plural": "{{count}} filer mottatt", + "message-received": "Melding mottatt", + "file-transfer-requested": "Filoverføring forespurt", + "message-received-plural": "{{count}} meldinger mottatt" + }, + "peer-ui": { + "preparing": "Forbereder …", + "waiting": "Venter…", + "processing": "Behandler …", + "transferring": "Overfører …", + "click-to-send": "Klikk for å sende filer, eller høyreklikk for å sende en melding", + "click-to-send-paste-mode": "Klikk for å sende {{descriptor}}", + "connection-hash": "Sammenlign dette sikkerhetsnummeret på begge enhetene for å bekrefte ende-til-ende -krypteringen." + } +} diff --git a/public_included_ws_fallback/lang/ru.json b/public_included_ws_fallback/lang/ru.json new file mode 100644 index 0000000..a4476d3 --- /dev/null +++ b/public_included_ws_fallback/lang/ru.json @@ -0,0 +1,156 @@ +{ + "header": { + "about_aria-label": "Открыть страницу \"О сервисе\"", + "pair-device_title": "Связать ваши устройства навсегда", + "install_title": "Установить PairDrop", + "cancel-paste-mode": "Выполнено", + "edit-paired-devices_title": "Редактировать сопряженные устройства", + "notification_title": "Включить уведомления", + "about_title": "О сервисе", + "theme-auto_title": "Адаптировать тему к системной", + "theme-dark_title": "Всегда использовать темную тему", + "theme-light_title": "Всегда использовать светлую тему", + "join-public-room_title": "Войти на время в публичную комнату", + "language-selector_title": "Выбрать язык" + }, + "instructions": { + "x-instructions_desktop": "Нажмите, чтобы отправить файлы, или щелкните правой кнопкой мыши, чтобы отправить сообщение", + "no-peers_data-drop-bg": "Отпустите, чтобы выбрать получателя", + "click-to-send": "Нажмите, чтобы отправить", + "x-instructions_data-drop-bg": "Отпустите, чтобы выбрать получателя", + "tap-to-send": "Прикоснитесь, чтобы отправить", + "x-instructions_data-drop-peer": "Отпустите, чтобы послать узлу", + "x-instructions_mobile": "Прикоснитесь коротко, чтобы отправить файлы, или долго, чтобы отправить сообщение", + "no-peers-title": "Откройте PairDrop на других устройствах, чтобы отправить файлы", + "no-peers-subtitle": "Сопрягите устройства или войдите в публичную комнату, чтобы вас могли обнаружить из других сетей", + "activate-paste-mode-and-other-files": "и {{count}} других файлов", + "activate-paste-mode-base": "Откройте PairDrop на других устройствах, чтобы отправить", + "activate-paste-mode-shared-text": "общий текст" + }, + "footer": { + "display-name_data-placeholder": "Загрузка…", + "routed": "направляется через сервер", + "webrtc": ", если WebRTC недоступен.", + "traffic": "Трафик", + "paired-devices": "сопряженными устройствами", + "known-as": "Вы известны под именем:", + "on-this-network": "в этой сети", + "display-name_title": "Изменить имя вашего устройства навсегда", + "public-room-devices_title": "Вы можете быть обнаружены устройствами в этой публичной комнате вне зависимости от сети.", + "paired-devices_title": "Вы можете быть обнаружены сопряженными устройствами в любое время вне зависимости от сети.", + "public-room-devices": "в комнате {{roomId}}", + "discovery": "Вы можете быть обнаружены:", + "on-this-network_title": "Вы можете быть обнаружены кем угодно в этой сети." + }, + "dialogs": { + "edit-paired-devices-title": "Редактировать сопряженные устройства", + "auto-accept": "автоприем", + "close": "Закрыть", + "decline": "Отклонить", + "share": "Поделиться", + "would-like-to-share": "хотел бы поделиться", + "has-sent": "отправил:", + "paired-devices-wrapper_data-empty": "Нет сопряженных устройств.", + "download": "Скачать", + "receive-text-title": "Сообщение получено", + "send": "Отправить", + "send-message-to": "Отправить сообщение", + "send-message-title": "Отправить сообщение", + "copy": "Копировать", + "base64-files": "файлы", + "base64-paste-to-send": "Вставьте здесь, чтобы отправить {{type}}", + "base64-processing": "Обработка…", + "base64-tap-to-paste": "Прикоснитесь здесь, чтобы вставить {{type}}", + "base64-text": "текст", + "title-file": "Файл", + "title-file-plural": "Файлы", + "title-image": "Изображение", + "title-image-plural": "Изображения", + "download-again": "Скачать еще раз", + "auto-accept-instructions-2": ", чтобы автоматически принимать все файлы, отправленные с того устройства.", + "enter-key-from-another-device": "Введите сюда ключ с другого устройства.", + "pair-devices-title": "Сопрягите устройства навсегда", + "input-key-on-this-device": "Введите этот ключ на другом устройстве", + "scan-qr-code": "или отсканируйте QR-код.", + "cancel": "Отменить", + "pair": "Подключить", + "accept": "Принять", + "auto-accept-instructions-1": "Активировать", + "file-other-description-file": "и 1 другой файл", + "file-other-description-image-plural": "и {{count}} других изображений", + "file-other-description-image": "и 1 другое изображение", + "file-other-description-file-plural": "и {{count}} других файлов", + "receive-title": "{{descriptor}} получен", + "system-language": "Язык системы", + "unpair": "Разорвать сопряжение", + "language-selector-title": "Выберите язык", + "hr-or": "ИЛИ", + "input-room-id-on-another-device": "Введите этот ID комнаты на другом устройстве" + }, + "about": { + "close-about-aria-label": "Закрыть страницу \"О сервисе\"", + "claim": "Самый простой способ передачи файлов между устройствами", + "close-about_aria-label": "Закрыть страницу \"О сервисе\"", + "buy-me-a-coffee_title": "Купить мне кофе!", + "github_title": "PairDrop на GitHub", + "tweet_title": "Твит о PairDrop", + "faq_title": "Часто задаваемые вопросы" + }, + "notifications": { + "display-name-changed-permanently": "Отображаемое имя было изменено навсегда.", + "display-name-random-again": "Отображаемое имя сгенерировалось случайным образом снова.", + "pairing-success": "Устройства сопряжены.", + "pairing-tabs-error": "Сопряжение двух вкладок браузера невозможно.", + "copied-to-clipboard": "Скопировано в буфер обмена", + "pairing-not-persistent": "Сопряженные устройства непостоянны.", + "link-received": "Получена ссылка от {{name}} - нажмите, чтобы открыть", + "notifications-enabled": "Уведомления включены.", + "text-content-incorrect": "Содержание текста неверно.", + "message-received": "Получено сообщение от {{name}} - нажмите, чтобы скопировать", + "connected": "Подключено.", + "copied-text": "Текст скопирован в буфер обмена", + "online": "Вы снова в сети", + "offline": "Вы находитесь вне сети", + "online-requirement": "Для сопряжения устройств вам нужно быть в сети.", + "files-incorrect": "Файлы неверны.", + "message-transfer-completed": "Передача сообщения завершена.", + "ios-memory-limit": "Отправка файлов на iOS устройства возможна только до 200 МБ за один раз", + "selected-peer-left": "Выбранный узел вышел.", + "request-title": "{{name}} хотел бы передать {{count}} {{descriptor}}", + "rate-limit-join-key": "Достигнут предел скорости. Подождите 10 секунд и повторите попытку.", + "unfinished-transfers-warning": "Есть незавершенные передачи. Вы уверены, что хотите закрыть PairDrop?", + "copied-text-error": "Запись в буфер обмена не удалась. Скопируйте вручную!", + "pairing-cleared": "Все устройства не сопряжены.", + "pairing-key-invalid": "Неверный ключ", + "pairing-key-invalidated": "Ключ {{key}} признан недействительным.", + "click-to-download": "Нажмите, чтобы скачать", + "clipboard-content-incorrect": "Содержание буфера обмена неверно.", + "click-to-show": "Нажмите, чтобы показать", + "connecting": "Подключение…", + "download-successful": "{{descriptor}} загружен", + "display-name-changed-temporarily": "Отображаемое имя было изменено только для этой сессии.", + "file-content-incorrect": "Содержимое файла неверно.", + "file-transfer-completed": "Передача файла завершена.", + "public-room-left": "Покинуть публичную комнату {{publicRoomId}}", + "copied-to-clipboard-error": "Копирование невозможно. Скопируйте вручную.", + "public-room-id-invalid": "Неверный ID комнаты", + "online-requirement-pairing": "Для сопряжения устройств необходимо находиться быть онлайн.", + "online-requirement-public-room": "Для создания публичной комнаты необходимо быть онлайн." + }, + "peer-ui": { + "click-to-send-paste-mode": "Нажмите, чтобы отправить {{descriptor}}", + "preparing": "Подготовка…", + "transferring": "Передача…", + "processing": "Обработка…", + "waiting": "Ожидание…", + "connection-hash": "Чтобы проверить безопасность сквозного шифрования, сравните этот номер безопасности на обоих устройствах", + "click-to-send": "Нажмите, чтобы отправить файлы, или щелкните правой кнопкой мыши, чтобы отправить сообщение" + }, + "document-titles": { + "file-received-plural": "{{count}} файлов получено", + "message-received-plural": "{{count}} сообщений получено", + "file-received": "Файл получен", + "file-transfer-requested": "Запрошена передача файлов", + "message-received": "Сообщение получено" + } +} diff --git a/public_included_ws_fallback/lang/tr.json b/public_included_ws_fallback/lang/tr.json new file mode 100644 index 0000000..87608f2 --- /dev/null +++ b/public_included_ws_fallback/lang/tr.json @@ -0,0 +1,25 @@ +{ + "header": { + "about_title": "PairDrop Hakkında", + "about_aria-label": "PairDrop Hakkında Aç", + "theme-auto_title": "Temayı Sisteme Uyarla", + "theme-light_title": "Daima Açık Tema Kullan", + "theme-dark_title": "Daima Koyu Tema Kullan", + "notification_title": "Bildirimleri Etkinleştir", + "install_title": "PairDrop'u Yükle", + "pair-device_title": "Cihaz Eşleştir", + "edit-paired-devices_title": "Eşleştirilmiş Cihazları Düzenle", + "cancel-paste-mode": "Bitti" + }, + "instructions": { + "no-peers_data-drop-bg": "Alıcıyı seçmek için bırakın" + }, + "footer": { + "display-name_data-placeholder": "Yükleniyor…", + "display-name_title": "Cihazının adını kalıcı olarak düzenle" + }, + "dialogs": { + "cancel": "İptal", + "edit-paired-devices-title": "Eşleştirilmiş Cihazları Düzenle" + } +} diff --git a/public_included_ws_fallback/lang/zh-CN.json b/public_included_ws_fallback/lang/zh-CN.json new file mode 100644 index 0000000..c8b07af --- /dev/null +++ b/public_included_ws_fallback/lang/zh-CN.json @@ -0,0 +1,155 @@ +{ + "header": { + "about_title": "关于 PairDrop", + "about_aria-label": "打开 关于 PairDrop", + "theme-light_title": "总是使用明亮主题", + "install_title": "安装 PairDrop", + "pair-device_title": "长久配对您的设备", + "theme-auto_title": "主题适应系统", + "theme-dark_title": "总是使用暗黑主题", + "notification_title": "开启通知", + "edit-paired-devices_title": "管理已配对设备", + "cancel-paste-mode": "完成", + "join-public-room_title": "暂时加入公共房间", + "language-selector_title": "选择语言" + }, + "instructions": { + "x-instructions_data-drop-peer": "释放以发送到此设备", + "no-peers_data-drop-bg": "释放来选择接收者", + "no-peers-subtitle": "配对新设备 或 加入一个公共房间 使在其他网络上可见", + "no-peers-title": "在其他设备上打开 PairDrop 来发送文件", + "x-instructions_desktop": "点击以发送文件 或 右键来发送信息", + "x-instructions_mobile": "轻触以发送文件 或 长按来发送信息", + "x-instructions_data-drop-bg": "释放来选择接收者", + "click-to-send": "点击发送", + "tap-to-send": "轻触发送", + "activate-paste-mode-base": "在其他设备上打开 PairDrop 来发送", + "activate-paste-mode-and-other-files": "和 {{count}} 个其他的文件", + "activate-paste-mode-shared-text": "分享文本" + }, + "footer": { + "routed": "途径服务器", + "webrtc": "如果 WebRTC 不可用。", + "known-as": "你的名字是:", + "display-name_data-placeholder": "加载中…", + "display-name_title": "修改你的默认设备名", + "on-this-network": "在此网络上", + "paired-devices": "已配对的设备", + "traffic": "流量将", + "public-room-devices_title": "您可以被这个独立于网络的公共房间中的设备发现。", + "paired-devices_title": "您可以在任何时候被已配对的设备发现,而不依赖于网络。", + "public-room-devices": "在房间 {{roomId}} 中", + "discovery": "您可以被发现:", + "on-this-network_title": "您可以被这个网络上的每个人发现。" + }, + "dialogs": { + "pair-devices-title": "配对新设备(常驻)", + "input-key-on-this-device": "在另一个设备上输入这串数字", + "base64-text": "信息", + "enter-key-from-another-device": "在此处输入从另一个设备上获得的数字。", + "edit-paired-devices-title": "管理已配对的设备", + "pair": "配对", + "cancel": "取消", + "scan-qr-code": "或者 扫描二维码。", + "paired-devices-wrapper_data-empty": "无已配对设备。", + "auto-accept-instructions-1": "启用", + "auto-accept": "自动接收", + "decline": "拒绝", + "base64-processing": "处理中…", + "base64-tap-to-paste": "轻触此处粘贴{{type}}", + "base64-paste-to-send": "粘贴到此处以发送 {{type}}", + "auto-accept-instructions-2": "以无需同意而自动接收从那个设备上发送的所有文件。", + "would-like-to-share": "想要分享", + "accept": "接收", + "close": "关闭", + "share": "分享", + "download": "保存", + "send": "发送", + "receive-text-title": "收到信息", + "copy": "复制", + "send-message-title": "发送信息", + "send-message-to": "发了一条信息给", + "has-sent": "发送了:", + "base64-files": "文件", + "file-other-description-file": "和 1 个其他的文件", + "file-other-description-image": "和 1 个其他的图片", + "file-other-description-image-plural": "和 {{count}} 个其他的图片", + "file-other-description-file-plural": "和 {{count}} 个其他的文件", + "title-image-plural": "图片", + "receive-title": "收到 {{descriptor}}", + "title-image": "图片", + "title-file": "文件", + "title-file-plural": "文件", + "download-again": "再次保存", + "system-language": "跟随系统语言", + "unpair": "取消配对", + "language-selector-title": "选择语言", + "hr-or": "或者", + "input-room-id-on-another-device": "在另一个设备上输入这串房间号" + }, + "about": { + "faq_title": "常见问题", + "close-about_aria-label": "关闭 关于 PairDrop", + "github_title": "PairDrop 在 GitHub 上开源", + "claim": "最简单的跨设备传输方案", + "buy-me-a-coffee_title": "帮我买杯咖啡!", + "tweet_title": "关于 PairDrop 的推特" + }, + "notifications": { + "display-name-changed-permanently": "展示的名字已经长久变更。", + "display-name-changed-temporarily": "展示的名字已经变更 仅在此会话中。", + "display-name-random-again": "展示的名字再次随机生成。", + "download-successful": "{{descriptor}} 已下载", + "pairing-tabs-error": "无法配对两个浏览器标签页。", + "pairing-success": "新设备已配对。", + "pairing-not-persistent": "配对的设备不是持久的。", + "pairing-key-invalid": "无效配对码", + "pairing-key-invalidated": "配对码 {{key}} 已失效。", + "text-content-incorrect": "文本内容不合法。", + "file-content-incorrect": "文件内容不合法。", + "clipboard-content-incorrect": "剪贴板内容不合法。", + "link-received": "收到来自 {{name}} 的链接 - 点击打开", + "message-received": "收到来自 {{name}} 的信息 - 点击复制", + "request-title": "{{name}} 想要发送 {{count}} 个 {{descriptor}}", + "click-to-show": "点击展示", + "copied-text": "复制到剪贴板", + "selected-peer-left": "选择的设备已离开。", + "pairing-cleared": "所有设备已解除配对。", + "copied-to-clipboard": "已复制到剪贴板", + "notifications-enabled": "通知已启用。", + "copied-text-error": "写入剪贴板失败。请手动复制!", + "click-to-download": "点击以保存", + "unfinished-transfers-warning": "还有未完成的传输任务。你确定要关闭 PairDrop 吗?", + "message-transfer-completed": "信息传输已完成。", + "offline": "你未连接到网络", + "online": "你已重新连接到网络", + "connected": "已连接。", + "online-requirement": "你需要连接网络来配对新设备。", + "files-incorrect": "文件不合法。", + "file-transfer-completed": "文件传输已完成。", + "connecting": "连接中…", + "ios-memory-limit": "向 iOS 发送文件 一次最多只能发送 200 MB", + "rate-limit-join-key": "已达连接限制。请等待 10秒 后再试。", + "public-room-left": "已退出公共房间 {{publicRoomId}}", + "copied-to-clipboard-error": "无法复制。请手动复制。", + "public-room-id-invalid": "无效的房间号", + "online-requirement-pairing": "您需要连接到互联网来配对新设备。", + "online-requirement-public-room": "您需要连接到互联网来创建一个公共房间。" + }, + "document-titles": { + "message-received": "收到信息", + "message-received-plural": "收到 {{count}} 条信息", + "file-transfer-requested": "文件传输请求", + "file-received-plural": "收到 {{count}} 个文件", + "file-received": "收到文件" + }, + "peer-ui": { + "click-to-send-paste-mode": "点击发送 {{descriptor}}", + "click-to-send": "点击以发送文件 或 右键来发送信息", + "connection-hash": "若要验证端到端加密的安全性,请在两个设备上比较此安全编号", + "preparing": "准备中…", + "waiting": "请等待…", + "transferring": "传输中…", + "processing": "处理中…" + } +} diff --git a/public_included_ws_fallback/scripts/localization.js b/public_included_ws_fallback/scripts/localization.js new file mode 100644 index 0000000..60d3a75 --- /dev/null +++ b/public_included_ws_fallback/scripts/localization.js @@ -0,0 +1,144 @@ +class Localization { + constructor() { + Localization.defaultLocale = "en"; + Localization.supportedLocales = ["en", "nb", "ru", "zh-CN", "de"]; + Localization.translations = {}; + Localization.defaultTranslations = {}; + + Localization.systemLocale = Localization.supportedOrDefault(navigator.languages); + + let storedLanguageCode = localStorage.getItem("language-code"); + + Localization.initialLocale = storedLanguageCode && Localization.isSupported(storedLanguageCode) + ? storedLanguageCode + : Localization.systemLocale; + + Localization.setTranslation(Localization.initialLocale) + .then(_ => { + console.log("Initial translation successful."); + Events.fire("initial-translation-loaded"); + }); + } + + static isSupported(locale) { + return Localization.supportedLocales.indexOf(locale) > -1; + } + + static supportedOrDefault(locales) { + return locales.find(Localization.isSupported) || Localization.defaultLocale; + } + + static async setTranslation(locale) { + if (!locale) locale = Localization.systemLocale; + + await Localization.setLocale(locale) + await Localization.translatePage(); + + console.log("Page successfully translated", + `System language: ${Localization.systemLocale}`, + `Selected language: ${locale}` + ); + + Events.fire("translation-loaded"); + } + + static async setLocale(newLocale) { + if (newLocale === Localization.locale) return false; + + Localization.defaultTranslations = await Localization.fetchTranslationsFor(Localization.defaultLocale); + + const newTranslations = await Localization.fetchTranslationsFor(newLocale); + + if(!newTranslations) return false; + + Localization.locale = newLocale; + Localization.translations = newTranslations; + } + + static getLocale() { + return Localization.locale; + } + + static isSystemLocale() { + return !localStorage.getItem('language-code'); + } + + static async fetchTranslationsFor(newLocale) { + const response = await fetch(`lang/${newLocale}.json`) + + if (response.redirected === true || response.status !== 200) return false; + + return await response.json(); + } + + static async translatePage() { + document + .querySelectorAll("[data-i18n-key]") + .forEach(element => Localization.translateElement(element)); + } + + static async translateElement(element) { + const key = element.getAttribute("data-i18n-key"); + const attrs = element.getAttribute("data-i18n-attrs").split(" "); + + for (let i in attrs) { + let attr = attrs[i]; + if (attr === "text") { + element.innerText = Localization.getTranslation(key); + } else { + if (attr.startsWith("data-")) { + let dataAttr = attr.substring(5); + element.dataset.dataAttr = Localization.getTranslation(key, attr); + } { + element.setAttribute(attr, Localization.getTranslation(key, attr)); + } + } + } + } + + static getTranslation(key, attr=null, data={}, useDefault=false) { + const keys = key.split("."); + + let translationCandidates = useDefault + ? Localization.defaultTranslations + : Localization.translations; + + let translation; + + try { + for (let i = 0; i < keys.length - 1; i++) { + translationCandidates = translationCandidates[keys[i]] + } + + let lastKey = keys[keys.length - 1]; + + if (attr) lastKey += "_" + attr; + + translation = translationCandidates[lastKey]; + + for (let j in data) { + translation = translation.replace(`{{${j}}}`, data[j]); + } + } catch (e) { + translation = ""; + } + + if (!translation) { + if (!useDefault) { + translation = this.getTranslation(key, attr, data, true); + console.warn(`Missing translation entry for your language ${Localization.locale.toUpperCase()}. Using ${Localization.defaultLocale.toUpperCase()} instead.`, key, attr); + console.warn("Help translating PairDrop: https://hosted.weblate.org/projects/pairdrop/pairdrop-spa/"); + } else { + console.warn("Missing translation in default language:", key, attr); + } + } + + return Localization.escapeHTML(translation); + } + + static escapeHTML(unsafeText) { + let div = document.createElement('div'); + div.innerText = unsafeText; + return div.innerHTML; + } +} diff --git a/public_included_ws_fallback/scripts/network.js b/public_included_ws_fallback/scripts/network.js index 41c7fa4..87e4ea2 100644 --- a/public_included_ws_fallback/scripts/network.js +++ b/public_included_ws_fallback/scripts/network.js @@ -1,5 +1,5 @@ window.URL = window.URL || window.webkitURL; -window.isRtcSupported = !!(window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection); +window.isRtcSupported = false; //!!(window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection); window.hiddenProperty = 'hidden' in document ? 'hidden' : 'webkitHidden' in document ? 'webkitHidden' : @@ -21,10 +21,14 @@ class ServerConnection { Events.on('join-ip-room', e => this.send({ type: 'join-ip-room'})); Events.on('room-secrets-deleted', e => this.send({ type: 'room-secrets-deleted', roomSecrets: e.detail})); Events.on('regenerate-room-secret', e => this.send({ type: 'regenerate-room-secret', roomSecret: e.detail})); - Events.on('resend-peers', _ => this.send({ type: 'resend-peers'})); 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('create-public-room', _ => this._onCreatePublicRoom()); + Events.on('join-public-room', e => this._onJoinPublicRoom(e.detail.roomId, e.detail.createIfInvalid)); + Events.on('leave-public-room', _ => this._onLeavePublicRoom()); + Events.on('offline', _ => clearTimeout(this._reconnectTimer)); Events.on('online', _ => this._connect()); } @@ -44,23 +48,47 @@ class ServerConnection { _onOpen() { console.log('WS: server connected'); Events.fire('ws-connected'); - if (this._isReconnect) Events.fire('notify-user', 'Connected.'); + if (this._isReconnect) Events.fire('notify-user', Localization.getTranslation("notifications.connected")); } _onPairDeviceInitiate() { if (!this._isConnected()) { - Events.fire('notify-user', 'You need to be online to pair devices.'); + Events.fire('notify-user', Localization.getTranslation("notifications.online-requirement-pairing")); return; } - this.send({ type: 'pair-device-initiate' }) + this.send({ type: 'pair-device-initiate' }); } - _onPairDeviceJoin(roomKey) { + _onPairDeviceJoin(pairKey) { if (!this._isConnected()) { - setTimeout(_ => this._onPairDeviceJoin(roomKey), 1000); + setTimeout(_ => this._onPairDeviceJoin(pairKey), 1000); return; } - this.send({ type: 'pair-device-join', roomKey: roomKey }) + this.send({ type: 'pair-device-join', pairKey: pairKey }); + } + + _onCreatePublicRoom() { + if (!this._isConnected()) { + Events.fire('notify-user', Localization.getTranslation("notifications.online-requirement-public-room")); + return; + } + this.send({ type: 'create-public-room' }); + } + + _onJoinPublicRoom(roomId, createIfInvalid) { + if (!this._isConnected()) { + setTimeout(_ => this._onJoinPublicRoom(roomId), 1000); + return; + } + this.send({ type: 'join-public-room', publicRoomId: roomId, createIfInvalid: createIfInvalid }); + } + + _onLeavePublicRoom() { + if (!this._isConnected()) { + setTimeout(_ => this._onLeavePublicRoom(), 1000); + return; + } + this.send({ type: 'leave-public-room' }); } _setRtcConfig(config) { @@ -102,10 +130,10 @@ class ServerConnection { Events.fire('pair-device-join-key-invalid'); break; case 'pair-device-canceled': - Events.fire('pair-device-canceled', msg.roomKey); + Events.fire('pair-device-canceled', msg.pairKey); break; - case 'pair-device-join-key-rate-limit': - Events.fire('notify-user', 'Rate limit reached. Wait 10 seconds and try again.'); + case 'join-key-rate-limit': + Events.fire('notify-user', Localization.getTranslation("notifications.rate-limit-join-key")); break; case 'secret-room-deleted': Events.fire('secret-room-deleted', msg.roomSecret); @@ -113,6 +141,15 @@ class ServerConnection { case 'room-secret-regenerated': Events.fire('room-secret-regenerated', msg); break; + case 'public-room-id-invalid': + Events.fire('public-room-id-invalid', msg.publicRoomId); + break; + case 'public-room-created': + Events.fire('public-room-created', msg.roomId); + break; + case 'public-room-left': + Events.fire('public-room-left'); + break; case 'request': case 'header': case 'partition': @@ -139,18 +176,12 @@ class ServerConnection { _onPeers(msg) { Events.fire('peers', msg); - if (msg.roomType === "ip" && msg.peers.length === 0) { - BrowserTabsConnector.removeOtherPeerIdsFromLocalStorage().then(peerId => { - if (!peerId) return; - console.log("successfully removed other peerIds from localStorage"); - }); - } } _onDisplayName(msg) { // Add peerId and peerIdHash to sessionStorage to authenticate as the same device on page reload - sessionStorage.setItem("peerId", msg.message.peerId); - sessionStorage.setItem("peerIdHash", msg.message.peerIdHash); + sessionStorage.setItem('peer_id', msg.message.peerId); + sessionStorage.setItem('peer_id_hash', msg.message.peerIdHash); // Add peerId to localStorage to mark it for other PairDrop tabs on the same browser BrowserTabsConnector.addPeerIdToLocalStorage().then(peerId => { @@ -172,8 +203,8 @@ class ServerConnection { const protocol = location.protocol.startsWith('https') ? 'wss' : 'ws'; const webrtc = window.isRtcSupported ? '/webrtc' : '/fallback'; let ws_url = new URL(protocol + '://' + location.host + location.pathname + 'server' + webrtc); - const peerId = sessionStorage.getItem("peerId"); - const peerIdHash = sessionStorage.getItem("peerIdHash"); + const peerId = sessionStorage.getItem('peer_id'); + const peerIdHash = sessionStorage.getItem('peer_id_hash'); if (peerId && peerIdHash) { ws_url.searchParams.append('peer_id', peerId); ws_url.searchParams.append('peer_id_hash', peerIdHash); @@ -184,7 +215,7 @@ class ServerConnection { _disconnect() { this.send({ type: 'disconnect' }); - const peerId = sessionStorage.getItem("peerId"); + const peerId = sessionStorage.getItem('peer_id'); BrowserTabsConnector.removePeerIdFromLocalStorage(peerId).then(_ => { console.log("successfully removed peerId from localStorage"); }); @@ -200,7 +231,7 @@ class ServerConnection { _onDisconnect() { console.log('WS: server disconnected'); - Events.fire('notify-user', 'Connecting..'); + Events.fire('notify-user', Localization.getTranslation("notifications.connecting")); clearTimeout(this._reconnectTimer); this._reconnectTimer = setTimeout(_ => this._connect(), 1000); Events.fire('ws-disconnected'); @@ -232,12 +263,13 @@ class ServerConnection { class Peer { - constructor(serverConnection, isCaller, peerId, roomType, roomSecret) { + constructor(serverConnection, isCaller, peerId, roomType, roomId) { this._server = serverConnection; this._isCaller = isCaller; this._peerId = peerId; - this._roomType = roomType; - this._updateRoomSecret(roomSecret); + + this._roomIds = {}; + this._updateRoomIds(roomType, roomId); this._filesQueue = []; this._busy = false; @@ -258,34 +290,58 @@ class Peer { return BrowserTabsConnector.peerIsSameBrowser(this._peerId); } - _updateRoomSecret(roomSecret) { + _isPaired() { + return !!this._roomIds['secret']; + } + + _getPairSecret() { + return this._roomIds['secret']; + } + + _getRoomTypes() { + return Object.keys(this._roomIds); + } + + _updateRoomIds(roomType, roomId) { // if peer is another browser tab, peer is not identifiable with roomSecret as browser tabs share all roomSecrets // -> do not delete duplicates and do not regenerate room secrets - if (!this._isSameBrowser() && this._roomSecret && this._roomSecret !== roomSecret) { - // remove old roomSecrets to prevent multiple pairings with same peer - PersistentStorage.deleteRoomSecret(this._roomSecret).then(deletedRoomSecret => { - if (deletedRoomSecret) console.log("Successfully deleted duplicate room secret with same peer: ", deletedRoomSecret); - }) + if (!this._isSameBrowser() && roomType === "secret" && this._isPaired() && this._getPairSecret() !== roomId) { + // multiple roomSecrets with same peer -> delete old roomSecret + PersistentStorage.deleteRoomSecret(this._getPairSecret()) + .then(deletedRoomSecret => { + if (deletedRoomSecret) console.log("Successfully deleted duplicate room secret with same peer: ", deletedRoomSecret); + }); } - this._roomSecret = roomSecret; + this._roomIds[roomType] = roomId; - if (!this._isSameBrowser() && this._roomSecret && this._roomSecret.length !== 256 && this._isCaller) { - // increase security by increasing roomSecret length + if (!this._isSameBrowser() && roomType === "secret" && this._isPaired() && this._getPairSecret().length !== 256 && this._isCaller) { + // increase security by initiating the increase of the roomSecret length from 64 chars ( { - const autoAccept = roomSecretEntry ? roomSecretEntry.entry.auto_accept : false; + const autoAccept = roomSecretEntry + ? roomSecretEntry.entry.auto_accept + : false; this._setAutoAccept(autoAccept); }) .catch(_ => { @@ -294,7 +350,9 @@ class Peer { } _setAutoAccept(autoAccept) { - this._autoAccept = autoAccept; + this._autoAccept = !this._isSameBrowser() + ? autoAccept + : false; } getResizedImageDataUrl(file, width = undefined, height = undefined, quality = 0.7) { @@ -505,7 +563,7 @@ class Peer { _abortTransfer() { Events.fire('set-progress', {peerId: this._peerId, progress: 1, status: 'wait'}); - Events.fire('notify-user', 'Files are incorrect.'); + Events.fire('notify-user', Localization.getTranslation("notifications.files-incorrect")); this._filesReceived = []; this._requestAccepted = null; this._digester = null; @@ -546,14 +604,14 @@ class Peer { this._abortTransfer(); } - // include for compatibility with Snapdrop for Android app + // include for compatibility with 'Snapdrop & PairDrop for Android' app Events.fire('file-received', fileBlob); this._filesReceived.push(fileBlob); if (!this._requestAccepted.header.length) { this._busy = false; Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: 'process'}); - Events.fire('files-received', {sender: this._peerId, files: this._filesReceived, imagesOnly: this._requestAccepted.imagesOnly, totalSize: this._requestAccepted.totalSize}); + Events.fire('files-received', {peerId: this._peerId, files: this._filesReceived, imagesOnly: this._requestAccepted.imagesOnly, totalSize: this._requestAccepted.totalSize}); this._filesReceived = []; this._requestAccepted = null; } @@ -563,7 +621,8 @@ class Peer { this._chunker = null; if (!this._filesQueue.length) { this._busy = false; - Events.fire('notify-user', 'File transfer completed.'); + Events.fire('notify-user', Localization.getTranslation("notifications.file-transfer-completed")); + Events.fire('files-sent'); // used by 'Snapdrop & PairDrop for Android' app } else { this._dequeueFile(); } @@ -574,7 +633,7 @@ class Peer { Events.fire('set-progress', {peerId: this._peerId, progress: 1, status: 'wait'}); this._filesRequested = null; if (message.reason === 'ios-memory-limit') { - Events.fire('notify-user', "Sending files to iOS is only possible up to 200MB at once"); + Events.fire('notify-user', Localization.getTranslation("notifications.ios-memory-limit")); } return; } @@ -584,7 +643,7 @@ class Peer { } _onMessageTransferCompleted() { - Events.fire('notify-user', 'Message transfer completed.'); + Events.fire('notify-user', Localization.getTranslation("notifications.message-transfer-completed")); } sendText(text) { @@ -615,8 +674,8 @@ class Peer { class RTCPeer extends Peer { - constructor(serverConnection, isCaller, peerId, roomType, roomSecret) { - super(serverConnection, isCaller, peerId, roomType, roomSecret); + constructor(serverConnection, isCaller, peerId, roomType, roomId) { + super(serverConnection, isCaller, peerId, roomType, roomId); this.rtcSupported = true; if (!this._isCaller) return; // we will listen for a caller this._connect(); @@ -642,13 +701,17 @@ class RTCPeer extends Peer { _openChannel() { if (!this._conn) return; + const channel = this._conn.createDataChannel('data-channel', { ordered: true, reliable: true // Obsolete. See https://developer.mozilla.org/en-US/docs/Web/API/RTCDataChannel/reliable }); channel.onopen = e => this._onChannelOpened(e); channel.onerror = e => this._onError(e); - this._conn.createOffer().then(d => this._onDescription(d)).catch(e => this._onError(e)); + + this._conn.createOffer() + .then(d => this._onDescription(d)) + .catch(e => this._onError(e)); } _onDescription(description) { @@ -729,7 +792,7 @@ class RTCPeer extends Peer { _onBeforeUnload(e) { if (this._busy) { e.preventDefault(); - return "There are unfinished transfers. Are you sure you want to close?"; + return Localization.getTranslation("notifications.unfinished-transfers-warning"); } } @@ -788,8 +851,8 @@ class RTCPeer extends Peer { _sendSignal(signal) { signal.type = 'signal'; signal.to = this._peerId; - signal.roomType = this._roomType; - signal.roomSecret = this._roomSecret; + signal.roomType = this._getRoomTypes()[0]; + signal.roomId = this._roomIds[this._getRoomTypes()[0]]; this._server.send(signal); } @@ -835,8 +898,8 @@ class WSPeer extends Peer { sendJSON(message) { message.to = this._peerId; - message.roomType = this._roomType; - message.roomSecret = this._roomSecret; + message.roomType = this._getRoomTypes()[0]; + message.roomId = this._roomIds[this._getRoomTypes()[0]]; this._server.send(message); } @@ -871,7 +934,14 @@ class PeersManager { Events.on('peer-joined', e => this._onPeerJoined(e.detail)); Events.on('peer-connected', e => this._onPeerConnected(e.detail.peerId)); Events.on('peer-disconnected', e => this._onPeerDisconnected(e.detail)); + + // this device closes connection + Events.on('room-secrets-deleted', e => this._onRoomSecretsDeleted(e.detail)); + Events.on('leave-public-room', e => this._onLeavePublicRoom(e.detail)); + + // peer closes connection Events.on('secret-room-deleted', e => this._onSecretRoomDeleted(e.detail)); + Events.on('room-secret-regenerated', e => this._onRoomSecretRegenerated(e.detail)); Events.on('display-name', e => this._onDisplayName(e.detail.message.displayName)); Events.on('self-display-name-changed', e => this._notifyPeersDisplayNameChanged(e.detail)); @@ -886,51 +956,45 @@ class PeersManager { this.peers[peerId].onServerMessage(message); } - _refreshPeer(peer, roomType, roomSecret) { + _refreshPeer(peer, roomType, roomId) { if (!peer) return false; - const roomTypeIsSecret = roomType === "secret"; - const roomSecretsDiffer = peer._roomSecret !== roomSecret; + const roomTypesDiffer = Object.keys(peer._roomIds)[0] !== roomType; + const roomIdsDiffer = peer._roomIds[roomType] !== roomId; - // if roomSecrets differs peer is already connected -> abort but update roomSecret and reevaluate auto accept - if (roomTypeIsSecret && roomSecretsDiffer) { - peer._updateRoomSecret(roomSecret); + // if roomType or roomId for roomType differs peer is already connected + // -> only update roomSecret and reevaluate auto accept + if (roomTypesDiffer || roomIdsDiffer) { + peer._updateRoomIds(roomType, roomId); peer._evaluateAutoAccept(); return true; } - const roomTypesDiffer = peer._roomType !== roomType; - - // if roomTypes differ peer is already connected -> abort - if (roomTypesDiffer) return true; - - peer.refresh(); - return true; } - _createOrRefreshPeer(isCaller, peerId, roomType, roomSecret, rtcSupported) { + _createOrRefreshPeer(isCaller, peerId, roomType, roomId, rtcSupported) { const peer = this.peers[peerId]; if (peer) { - this._refreshPeer(peer, roomType, roomSecret); + this._refreshPeer(peer, roomType, roomId); return; } if (window.isRtcSupported && rtcSupported) { - this.peers[peerId] = new RTCPeer(this._server,isCaller, peerId, roomType, roomSecret); + this.peers[peerId] = new RTCPeer(this._server,isCaller, peerId, roomType, roomId); } else { - this.peers[peerId] = new WSPeer(this._server, isCaller, peerId, roomType, roomSecret); + this.peers[peerId] = new WSPeer(this._server, isCaller, peerId, roomType, roomId); } } _onPeerJoined(message) { - this._createOrRefreshPeer(false, message.peer.id, message.roomType, message.roomSecret, message.peer.rtcSupported); + this._createOrRefreshPeer(false, message.peer.id, message.roomType, message.roomId, message.peer.rtcSupported); } _onPeers(message) { message.peers.forEach(peer => { - this._createOrRefreshPeer(true, peer.id, message.roomType, message.roomSecret, peer.rtcSupported); + this._createOrRefreshPeer(true, peer.id, message.roomType, message.roomId, peer.rtcSupported); }) } @@ -970,7 +1034,7 @@ class PeersManager { } if (message.disconnect === true) { // if user actively disconnected from PairDrop server, disconnect all peer to peer connections immediately - Events.fire('peer-disconnected', message.peerId); + this._disconnectOrRemoveRoomTypeByPeerId(message.peerId, message.roomType); // If no peers are connected anymore, we can safely assume that no other tab on the same browser is connected: // Tidy up peerIds in localStorage @@ -1002,14 +1066,42 @@ class PeersManager { if (peer._channel) peer._channel.onclose = null; peer._conn.close(); peer._busy = false; + peer._roomIds = {}; + } + + _onRoomSecretsDeleted(roomSecrets) { + for (let i=0; i 1) { + peer._removeRoomType(roomType); + } else { + Events.fire('peer-disconnected', peerId); } } @@ -1040,20 +1132,26 @@ class PeersManager { } _onAutoAcceptUpdated(roomSecret, autoAccept) { - const peerId = this._getPeerIdFromRoomSecret(roomSecret); + const peerId = this._getPeerIdsFromRoomId(roomSecret)[0]; + if (!peerId) return; + this.peers[peerId]._setAutoAccept(autoAccept); } - _getPeerIdFromRoomSecret(roomSecret) { + _getPeerIdsFromRoomId(roomId) { + if (!roomId) return []; + + let peerIds = [] for (const peerId in this.peers) { const peer = this.peers[peerId]; - // peer must have same roomSecret and not be on the same browser. - if (peer._roomSecret === roomSecret && !peer._isSameBrowser()) { - return peer._peerId; + + // peer must have same roomId. + if (Object.values(peer._roomIds).includes(roomId)) { + peerIds.push(peer._peerId); } } - return false; + return peerIds; } } @@ -1139,7 +1237,7 @@ class FileDigester { } class Events { - static fire(type, detail) { + static fire(type, detail = {}) { window.dispatchEvent(new CustomEvent(type, { detail: detail })); } diff --git a/public_included_ws_fallback/scripts/ui.js b/public_included_ws_fallback/scripts/ui.js index d355468..2304644 100644 --- a/public_included_ws_fallback/scripts/ui.js +++ b/public_included_ws_fallback/scripts/ui.js @@ -1,8 +1,8 @@ const $ = query => document.getElementById(query); const $$ = query => document.body.querySelector(query); -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.isMobile = window.iOS || window.android; window.pasteMode = {}; window.pasteMode.activated = false; @@ -22,7 +22,7 @@ class PeersUI { Events.on('peers', e => this._onPeers(e.detail)); Events.on('set-progress', e => this._onSetProgress(e.detail)); Events.on('paste', e => this._onPaste(e)); - Events.on('secret-room-deleted', e => this._onSecretRoomDeleted(e.detail)); + Events.on('room-type-removed', e => this._onRoomTypeRemoved(e.detail.peerId, e.detail.roomType)); Events.on('activate-paste-mode', e => this._activatePasteMode(e.detail.files, e.detail.text)); this.peers = {}; @@ -45,6 +45,8 @@ class PeersUI { this.$displayName = $('display-name'); + this.$displayName.setAttribute("placeholder", this.$displayName.dataset.placeholder); + this.$displayName.addEventListener('keydown', e => this._onKeyDownDisplayName(e)); this.$displayName.addEventListener('keyup', e => this._onKeyUpDisplayName(e)); this.$displayName.addEventListener('blur', e => this._saveDisplayName(e.target.innerText)); @@ -89,12 +91,12 @@ class PeersUI { if (newDisplayName) { PersistentStorage.set('editedDisplayName', newDisplayName) .then(_ => { - Events.fire('notify-user', 'Device name is changed permanently.'); + Events.fire('notify-user', Localization.getTranslation("notifications.display-name-changed-permanently")); }) .catch(_ => { console.log("This browser does not support IndexedDB. Use localStorage instead."); localStorage.setItem('editedDisplayName', newDisplayName); - Events.fire('notify-user', 'Device name is changed only for this session.'); + Events.fire('notify-user', Localization.getTranslation("notifications.display-name-changed-temporarily")); }) .finally(_ => { Events.fire('self-display-name-changed', newDisplayName); @@ -105,10 +107,9 @@ class PeersUI { .catch(_ => { console.log("This browser does not support IndexedDB. Use localStorage instead.") localStorage.removeItem('editedDisplayName'); - Events.fire('notify-user', 'Random Display name is used again.'); }) .finally(_ => { - Events.fire('notify-user', 'Device name is randomly generated again.'); + Events.fire('notify-user', Localization.getTranslation("notifications.display-name-random-again")); Events.fire('self-display-name-changed', ''); Events.fire('broadcast-send', {type: 'self-display-name-changed', detail: ''}); }); @@ -149,32 +150,22 @@ class PeersUI { } _onPeerJoined(msg) { - this._joinPeer(msg.peer, msg.roomType, msg.roomSecret); + this._joinPeer(msg.peer, msg.roomType, msg.roomId); } - _joinPeer(peer, roomType, roomSecret) { + _joinPeer(peer, roomType, roomId) { const existingPeer = this.peers[peer.id]; if (existingPeer) { - // peer already exists. Abort but add roomType to GUI and update roomSecret - // skip if peer is a tab on the same browser - if (!existingPeer.sameBrowser()) { - // add roomType to PeerUI - if (!existingPeer.roomTypes.includes(roomType)) { - existingPeer.roomTypes.push(roomType); - } - this._redrawPeerRoomTypes(peer.id); - - if (roomType === "secret") existingPeer.roomSecret = roomSecret; - } + // peer already exists. Abort but add roomType to GUI + existingPeer._roomIds[roomType] = roomId; + this._redrawPeerRoomTypes(peer.id); return; } - peer.sameBrowser = _ => BrowserTabsConnector.peerIsSameBrowser(peer.id); - if (!(roomType === "secret" && peer.sameBrowser())) { - peer.roomTypes = [roomType]; - peer.roomSecret = roomSecret; - } + peer._isSameBrowser = _ => BrowserTabsConnector.peerIsSameBrowser(peer.id); + peer._roomIds = {}; + peer._roomIds[roomType] = roomId; this.peers[peer.id] = peer; } @@ -187,13 +178,18 @@ class PeersUI { } _redrawPeerRoomTypes(peerId) { - const peer = this.peers[peerId] + const peer = this.peers[peerId]; const peerNode = $(peerId); - if (!peerNode) return; - peerNode.classList.remove('type-ip', 'type-secret'); - if (!peer.sameBrowser()) { - peer.roomTypes.forEach(roomType => peerNode.classList.add(`type-${roomType}`)); + + if (!peer || !peerNode) return; + + peerNode.classList.remove('type-ip', 'type-secret', 'type-public-id', 'type-same-browser'); + + if (peer._isSameBrowser()) { + peerNode.classList.add(`type-same-browser`); } + + Object.keys(peer._roomIds).forEach(roomType => peerNode.classList.add(`type-${roomType}`)); } evaluateOverflowing() { @@ -205,7 +201,7 @@ class PeersUI { } _onPeers(msg) { - msg.peers.forEach(peer => this._joinPeer(peer, msg.roomType, msg.roomSecret)); + msg.peers.forEach(peer => this._joinPeer(peer, msg.roomType, msg.roomId)); } _onPeerDisconnected(peerId) { @@ -215,23 +211,14 @@ class PeersUI { this.evaluateOverflowing(); } - _onSecretRoomDeleted(roomSecret) { - for (const peerId in this.peers) { - const peer = this.peers[peerId]; - if (peer.roomSecret === roomSecret) { - let index = peer.roomTypes.indexOf('secret'); - peer.roomTypes.splice(index, 1); - peer.roomSecret = ""; + _onRoomTypeRemoved(peerId, roomType) { + const peer = this.peers[peerId]; - if (peer.roomTypes.length) { - this._redrawPeerRoomTypes(peerId) - return; - } + if (!peer) return; - this._onPeerDisconnected(peerId); - return; - } - } + delete peer._roomIds[roomType]; + + this._redrawPeerRoomTypes(peerId) } _onSetProgress(progress) { @@ -272,26 +259,28 @@ class PeersUI { _activatePasteMode(files, text) { if (!window.pasteMode.activated && (files.length > 0 || text.length > 0)) { + const openPairDrop = Localization.getTranslation("instructions.activate-paste-mode-base"); + const andOtherFiles = Localization.getTranslation("instructions.activate-paste-mode-and-other-files", null, {count: files.length-1}); + const sharedText = Localization.getTranslation("instructions.activate-paste-mode-shared-text"); + const clickToSend = Localization.getTranslation("instructions.click-to-send") + const tapToSend = Localization.getTranslation("instructions.tap-to-send") + let descriptor; - let noPeersMessage; if (files.length === 1) { - descriptor = files[0].name; - noPeersMessage = `Open PairDrop on other devices to send
${descriptor}`; + descriptor = `${files[0].name}`; } else if (files.length > 1) { - descriptor = `${files[0].name} and ${files.length-1} other files`; - noPeersMessage = `Open PairDrop on other devices to send
${descriptor}`; + descriptor = `${files[0].name}
${andOtherFiles}`; } else { - descriptor = "shared text"; - noPeersMessage = `Open PairDrop on other devices to send
${descriptor}`; + descriptor = sharedText; } this.$xInstructions.querySelector('p').innerHTML = `${descriptor}`; this.$xInstructions.querySelector('p').style.display = 'block'; - this.$xInstructions.setAttribute('desktop', `Click to send`); - this.$xInstructions.setAttribute('mobile', `Tap to send`); + this.$xInstructions.setAttribute('desktop', clickToSend); + this.$xInstructions.setAttribute('mobile', tapToSend); - this.$xNoPeers.querySelector('h2').innerHTML = noPeersMessage; + this.$xNoPeers.querySelector('h2').innerHTML = `${openPairDrop}
${descriptor}`; const _callback = (e) => this._sendClipboardData(e, files, text); Events.on('paste-pointerdown', _callback); @@ -320,10 +309,10 @@ class PeersUI { this.$xInstructions.querySelector('p').innerText = ''; this.$xInstructions.querySelector('p').style.display = 'none'; - this.$xInstructions.setAttribute('desktop', 'Click to send files or right click to send a message'); - this.$xInstructions.setAttribute('mobile', 'Tap to send files or long tap to send a message'); + this.$xInstructions.setAttribute('desktop', Localization.getTranslation("instructions.x-instructions", "desktop")); + this.$xInstructions.setAttribute('mobile', Localization.getTranslation("instructions.x-instructions", "mobile")); - this.$xNoPeers.querySelector('h2').innerHTML = 'Open PairDrop on other devices to send files'; + this.$xNoPeers.querySelector('h2').innerHTML = Localization.getTranslation("instructions.no-peers-title"); this.$cancelPasteModeBtn.setAttribute('hidden', ""); @@ -368,20 +357,22 @@ class PeerUI { let title; let input = ''; if (window.pasteMode.activated) { - title = `Click to send ${window.pasteMode.descriptor}`; + title = Localization.getTranslation("peer-ui.click-to-send-paste-mode", null, {descriptor: window.pasteMode.descriptor}); } else { - title = 'Click to send files or right click to send a message'; + title = Localization.getTranslation("peer-ui.click-to-send"); input = ''; } this.$el.innerHTML = ` -
-
` $pairedDevice.querySelector('input[type="checkbox"]').addEventListener('click', e => { @@ -1239,6 +1435,7 @@ class EditPairedDevicesDialog extends Dialog { }); }) + this.$pairedDevicesWrapper.html = ""; this.$pairedDevicesWrapper.appendChild($pairedDevice) }) @@ -1261,7 +1458,7 @@ class EditPairedDevicesDialog extends Dialog { PersistentStorage.clearRoomSecrets().finally(_ => { Events.fire('room-secrets-deleted', roomSecrets); Events.fire('evaluate-number-room-secrets'); - Events.fire('notify-user', 'All Devices unpaired.'); + Events.fire('notify-user', Localization.getTranslation("notifications.pairing-cleared")); this.hide(); }) }); @@ -1275,14 +1472,237 @@ class EditPairedDevicesDialog extends Dialog { const peer = peerNode.ui._peer; - if (!peer.roomSecret) return; + if (!peer || !peer._roomIds["secret"]) return; - PersistentStorage.updateRoomSecretNames(peer.roomSecret, peer.name.displayName, peer.name.deviceName).then(roomSecretEntry => { + PersistentStorage.updateRoomSecretNames(peer._roomIds["secret"], peer.name.displayName, peer.name.deviceName).then(roomSecretEntry => { console.log(`Successfully updated DisplayName and DeviceName for roomSecretEntry ${roomSecretEntry.key}`); }) } } +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.$footerInstructionsPublicRoomDevices = $$('.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.$footerInstructionsPublicRoomDevices.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", ""), + () => 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)); + 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.evaluateUrlAttributes(); + + Events.on('ws-connected', _ => this._onWsConnected()); + Events.on('translation-loaded', _ => this.setFooterBadge()); + } + + _onKeyDown(e) { + if (this.isShown() && 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.setIdAndQrCode(); + + this.show(); + + sessionStorage.setItem('public_room_id', roomId); + } + + setIdAndQrCode() { + 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: 150, + height: 150, + padding: 0, + background: "transparent", + color: `rgb(var(--text-color))`, + ecl: "L", + join: true + }); + this.$qrCode.innerHTML = qr.svg(); + + this.setFooterBadge(); + } + + setFooterBadge() { + if (!this.roomId) return; + + this.$footerInstructionsPublicRoomDevices.innerText = Localization.getTranslation("footer.public-room-devices", null, { + roomId: this.roomId.toUpperCase() + }); + this.$footerInstructionsPublicRoomDevices.removeAttribute('hidden'); + + super.evaluateFooterBadges(); + } + + _getShareRoomURL() { + let url = new URL(location.href); + url.searchParams.append('room_key', this.roomId) + return url.href; + } + + evaluateUrlAttributes() { + const urlParams = new URLSearchParams(window.location.search); + if (urlParams.has('room_key')) { + this._joinPublicRoom(urlParams.get('room_key')); + const url = getUrlWithoutArguments(); + window.history.replaceState({}, "Rewrite URL", url); //remove pair_key from url + } + } + + _onWsConnected() { + let roomId = sessionStorage.getItem('public_room_id'); + + if (!roomId) return; + + this.roomId = roomId; + this.setIdAndQrCode(); + + 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(message) { + this._evaluateJoinedPeer(message.peer.id, message.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.setIdAndQrCode(); + } + } + + _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.$footerInstructionsPublicRoomDevices.setAttribute('hidden', ''); + super.evaluateFooterBadges(); + } +} + class SendTextDialog extends Dialog { constructor() { super('send-text-dialog'); @@ -1293,7 +1713,7 @@ class SendTextDialog extends Dialog { this.$submit = this.$el.querySelector('button[type="submit"]'); this.$form.addEventListener('submit', e => this._onSubmit(e)); this.$text.addEventListener('input', e => this._onChange(e)); - Events.on("keydown", e => this._onKeyDown(e)); + Events.on('keydown', e => this._onKeyDown(e)); } async _onKeyDown(e) { @@ -1322,6 +1742,9 @@ class SendTextDialog extends Dialog { _onRecipient(peerId, deviceName) { this.correspondingPeerId = peerId; this.$peerDisplayName.innerText = deviceName; + this.$peerDisplayName.classList.remove("badge-room-ip", "badge-room-secret", "badge-room-public-id"); + this.$peerDisplayName.classList.add($(peerId).ui._badgeClassName()); + this.show(); const range = document.createRange(); @@ -1359,9 +1782,9 @@ class ReceiveTextDialog extends Dialog { this.$copy.addEventListener('click', _ => this._onCopy()); this.$close.addEventListener('click', _ => this.hide()); - Events.on("keydown", e => this._onKeyDown(e)); + Events.on('keydown', e => this._onKeyDown(e)); - this.$displayNameNode = this.$el.querySelector('.display-name'); + this.$displayName = this.$el.querySelector('.display-name'); this._receiveTextQueue = []; } @@ -1391,7 +1814,9 @@ class ReceiveTextDialog extends Dialog { } _showReceiveTextDialog(text, peerId) { - this.$displayNameNode.innerText = $(peerId).ui._displayName(); + this.$displayName.innerText = $(peerId).ui._displayName(); + this.$displayName.classList.remove("badge-room-ip", "badge-room-secret", "badge-room-public-id"); + this.$displayName.classList.add($(peerId).ui._badgeClassName()); this.$text.innerText = text; this.$text.classList.remove('text-center'); @@ -1402,10 +1827,6 @@ class ReceiveTextDialog extends Dialog { this.$text.innerHTML = this.$text.innerHTML.replace(/((https?:\/\/|www)[ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789\-._~:\/?#\[\]@!$&'()*+,;=]+)/g, url => { return `
${url}`; }); - - if (!/\s/.test(text)) { - this.$text.classList.add('text-center'); - } } this._setDocumentTitleMessages(); @@ -1416,15 +1837,20 @@ class ReceiveTextDialog extends Dialog { _setDocumentTitleMessages() { document.title = !this._receiveTextQueue.length - ? 'Message Received - PairDrop' - : `${this._receiveTextQueue.length + 1} Messages Received - PairDrop`; + ? `${ 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, ' '); - await navigator.clipboard.writeText(sanitizedText); - Events.fire('notify-user', 'Copied to clipboard'); - this.hide(); + 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() { @@ -1447,16 +1873,16 @@ class Base64ZipDialog extends Dialog { if (base64Text) { this.show(); - if (base64Text === "paste") { + if (base64Text === 'paste') { // ?base64text=paste // base64 encoded string is ready to be pasted from clipboard - this.preparePasting("text"); - } else if (base64Text === "hash") { + this.preparePasting('text'); + } else if (base64Text === 'hash') { // ?base64text=hash#BASE64ENCODED // base64 encoded string is url hash which is never sent to server and faster (recommended) this.processBase64Text(base64Hash) .catch(_ => { - Events.fire('notify-user', 'Text content is incorrect.'); + Events.fire('notify-user', Localization.getTranslation("notifications.text-content-incorrect")); console.log("Text content incorrect."); }).finally(_ => { this.hide(); @@ -1466,7 +1892,7 @@ class Base64ZipDialog extends Dialog { // base64 encoded string was part of url param (not recommended) this.processBase64Text(base64Text) .catch(_ => { - Events.fire('notify-user', 'Text content is incorrect.'); + Events.fire('notify-user', Localization.getTranslation("notifications.text-content-incorrect")); console.log("Text content incorrect."); }).finally(_ => { this.hide(); @@ -1479,7 +1905,7 @@ class Base64ZipDialog extends Dialog { // base64 encoded zip file is url hash which is never sent to the server this.processBase64Zip(base64Hash) .catch(_ => { - Events.fire('notify-user', 'File content is incorrect.'); + Events.fire('notify-user', Localization.getTranslation("notifications.file-content-incorrect")); console.log("File content incorrect."); }).finally(_ => { this.hide(); @@ -1493,18 +1919,22 @@ class Base64ZipDialog extends Dialog { _setPasteBtnToProcessing() { this.$pasteBtn.style.pointerEvents = "none"; - this.$pasteBtn.innerText = "Processing..."; + 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 = `Tap here to paste ${type}`; + 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', ''); - this.$fallbackTextarea.setAttribute('placeholder', `Paste here to send ${type}`); + 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()); @@ -1538,13 +1968,13 @@ class Base64ZipDialog extends Dialog { if (!base64 || !this.isValidBase64(base64)) return; this._setPasteBtnToProcessing(); try { - if (type === "text") { + if (type === 'text') { await this.processBase64Text(base64); } else { await this.processBase64Zip(base64); } } catch(_) { - Events.fire('notify-user', 'Clipboard content is incorrect.'); + Events.fire('notify-user', Localization.getTranslation("notifications.clipboard-content-incorrect")); console.log("Clipboard content is incorrect.") } this.hide(); @@ -1627,7 +2057,7 @@ class Notifications { Events.fire('notify-user', Notifications.PERMISSION_ERROR || 'Error'); return; } - Events.fire('notify-user', 'Notifications enabled.'); + Events.fire('notify-user', Localization.getTranslation("notifications.notifications-enabled")); this.$button.setAttribute('hidden', 1); }); } @@ -1662,10 +2092,10 @@ class Notifications { if (document.visibilityState !== 'visible') { const peerDisplayName = $(peerId).ui._displayName(); if (/^((https?:\/\/|www)[abcdefghijklmnopqrstuvwxyz0123456789\-._~:\/?#\[\]@!$&'()*+,;=]+)$/.test(message.toLowerCase())) { - const notification = this._notify(`Link received by ${peerDisplayName} - Click to open`, message); + const notification = this._notify(Localization.getTranslation("notifications.link-received", null, {name: peerDisplayName}), message); this._bind(notification, _ => window.open(message, '_blank', null, true)); } else { - const notification = this._notify(`Message received by ${peerDisplayName} - Click to copy`, message); + const notification = this._notify(Localization.getTranslation("notifications.message-received", null, {name: peerDisplayName}), message); this._bind(notification, _ => this._copyText(message, notification)); } } @@ -1680,13 +2110,23 @@ class Notifications { break; } } - let title = files[0].name; - if (files.length >= 2) { - title += ` and ${files.length - 1} other `; - title += imagesOnly ? 'image' : 'file'; - if (files.length > 2) title += "s"; + 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, 'Click to download'); + const notification = this._notify(title, Localization.getTranslation("notifications.click-to-download")); this._bind(notification, _ => this._download(notification)); } } @@ -1700,15 +2140,27 @@ class Notifications { break; } } - let descriptor; - if (request.header.length > 1) { - descriptor = imagesOnly ? ' images' : ' files'; - } else { - descriptor = imagesOnly ? ' image' : ' file'; - } + let displayName = $(peerId).querySelector('.name').textContent - let title = `${displayName} would like to transfer ${request.header.length} ${descriptor}`; - const notification = this._notify(title, 'Click to show'); + + 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")); } } @@ -1720,10 +2172,9 @@ class Notifications { _copyText(message, notification) { if (navigator.clipboard.writeText(message)) { notification.close(); - this._notify('Copied text to clipboard'); + this._notify(Localization.getTranslation("notifications.copied-text")); } else { - this._notify('Writing to clipboard failed. Copy manually!'); - + this._notify(Localization.getTranslation("notifications.copied-text-error")); } } @@ -1747,11 +2198,11 @@ class NetworkStatusUI { } _showOfflineMessage() { - Events.fire('notify-user', 'You are offline'); + Events.fire('notify-user', Localization.getTranslation("notifications.offline")); } _showOnlineMessage() { - Events.fire('notify-user', 'You are back online'); + Events.fire('notify-user', Localization.getTranslation("notifications.online")); } } @@ -2167,58 +2618,60 @@ class BrowserTabsConnector { } static peerIsSameBrowser(peerId) { - let peerIdsBrowser = JSON.parse(localStorage.getItem("peerIdsBrowser")); + let peerIdsBrowser = JSON.parse(localStorage.getItem("peer_ids_browser")); return peerIdsBrowser ? peerIdsBrowser.indexOf(peerId) !== -1 : false; } static async addPeerIdToLocalStorage() { - const peerId = sessionStorage.getItem("peerId"); + const peerId = sessionStorage.getItem("peer_id"); if (!peerId) return false; let peerIdsBrowser = []; - let peerIdsBrowserOld = JSON.parse(localStorage.getItem("peerIdsBrowser")); + let peerIdsBrowserOld = JSON.parse(localStorage.getItem("peer_ids_browser")); if (peerIdsBrowserOld) peerIdsBrowser.push(...peerIdsBrowserOld); peerIdsBrowser.push(peerId); peerIdsBrowser = peerIdsBrowser.filter(onlyUnique); - localStorage.setItem("peerIdsBrowser", JSON.stringify(peerIdsBrowser)); + localStorage.setItem("peer_ids_browser", JSON.stringify(peerIdsBrowser)); return peerIdsBrowser; } static async removePeerIdFromLocalStorage(peerId) { - let peerIdsBrowser = JSON.parse(localStorage.getItem("peerIdsBrowser")); + let peerIdsBrowser = JSON.parse(localStorage.getItem("peer_ids_browser")); const index = peerIdsBrowser.indexOf(peerId); peerIdsBrowser.splice(index, 1); - localStorage.setItem("peerIdsBrowser", JSON.stringify(peerIdsBrowser)); + localStorage.setItem("peer_ids_browser", JSON.stringify(peerIdsBrowser)); return peerId; } static async removeOtherPeerIdsFromLocalStorage() { - const peerId = sessionStorage.getItem("peerId"); + const peerId = sessionStorage.getItem("peer_id"); if (!peerId) return false; let peerIdsBrowser = [peerId]; - localStorage.setItem("peerIdsBrowser", JSON.stringify(peerIdsBrowser)); + localStorage.setItem("peer_ids_browser", JSON.stringify(peerIdsBrowser)); return peerIdsBrowser; } } class PairDrop { constructor() { - Events.on('load', _ => { + Events.on('initial-translation-loaded', _ => { const server = new ServerConnection(); const peers = new PeersManager(server); const peersUI = new PeersUI(); + const languageSelectDialog = new LanguageSelectDialog(); const receiveFileDialog = new ReceiveFileDialog(); const receiveRequestDialog = new ReceiveRequestDialog(); const sendTextDialog = new SendTextDialog(); const receiveTextDialog = new ReceiveTextDialog(); const pairDeviceDialog = new PairDeviceDialog(); const clearDevicesDialog = new EditPairedDevicesDialog(); + const publicRoomDialog = new PublicRoomDialog(); const base64ZipDialog = new Base64ZipDialog(); const toast = new Toast(); const notifications = new Notifications(); @@ -2233,6 +2686,7 @@ class PairDrop { const persistentStorage = new PersistentStorage(); const pairDrop = new PairDrop(); +const localization = new Localization(); if ('serviceWorker' in navigator) { @@ -2265,7 +2719,7 @@ Events.on('load', () => { let oldOffset = offset w = document.documentElement.clientWidth; h = document.documentElement.clientHeight; - offset = $$('footer').offsetHeight - 33; + offset = $$('footer').offsetHeight - 27; if (oldW === w && oldH === h && oldOffset === offset) return; // nothing has changed diff --git a/public_included_ws_fallback/styles.css b/public_included_ws_fallback/styles.css index e384b51..5a64b39 100644 --- a/public_included_ws_fallback/styles.css +++ b/public_included_ws_fallback/styles.css @@ -4,6 +4,8 @@ --icon-size: 24px; --primary-color: #4285f4; --paired-device-color: #00a69c; + --public-room-color: #db8500; + --accent-color: var(--primary-color); --peer-width: 120px; --ws-peer-color: #ff6b6b; color-scheme: light dark; @@ -24,6 +26,7 @@ body { -webkit-user-select: none; -moz-user-select: none; user-select: none; + transition: color 300ms; } body { @@ -41,6 +44,10 @@ html { min-height: fill-available; } +.fw { + width: 100%; +} + .row-reverse { display: flex; flex-direction: row-reverse; @@ -52,7 +59,6 @@ html { .row { display: flex; - justify-content: center; flex-direction: row; } @@ -79,6 +85,10 @@ html { bottom: 0; } +.pointer { + cursor: pointer; +} + header { position: absolute; align-items: baseline; @@ -216,10 +226,6 @@ a, cursor: pointer; } -hr { - color: white; -} - input { cursor: pointer; } @@ -276,8 +282,6 @@ x-noscript { margin-top: 56px; flex-direction: column-reverse; flex-grow: 1; - --footer-height: 146px; - max-height: calc(100vh - 56px - var(--footer-height)); justify-content: space-around; align-items: center; overflow-x: hidden; @@ -285,17 +289,7 @@ x-noscript { overscroll-behavior-x: none; } -@media screen and (min-width: 402px) and (max-width: 425px) { - header:has(#edit-pair-devices:not([hidden]))~#center { - --footer-height: 164px; - } -} -@media screen and (max-width: 402px) { - #center { - --footer-height: 184px; - } -} /* Peers List */ #x-peers-filler { @@ -452,7 +446,7 @@ x-no-peers::before { } x-no-peers[drop-bg]::before { - content: "Release to select recipient"; + content: attr(data-drop-bg); } x-no-peers[drop-bg] * { @@ -471,7 +465,6 @@ x-peer { x-peer label { width: var(--peer-width); - cursor: pointer; touch-action: manipulation; -webkit-tap-highlight-color: rgba(0, 0, 0, 0); position: relative; @@ -500,10 +493,14 @@ x-peer .icon-wrapper { display: flex; } -x-peer:not(.type-ip).type-secret .icon-wrapper { +x-peer.type-secret .icon-wrapper { background: var(--paired-device-color); } +x-peer:not(.type-ip):not(.type-secret).type-public-id .icon-wrapper { + background: var(--public-room-color); +} + x-peer x-icon > .highlight-wrapper { align-self: center; align-items: center; @@ -512,17 +509,29 @@ x-peer x-icon > .highlight-wrapper { } x-peer x-icon > .highlight-wrapper > .highlight { - width: 6px; + width: 15px; height: 6px; - border-radius: 50%; + border-radius: 4px; + margin-left: 1px; + margin-right: 1px; display: none; } -x-peer.type-secret x-icon > .highlight-wrapper > .highlight { +x-peer.type-ip x-icon > .highlight-wrapper > .highlight.highlight-room-ip { + background-color: var(--primary-color); + display: inline; +} + +x-peer.type-secret x-icon > .highlight-wrapper > .highlight.highlight-room-secret { background-color: var(--paired-device-color); display: inline; } +x-peer.type-public-id x-icon > .highlight-wrapper > .highlight.highlight-room-public-id { + background-color: var(--public-room-color); + display: inline; +} + x-peer:not([status]):hover x-icon, x-peer:not([status]):focus x-icon { transform: scale(1.05); @@ -551,6 +560,14 @@ x-peer.ws-peer .highlight-wrapper { margin-top: 3px; } +#websocket-fallback { + opacity: 0.5; +} + +#websocket-fallback > span:nth-of-type(2) { + border-bottom: solid 2px var(--ws-peer-color); +} + .device-descriptor { width: 100%; text-align: center; @@ -580,22 +597,6 @@ x-peer.ws-peer .highlight-wrapper { white-space: nowrap; } -x-peer[status=transfer] .status:before { - content: 'Transferring...'; -} - -x-peer[status=prepare] .status:before { - content: 'Preparing...'; -} - -x-peer[status=wait] .status:before { - content: 'Waiting...'; -} - -x-peer[status=process] .status:before { - content: 'Processing...'; -} - x-peer:not([status]) .status, x-peer[status] .device-name { display: none; @@ -629,12 +630,11 @@ x-peer[drop] x-icon { footer { position: relative; - margin-top: auto; z-index: 2; align-items: center; text-align: center; - transition: color 300ms; cursor: default; + margin: auto 5px 5px; } footer .logo { @@ -644,43 +644,71 @@ footer .logo { margin-top: -10px; } -footer .font-body2 { - color: var(--primary-color); - margin: auto 18px; +.discovery-wrapper { + font-size: 12px; + margin: 10px auto auto; + border: 3px solid var(--border-color); + border-radius: 0.5rem; + padding: 2px; + background-color: rgb(var(--bg-color)); + transition: background-color 0.5s ease; } -#on-this-network { - border-bottom: solid 4px var(--primary-color); - padding-bottom: 1px; +/*You can be discovered wrapper*/ +.discovery-wrapper > div:first-of-type { + padding-left: 4px; + padding-right: 4px; } -#paired-devices { - border-bottom: solid 4px var(--paired-device-color); - padding-bottom: 1px; + +.discovery-wrapper .badge { + word-break: keep-all; + margin: 2px; +} + +.badge { + border-radius: 0.3rem/0.3rem; + padding-right: 0.3rem; + padding-left: 0.3em; + background-color: var(--badge-color); + color: white; + transition: background-color 0.5s ease; + white-space: nowrap; +} + +.badge-room-ip { + background-color: var(--primary-color); + border-color: var(--primary-color); +} + +.badge-room-secret { + background-color: var(--paired-device-color); + border-color: var(--paired-device-color); +} + +.badge-room-public-id { + background-color: var(--public-room-color); + border-color: var(--public-room-color); } #display-name { + position: relative; display: inline-block; text-align: left; border: none; outline: none; max-width: 15em; text-overflow: ellipsis; - white-space: nowrap; cursor: text; margin-left: -1rem; margin-bottom: -6px; - padding-right: 0.3rem; - padding-left: 0.3em; padding-bottom: 0.1rem; border-radius: 1.3rem/30%; border-right: solid 1rem transparent; border-left: solid 1rem transparent; background-clip: padding-box; - background-color: rgba(var(--text-color), 43%); - color: white; - transition: background-color 0.5s ease; overflow: hidden; + z-index: 1; } #edit-pen { @@ -689,7 +717,6 @@ footer .font-body2 { margin-left: -1rem; margin-bottom: -2px; position: relative; - z-index: -1; } /* Dialog */ @@ -707,7 +734,6 @@ x-dialog x-paper { z-index: 3; background: white; border-radius: 8px; - padding: 16px 24px; width: 100%; max-width: 400px; overflow: hidden; @@ -716,14 +742,33 @@ x-dialog x-paper { will-change: transform; } -#pair-device-dialog x-paper { +x-paper > .row:first-of-type { + background-color: var(--accent-color); + border-bottom: solid 4px var(--border-color); + margin-bottom: 10px; +} + +x-paper > .row:first-of-type h2 { + color: white; +} + +#pair-device-dialog, +#edit-paired-devices-dialog { + --accent-color: var(--paired-device-color); +} + +#public-room-dialog { + --accent-color: var(--public-room-color); +} + +#pair-device-dialog x-paper, +#public-room-dialog x-paper { display: flex; flex-direction: column; position: absolute; top: max(50%, 350px); margin-top: -328.5px; width: calc(100vw - 20px); - height: 625px; } #pair-device-dialog ::-moz-selection, @@ -732,6 +777,12 @@ x-dialog x-paper { background: var(--paired-device-color); } +#public-room-dialog ::-moz-selection, +#public-room-dialog ::selection { + color: black; + background: var(--public-room-color); +} + x-dialog:not([show]) { pointer-events: none; } @@ -749,24 +800,21 @@ x-dialog a { color: var(--primary-color); } -x-dialog .font-subheading { - margin-bottom: 5px; -} - /* Pair Devices Dialog */ -#key-input-container { +.input-key-container { width: 100%; display: flex; justify-content: center; } -#key-input-container > input { +.input-key-container > input { width: 45px; height: 45px; font-size: 30px; padding: 0; text-align: center; + text-transform: uppercase; display: -webkit-box !important; display: -webkit-flex !important; display: -moz-flex !important; @@ -777,15 +825,15 @@ x-dialog .font-subheading { justify-content: center; } -#key-input-container > input + * { +.input-key-container > input + * { margin-left: 6px; } -#key-input-container > input:nth-of-type(4) { +.input-key-container.six-chars > input:nth-of-type(4) { margin-left: 5%; } -#room-key { +.key { -webkit-user-select: text; -moz-user-select: text; user-select: text; @@ -796,13 +844,48 @@ x-dialog .font-subheading { margin: 15px -15px; } -#room-key-qr-code { +.key-qr-code { margin: 16px; } +.key-instructions { + flex-direction: column; +} + +x-dialog h2 { + margin-top: 5px; + margin-bottom: 0; +} + x-dialog hr { - margin: 40px -24px 30px -24px; - border: solid 1.25px var(--border-color); + height: 3px; + border: none; + width: 100%; + background-color: var(--border-color); +} + +.hr-note { + margin-top: 10px; + margin-bottom: 10px; +} + +.hr-note hr { + margin-bottom: -2px; +} + +.hr-note > div { + height: 0; + transform: translateY(-10px); +} + + +.hr-note > div > span { + padding: 3px 10px; + border-radius: 10px; + color: rgb(var(--text-color)); + background-color: rgb(var(--bg-color)); + border: var(--border-color) solid 3px; + text-transform: uppercase; } #pair-device-dialog x-background { @@ -811,7 +894,7 @@ x-dialog hr { /* Edit Paired Devices Dialog */ .paired-devices-wrapper:empty:before { - content: "No paired devices."; + content: attr(data-empty); } .paired-devices-wrapper:empty { @@ -896,39 +979,34 @@ x-dialog hr { text-overflow: ellipsis; } -.paired-device > .auto-accept { - cursor: pointer; -} - /* Receive Dialog */ -x-dialog .row { - margin-top: 24px; - margin-bottom: 8px; +x-paper > .row { + padding: 10px; } /* button row*/ -x-paper > div:last-child { - margin: auto -24px -15px; - border-top: solid 2.5px var(--border-color); +x-paper > .button-row { + border-top: solid 3px var(--border-color); height: 50px; + margin-top: 10px; } -x-paper > div:last-child > .button { +x-paper > .button-row > .button { height: 100%; width: 100%; } -x-paper > div:last-child > .button:not(:last-child) { - border-left: solid 2.5px var(--border-color); +x-paper > .button-row > .button:not(:first-child) { + border-right: solid 1.5px var(--border-color); +} + +x-paper > .button-row > .button:not(:last-child) { + border-left: solid 1.5px var(--border-color); } .file-description { - margin-bottom: 25px; -} - -.file-description .row { - margin: 0 + max-width: 100%; } .file-description span { @@ -939,23 +1017,29 @@ x-paper > div:last-child > .button:not(:last-child) { .file-name { font-style: italic; max-width: 100%; + margin-top: 5px; } .file-stem { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + padding-right: 1px; } /* Send Text Dialog */ -/* Todo: add pair underline to send / receive dialogs displayName */ x-dialog .dialog-subheader { - margin-bottom: 25px; + padding-top: 16px; + padding-bottom: 16px; +} + +#send-text-dialog .display-name-wrapper { + padding-bottom: 0; } #text-input { min-height: 200px; - margin: 14px auto; + width: 100%; } /* Receive Text Dialog */ @@ -970,7 +1054,6 @@ x-dialog .dialog-subheader { -moz-user-select: text; user-select: text; white-space: pre-wrap; - padding: 15px 0; } #receive-text-dialog #text a { @@ -997,12 +1080,11 @@ x-dialog .dialog-subheader { width: 100%; height: 40vh; border: solid 12px #438cff; - text-align: center; + border-radius: 8px; } #base64-paste-dialog .textarea { display: flex; - flex-direction: column; align-items: center; justify-content: center; text-align: center; @@ -1014,21 +1096,9 @@ x-dialog .dialog-subheader { color: var(--primary-color); font-weight: 700; text-transform: uppercase; - content: attr(placeholder); + white-space: pre-wrap; } -#base64-paste-dialog button { - margin: auto; - border-radius: 8px; -} - -#base64-paste-dialog button[close] { - margin-top: 20px; -} - -#base64-paste-dialog button[close]:before { - border-radius: 8px; -} /* Button */ @@ -1045,12 +1115,13 @@ x-dialog .dialog-subheader { cursor: pointer; user-select: none; background: inherit; - color: var(--primary-color); + color: var(--accent-color); overflow: hidden; } .button[disabled] { color: #5B5B66; + cursor: not-allowed; } @@ -1084,6 +1155,11 @@ x-dialog .dialog-subheader { opacity: 0.1; } +.button[selected], +.icon-button[selected] { + opacity: 0.1; +} + #cancel-paste-mode { z-index: 2; margin: 0; @@ -1126,8 +1202,7 @@ button::-moz-focus-inner { border: none; outline: none; padding: 16px 24px; - border-radius: 16px; - margin: 10px 0; + border-radius: 8px; font-size: 14px; font-family: inherit; background: #f1f3f4; @@ -1314,11 +1389,11 @@ x-instructions:not([drop-peer]):not([drop-bg]):before { } x-instructions[drop-peer]:before { - content: "Release to send to peer"; + content: attr(data-drop-peer); } x-instructions[drop-bg]:not([drop-peer]):before { - content: "Release to select recipient"; + content: attr(data-drop-bg); } x-instructions p { @@ -1336,36 +1411,11 @@ x-peers:empty~x-instructions { } } -#websocket-fallback { - margin-left: 5px; - margin-right: 5px; - padding: 5px; - text-align: center; - opacity: 0.5; - transition: opacity 300ms; -} - -#websocket-fallback > span { - margin: 2px; -} - -#websocket-fallback > span > span { - border-bottom: solid 4px var(--ws-peer-color); -} - /* Responsive Styles */ -@media screen and (max-width: 360px) { - x-dialog x-paper { - padding: 15px; - } - x-paper > div:last-child { - margin: auto -15px -15px; - } -} @media screen and (min-height: 800px) { - #websocket-fallback { - padding-bottom: 16px; + footer { + margin-bottom: 16px; } } @@ -1384,8 +1434,9 @@ body { --text-color: 51,51,51; --bg-color: 250,250,250; /*rgb code*/ --bg-color-test: 18,18,18; - --bg-color-secondary: #f1f3f4; - --border-color: #e7e8e8; + --bg-color-secondary: #e4e4e4; + --border-color: rgb(169, 169, 169); + --badge-color: #a5a5a5; } /* Dark theme colors */ @@ -1393,7 +1444,8 @@ body.dark-theme { --text-color: 238,238,238; --bg-color: 18,18,18; /*rgb code*/ --bg-color-secondary: #333; - --border-color: #252525; + --border-color: rgb(238,238,238); + --badge-color: #717171; } /* Colored Elements */ @@ -1427,7 +1479,7 @@ x-dialog x-paper { /* Image/Video/Audio Preview */ .file-preview { - margin: 10px -24px 40px -24px; + margin-bottom: 15px; } .file-preview:empty { @@ -1451,15 +1503,17 @@ x-dialog x-paper { --text-color: 238,238,238; --bg-color: 18,18,18; /*rgb code*/ --bg-color-secondary: #333; - --border-color: #252525; + --border-color: rgb(238,238,238); + --badge-color: #717171; } /* Override dark mode with light mode styles if the user decides to swap */ body.light-theme { --text-color: 51,51,51; --bg-color: 250,250,250; /*rgb code*/ - --bg-color-secondary: #f1f3f4; - --border-color: #e7e8e8; + --bg-color-secondary: #e4e4e4; + --border-color: rgb(169, 169, 169); + --badge-color: #a5a5a5; } }