Merge branch 'next' into translate
93
public/fonts/OpenSans/OFL.txt
Normal file
|
@ -0,0 +1,93 @@
|
|||
Copyright 2020 The Open Sans Project Authors (https://github.com/googlefonts/opensans)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
BIN
public/fonts/OpenSans/OpenSans-Italic-VariableFont_wdth,wght.ttf
Normal file
BIN
public/fonts/OpenSans/OpenSans-VariableFont_wdth,wght.ttf
Normal file
100
public/fonts/OpenSans/README.txt
Normal file
|
@ -0,0 +1,100 @@
|
|||
Open Sans Variable Font
|
||||
=======================
|
||||
|
||||
This download contains Open Sans as both variable fonts and static fonts.
|
||||
|
||||
Open Sans is a variable font with these axes:
|
||||
wdth
|
||||
wght
|
||||
|
||||
This means all the styles are contained in these files:
|
||||
OpenSans-VariableFont_wdth,wght.ttf
|
||||
OpenSans-Italic-VariableFont_wdth,wght.ttf
|
||||
|
||||
If your app fully supports variable fonts, you can now pick intermediate styles
|
||||
that aren’t available as static fonts. Not all apps support variable fonts, and
|
||||
in those cases you can use the static font files for Open Sans:
|
||||
static/OpenSans_Condensed-Light.ttf
|
||||
static/OpenSans_Condensed-Regular.ttf
|
||||
static/OpenSans_Condensed-Medium.ttf
|
||||
static/OpenSans_Condensed-SemiBold.ttf
|
||||
static/OpenSans_Condensed-Bold.ttf
|
||||
static/OpenSans_Condensed-ExtraBold.ttf
|
||||
static/OpenSans_SemiCondensed-Light.ttf
|
||||
static/OpenSans_SemiCondensed-Regular.ttf
|
||||
static/OpenSans_SemiCondensed-Medium.ttf
|
||||
static/OpenSans_SemiCondensed-SemiBold.ttf
|
||||
static/OpenSans_SemiCondensed-Bold.ttf
|
||||
static/OpenSans_SemiCondensed-ExtraBold.ttf
|
||||
static/OpenSans-Light.ttf
|
||||
static/OpenSans-Regular.ttf
|
||||
static/OpenSans-Medium.ttf
|
||||
static/OpenSans-SemiBold.ttf
|
||||
static/OpenSans-Bold.ttf
|
||||
static/OpenSans-ExtraBold.ttf
|
||||
static/OpenSans_Condensed-LightItalic.ttf
|
||||
static/OpenSans_Condensed-Italic.ttf
|
||||
static/OpenSans_Condensed-MediumItalic.ttf
|
||||
static/OpenSans_Condensed-SemiBoldItalic.ttf
|
||||
static/OpenSans_Condensed-BoldItalic.ttf
|
||||
static/OpenSans_Condensed-ExtraBoldItalic.ttf
|
||||
static/OpenSans_SemiCondensed-LightItalic.ttf
|
||||
static/OpenSans_SemiCondensed-Italic.ttf
|
||||
static/OpenSans_SemiCondensed-MediumItalic.ttf
|
||||
static/OpenSans_SemiCondensed-SemiBoldItalic.ttf
|
||||
static/OpenSans_SemiCondensed-BoldItalic.ttf
|
||||
static/OpenSans_SemiCondensed-ExtraBoldItalic.ttf
|
||||
static/OpenSans-LightItalic.ttf
|
||||
static/OpenSans-Italic.ttf
|
||||
static/OpenSans-MediumItalic.ttf
|
||||
static/OpenSans-SemiBoldItalic.ttf
|
||||
static/OpenSans-BoldItalic.ttf
|
||||
static/OpenSans-ExtraBoldItalic.ttf
|
||||
|
||||
Get started
|
||||
-----------
|
||||
|
||||
1. Install the font files you want to use
|
||||
|
||||
2. Use your app's font picker to view the font family and all the
|
||||
available styles
|
||||
|
||||
Learn more about variable fonts
|
||||
-------------------------------
|
||||
|
||||
https://developers.google.com/web/fundamentals/design-and-ux/typography/variable-fonts
|
||||
https://variablefonts.typenetwork.com
|
||||
https://medium.com/variable-fonts
|
||||
|
||||
In desktop apps
|
||||
|
||||
https://theblog.adobe.com/can-variable-fonts-illustrator-cc
|
||||
https://helpx.adobe.com/nz/photoshop/using/fonts.html#variable_fonts
|
||||
|
||||
Online
|
||||
|
||||
https://developers.google.com/fonts/docs/getting_started
|
||||
https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Fonts/Variable_Fonts_Guide
|
||||
https://developer.microsoft.com/en-us/microsoft-edge/testdrive/demos/variable-fonts
|
||||
|
||||
Installing fonts
|
||||
|
||||
MacOS: https://support.apple.com/en-us/HT201749
|
||||
Linux: https://www.google.com/search?q=how+to+install+a+font+on+gnu%2Blinux
|
||||
Windows: https://support.microsoft.com/en-us/help/314960/how-to-install-or-remove-a-font-in-windows
|
||||
|
||||
Android Apps
|
||||
|
||||
https://developers.google.com/fonts/docs/android
|
||||
https://developer.android.com/guide/topics/ui/look-and-feel/downloadable-fonts
|
||||
|
||||
License
|
||||
-------
|
||||
Please read the full license text (OFL.txt) to understand the permissions,
|
||||
restrictions and requirements for usage, redistribution, and modification.
|
||||
|
||||
You can use them in your products & projects – print or digital,
|
||||
commercial or otherwise.
|
||||
|
||||
This isn't legal advice, please consider consulting a lawyer and see the full
|
||||
license for all details.
|
BIN
public/fonts/OpenSans/static/OpenSans-Bold.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans-BoldItalic.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans-ExtraBold.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans-ExtraBoldItalic.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans-Italic.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans-Light.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans-LightItalic.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans-Medium.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans-MediumItalic.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans-Regular.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans-SemiBold.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans-SemiBoldItalic.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans_Condensed-Bold.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans_Condensed-BoldItalic.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans_Condensed-ExtraBold.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans_Condensed-Italic.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans_Condensed-Light.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans_Condensed-LightItalic.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans_Condensed-Medium.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans_Condensed-MediumItalic.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans_Condensed-Regular.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans_Condensed-SemiBold.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans_SemiCondensed-Bold.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans_SemiCondensed-Italic.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans_SemiCondensed-Light.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans_SemiCondensed-Medium.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans_SemiCondensed-Regular.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans_SemiCondensed-SemiBold.ttf
Normal file
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 21 KiB |
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 22 KiB |
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 56 KiB |
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 59 KiB |
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 20 KiB |
Before Width: | Height: | Size: 7 KiB After Width: | Height: | Size: 7.2 KiB |
Before Width: | Height: | Size: 7 KiB After Width: | Height: | Size: 6.9 KiB |
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 56 KiB |
Before Width: | Height: | Size: 10 KiB |
Before Width: | Height: | Size: 52 KiB |
Before Width: | Height: | Size: 31 KiB |
|
@ -5,17 +5,17 @@
|
|||
<meta charset="utf-8">
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||
<!-- Web App Config -->
|
||||
<title>PairDrop</title>
|
||||
<title>PairDrop | Transfer Files Cross-Platform. No Setup, No Signup.</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="theme-color" content="#3367d6">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<meta name="apple-mobile-web-app-capable" content="no">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-title" content="PairDrop">
|
||||
<meta name="application-name" content="PairDrop">
|
||||
<!-- Descriptions -->
|
||||
<meta name="description" content="Instantly share images, videos, PDFs, and links with people nearby. Peer2Peer and Open Source. No Setup, No Signup.">
|
||||
<meta name="keywords" content="File, Transfer, Share, Peer2Peer">
|
||||
<meta name="author" content="RobinLinus">
|
||||
<meta name="author" content="schlagmichdoch">
|
||||
<meta property="og:title" content="PairDrop">
|
||||
<meta property="og:type" content="article">
|
||||
<meta property="og:url" content="https://pairdrop.net/">
|
||||
|
@ -28,13 +28,14 @@
|
|||
<link rel="icon" sizes="96x96" href="images/favicon-96x96.png">
|
||||
<link rel="shortcut icon" href="images/favicon-96x96.png">
|
||||
<link rel="apple-touch-icon" href="images/apple-touch-icon.png">
|
||||
<link rel="apple-touch-icon-precomposed" href="images/apple-touch-icon.png">
|
||||
<meta name="msapplication-TileImage" content="images/mstile-150x150.png">
|
||||
<link rel="fluid-icon" type="image/png" href="images/android-chrome-192x192.png">
|
||||
<meta name="twitter:image" content="images/logo_transparent_512x512.png">
|
||||
<meta property="og:image" content="images/logo_transparent_512x512.png">
|
||||
<!-- Resources -->
|
||||
<link rel="preload" href="lang/en.json" as="fetch">
|
||||
<link rel="stylesheet" type="text/css" href="styles.css">
|
||||
<link rel="stylesheet" type="text/css" href="styles/styles-main.css">
|
||||
<link rel="manifest" href="manifest.json">
|
||||
</head>
|
||||
|
||||
|
@ -94,7 +95,7 @@
|
|||
<use xlink:href="#public-room-icon"></use>
|
||||
</svg>
|
||||
</div>
|
||||
<div id="cancel-paste-mode" class="button" data-i18n-key="header.cancel-paste-mode" data-i18n-attrs="text" hidden></div>
|
||||
<div id="cancel-paste-mode" class="btn" data-i18n-key="header.cancel-paste-mode" data-i18n-attrs="text" hidden></div>
|
||||
</header>
|
||||
<!-- Center -->
|
||||
<div id="center" class="opacity-0">
|
||||
|
@ -108,11 +109,22 @@
|
|||
<x-instructions data-i18n-key="instructions.x-instructions" data-i18n-attrs="desktop mobile data-drop-peer data-drop-bg">
|
||||
<p id="paste-filename"></p>
|
||||
</x-instructions>
|
||||
<div id="websocket-fallback" hidden>
|
||||
<span data-i18n-key="footer.traffic" data-i18n-attrs="text"></span>
|
||||
<span data-i18n-key="footer.routed" data-i18n-attrs="text"></span>
|
||||
<span data-i18n-key="footer.webrtc" data-i18n-attrs="text"></span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Footer -->
|
||||
<footer class="column opacity-0">
|
||||
<svg class="icon logo">
|
||||
<use xlink:href="#wifi-tethering"></use>
|
||||
<defs>
|
||||
<linearGradient id="primaryGradient" gradientTransform="rotate(90)">
|
||||
<stop offset="0%" class="start-color" />
|
||||
<stop offset="100%" class="stop-color" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<use xlink:href="#wifi-tethering" style="fill: url(#primaryGradient);"></use>
|
||||
</svg>
|
||||
<div class="column">
|
||||
<div class="known-as-wrapper">
|
||||
|
@ -127,9 +139,9 @@
|
|||
<span data-i18n-key="footer.discovery" data-i18n-attrs="text"></span>
|
||||
</div>
|
||||
<div class="row center">
|
||||
<span class="badge badge-room-ip" data-i18n-key="footer.on-this-network" data-i18n-attrs="text title"></span>
|
||||
<span class="badge badge-room-secret pointer" data-i18n-key="footer.paired-devices" data-i18n-attrs="text title" hidden></span>
|
||||
<span class="badge badge-room-public-id pointer" data-i18n-key="footer.public-room-devices" data-i18n-attrs="title" hidden>in room IAIAI</span>
|
||||
<span class="badge badge-gradient badge-room-ip" data-i18n-key="footer.on-this-network" data-i18n-attrs="text title"></span>
|
||||
<span class="badge badge-gradient badge-room-secret pointer" data-i18n-key="footer.paired-devices" data-i18n-attrs="text title" hidden></span>
|
||||
<span class="badge badge-gradient badge-room-public-id pointer" data-i18n-key="footer.public-room-devices" data-i18n-attrs="title" hidden>in room IAIAI</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -142,83 +154,83 @@
|
|||
<h2 class="center" data-i18n-key="dialogs.language-selector-title" data-i18n-attrs="text"></h2>
|
||||
</div>
|
||||
<div class="language-buttons">
|
||||
<button class="button fw" data-i18n-key="dialogs.system-language" data-i18n-attrs="text"></button>
|
||||
<button class="button fw" value="ar">
|
||||
<button class="btn fw" data-i18n-key="dialogs.system-language" data-i18n-attrs="text"></button>
|
||||
<button class="btn fw" value="ar">
|
||||
<span>العربية</span>
|
||||
<span>-</span>
|
||||
<span>(Arabic)</span>
|
||||
</button>
|
||||
<button class="button fw" value="de">
|
||||
<button class="btn fw" value="de">
|
||||
<span>Deutsch</span>
|
||||
<span>-</span>
|
||||
<span>(German)</span>
|
||||
</button>
|
||||
<button class="button fw" value="en">
|
||||
<button class="btn fw" value="en">
|
||||
<span>English</span>
|
||||
</button>
|
||||
<button class="button fw" value="es">
|
||||
<button class="btn fw" value="es">
|
||||
<span>Español</span>
|
||||
<span>-</span>
|
||||
<span>(Spanish)</span>
|
||||
</button>
|
||||
<button class="button fw" value="fr">
|
||||
<button class="btn fw" value="fr">
|
||||
<span>Français</span>
|
||||
<span>-</span>
|
||||
<span>(French)</span>
|
||||
</button>
|
||||
<button class="button fw" value="id">
|
||||
<button class="btn fw" value="id">
|
||||
<span>Bahasa Indonesia</span>
|
||||
<span>-</span>
|
||||
<span>(Indonesian)</span>
|
||||
</button>
|
||||
<button class="button fw" value="it">
|
||||
<button class="btn fw" value="it">
|
||||
<span>Italiano</span>
|
||||
<span>-</span>
|
||||
<span>(Italian)</span>
|
||||
</button>
|
||||
<button class="button fw" value="nl">
|
||||
<button class="btn fw" value="nl">
|
||||
<span>Nederlands</span>
|
||||
<span>-</span>
|
||||
<span>(Dutch)</span>
|
||||
</button>
|
||||
<button class="button fw" value="nb">
|
||||
<button class="btn fw" value="nb">
|
||||
<span>Norsk</span>
|
||||
<span>-</span>
|
||||
<span>(Norwegian)</span>
|
||||
</button>
|
||||
<button class="button fw" value="pt-BR">
|
||||
<button class="btn fw" value="pt-BR">
|
||||
<span>Português do Brasil</span>
|
||||
<span>-</span>
|
||||
<span>(Brazilian Portuguese)</span>
|
||||
</button>
|
||||
<button class="button fw" value="ro">
|
||||
<button class="btn fw" value="ro">
|
||||
<span>Română</span>
|
||||
<span>-</span>
|
||||
<span>(Romanian)</span>
|
||||
</button>
|
||||
<button class="button fw" value="ru">
|
||||
<button class="btn fw" value="ru">
|
||||
<span>Русский язык</span>
|
||||
<span>-</span>
|
||||
<span>(Russian)</span>
|
||||
</button>
|
||||
<button class="button fw" value="tr">
|
||||
<button class="btn fw" value="tr">
|
||||
<span>Türkçe</span>
|
||||
<span>-</span>
|
||||
<span>(Turkish)</span>
|
||||
</button>
|
||||
<button class="button fw" value="zh-CN">
|
||||
<button class="btn fw" value="zh-CN">
|
||||
<span>中文</span>
|
||||
<span>-</span>
|
||||
<span>(Chinese)</span>
|
||||
</button>
|
||||
<button class="button fw" value="ja">
|
||||
<button class="btn fw" value="ja">
|
||||
<span>日本語</span>
|
||||
<span>-</span>
|
||||
<span>(Japanese)</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="center row-reverse button-row">
|
||||
<button class="button" type="button" data-i18n-key="dialogs.close" data-i18n-attrs="text" close></button>
|
||||
<button class="btn btn-rounded btn-grey" type="button" data-i18n-key="dialogs.close" data-i18n-attrs="text" close></button>
|
||||
</div>
|
||||
</x-paper>
|
||||
</x-background>
|
||||
|
@ -248,7 +260,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="row center">
|
||||
<div class="column">
|
||||
<div class="column fw">
|
||||
<div class="input-key-container six-chars" dir="ltr">
|
||||
<input type="tel" class="textarea center" aria-label="pair-key-char-1" maxlength="1" autocorrect="off" autocomplete="off" autocapitalize="none" spellcheck="false" autofocus contenteditable placeholder disabled>
|
||||
<input type="tel" class="textarea center" aria-label="pair-key-char-2" maxlength="1" autocorrect="off" autocomplete="off" autocapitalize="none" spellcheck="false" contenteditable placeholder disabled>
|
||||
|
@ -261,8 +273,8 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="button-row row-reverse">
|
||||
<button class="button" type="submit" data-i18n-key="dialogs.pair" data-i18n-attrs="text" disabled></button>
|
||||
<button class="button" type="button" data-i18n-key="dialogs.cancel" data-i18n-attrs="text" close></button>
|
||||
<button class="btn btn-rounded btn-grey" type="submit" data-i18n-key="dialogs.pair" data-i18n-attrs="text" disabled></button>
|
||||
<button class="btn btn-rounded btn-grey" type="button" data-i18n-key="dialogs.cancel" data-i18n-attrs="text" close></button>
|
||||
</div>
|
||||
</x-paper>
|
||||
</x-background>
|
||||
|
@ -285,7 +297,7 @@
|
|||
</p>
|
||||
</div>
|
||||
<div class="center row-reverse button-row">
|
||||
<button class="button" type="button" data-i18n-key="dialogs.close" data-i18n-attrs="text" close></button>
|
||||
<button class="btn btn-rounded btn-grey" type="button" data-i18n-key="dialogs.close" data-i18n-attrs="text" close></button>
|
||||
</div>
|
||||
</x-paper>
|
||||
</x-background>
|
||||
|
@ -318,7 +330,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="row center">
|
||||
<div class="column">
|
||||
<div class="column fw">
|
||||
<div class="input-key-container" dir="ltr">
|
||||
<input type="text" class="textarea center" aria-label="room-id-char-1" maxlength="1" autocorrect="off" autocomplete="off" autocapitalize="none" spellcheck="false" autofocus contenteditable placeholder disabled>
|
||||
<input type="text" class="textarea center" aria-label="room-id-char-2" maxlength="1" autocorrect="off" autocomplete="off" autocapitalize="none" spellcheck="false" contenteditable placeholder disabled>
|
||||
|
@ -330,9 +342,9 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="center row-reverse button-row">
|
||||
<button class="button" type="submit" data-i18n-key="dialogs.join" data-i18n-attrs="text" disabled></button>
|
||||
<button class="button" type="button" data-i18n-key="dialogs.close" data-i18n-attrs="text" close></button>
|
||||
<button class="button leave-room" type="button" data-i18n-key="dialogs.leave" data-i18n-attrs="text"></button>
|
||||
<button class="btn btn-rounded btn-grey" type="submit" data-i18n-key="dialogs.join" data-i18n-attrs="text" disabled></button>
|
||||
<button class="btn btn-rounded btn-grey" type="button" data-i18n-key="dialogs.close" data-i18n-attrs="text" close></button>
|
||||
<button class="btn btn-rounded btn-grey leave-room" type="button" data-i18n-key="dialogs.leave" data-i18n-attrs="text"></button>
|
||||
</div>
|
||||
</x-paper>
|
||||
</x-background>
|
||||
|
@ -347,10 +359,10 @@
|
|||
<h2 class="center"></h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row center">
|
||||
<div class="row center p1">
|
||||
<div class="column center file-description">
|
||||
<div>
|
||||
<span class="display-name badge"></span>
|
||||
<span class="display-name badge badge-gradient"></span>
|
||||
<span data-i18n-key="dialogs.would-like-to-share" data-i18n-attrs="text"></span>
|
||||
</div>
|
||||
<div class="row file-name">
|
||||
|
@ -364,8 +376,8 @@
|
|||
</div>
|
||||
<div class="center file-preview"></div>
|
||||
<div class="row-reverse center button-row">
|
||||
<button id="accept-request" class="button" title="ENTER" data-i18n-key="dialogs.accept" data-i18n-attrs="text" autofocus></button>
|
||||
<button id="decline-request" class="button" title="ESCAPE" data-i18n-key="dialogs.decline" data-i18n-attrs="text"></button>
|
||||
<button id="accept-request" class="btn btn-rounded btn-grey" title="ENTER" data-i18n-key="dialogs.accept" data-i18n-attrs="text" autofocus></button>
|
||||
<button id="decline-request" class="btn btn-rounded btn-grey" title="ESCAPE" data-i18n-key="dialogs.decline" data-i18n-attrs="text"></button>
|
||||
</div>
|
||||
</x-paper>
|
||||
</x-background>
|
||||
|
@ -379,10 +391,10 @@
|
|||
<h2 class="center"></h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row center">
|
||||
<div class="row center p1">
|
||||
<div class="column center file-description">
|
||||
<div>
|
||||
<span class="display-name badge"></span>
|
||||
<span class="display-name badge badge-gradient"></span>
|
||||
<span data-i18n-key="dialogs.has-sent" data-i18n-attrs="text"></span>
|
||||
</div>
|
||||
<div class="row file-name">
|
||||
|
@ -396,9 +408,9 @@
|
|||
</div>
|
||||
<div class="center file-preview"></div>
|
||||
<div class="row-reverse center button-row">
|
||||
<button id="share-btn" class="button" data-i18n-key="dialogs.share" data-i18n-attrs="text" hidden></button>
|
||||
<button id="download-btn" class="button" data-i18n-key="dialogs.download" data-i18n-attrs="text" autofocus></button>
|
||||
<button class="button" data-i18n-key="dialogs.close" data-i18n-attrs="text" close></button>
|
||||
<button id="share-btn" class="btn btn-rounded btn-grey" data-i18n-key="dialogs.share" data-i18n-attrs="text" hidden></button>
|
||||
<button id="download-btn" class="btn btn-rounded btn-grey" data-i18n-key="dialogs.download" data-i18n-attrs="text" autofocus></button>
|
||||
<button class="btn btn-rounded btn-grey" data-i18n-key="dialogs.close" data-i18n-attrs="text" close></button>
|
||||
</div>
|
||||
</x-paper>
|
||||
</x-background>
|
||||
|
@ -413,22 +425,22 @@
|
|||
<h2 class="center" data-i18n-key="dialogs.send-message-title" data-i18n-attrs="text"></h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row center display-name-wrapper">
|
||||
<div class="row center p1 display-name-wrapper">
|
||||
<div class="column">
|
||||
<div class="text-center">
|
||||
<span data-i18n-key="dialogs.send-message-to" data-i18n-attrs="text"></span>
|
||||
<span class="display-name badge"></span>
|
||||
<span class="display-name badge badge-gradient"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="row p1">
|
||||
<div class="column fw">
|
||||
<div id="text-input" class="textarea" role="textbox" data-i18n-key="dialogs.message" data-i18n-attrs="title" autocapitalize="none" spellcheck="false" autofocus contenteditable></div>
|
||||
<div id="text-input" class="fw textarea" role="textbox" data-i18n-key="dialogs.message" data-i18n-attrs="title placeholder" autofocus contenteditable></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="button-row row-reverse">
|
||||
<button class="button" type="submit" title="CTRL/⌘ + ENTER" data-i18n-key="dialogs.send" data-i18n-attrs="text" disabled></button>
|
||||
<button class="button" type="button" title="ESCAPE" data-i18n-key="dialogs.cancel" data-i18n-attrs="text" close></button>
|
||||
<button class="btn btn-rounded btn-grey" type="submit" title="CTRL/⌘ + ENTER" data-i18n-key="dialogs.send" data-i18n-attrs="text" disabled></button>
|
||||
<button class="btn btn-rounded btn-grey" type="button" title="ESCAPE" data-i18n-key="dialogs.cancel" data-i18n-attrs="text" close></button>
|
||||
</div>
|
||||
</x-paper>
|
||||
</x-background>
|
||||
|
@ -441,20 +453,20 @@
|
|||
<div class="row center">
|
||||
<h2 class="text-center" data-i18n-key="dialogs.receive-text-title" data-i18n-attrs="text"></h2>
|
||||
</div>
|
||||
<div class="row center">
|
||||
<div class="row center p1 display-name-wrapper">
|
||||
<div class="text-center">
|
||||
<span class="display-name badge"></span>
|
||||
<span class="display-name badge badge-gradient"></span>
|
||||
<span data-i18n-key="dialogs.has-sent" data-i18n-attrs="text"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row center">
|
||||
<div class="row center p1">
|
||||
<div class="column fw">
|
||||
<div id="text" class="textarea fw"></div>
|
||||
<div id="text" class="textarea"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row-reverse center button-row">
|
||||
<button id="copy" class="button" title="CTRL/⌘ + C" data-i18n-key="dialogs.copy" data-i18n-attrs="text"></button>
|
||||
<button id="close" class="button" title="ESCAPE" data-i18n-key="dialogs.close" data-i18n-attrs="text"></button>
|
||||
<button id="copy" class="btn btn-rounded btn-grey" title="CTRL/⌘ + C" data-i18n-key="dialogs.copy" data-i18n-attrs="text"></button>
|
||||
<button id="close" class="btn btn-rounded btn-grey" title="ESCAPE" data-i18n-key="dialogs.close" data-i18n-attrs="text"></button>
|
||||
</div>
|
||||
</x-paper>
|
||||
</x-background>
|
||||
|
@ -463,10 +475,10 @@
|
|||
<x-dialog id="base64-paste-dialog">
|
||||
<x-background class="full center">
|
||||
<x-paper shadow="2">
|
||||
<button class="button center" id="base64-paste-btn" title="Paste"></button>
|
||||
<button class="btn btn-rounded btn-grey center" id="base64-paste-btn" title="Paste"></button>
|
||||
<div class="textarea" placeholder="Paste here to send files" title="CMD/⌘ + V" contenteditable hidden></div>
|
||||
<div class="row-reverse center button-row">
|
||||
<button class="button" data-i18n-key="dialogs.close" data-i18n-attrs="text" close></button>
|
||||
<button class="btn btn-rounded btn-grey" data-i18n-key="dialogs.close" data-i18n-attrs="text" close></button>
|
||||
</div>
|
||||
</x-paper>
|
||||
</x-background>
|
||||
|
@ -595,13 +607,9 @@
|
|||
</svg>
|
||||
<!-- Scripts -->
|
||||
<script src="scripts/localization.js"></script>
|
||||
<script src="scripts/theme.js"></script>
|
||||
<script src="scripts/network.js"></script>
|
||||
<script src="scripts/ui.js"></script>
|
||||
<script src="scripts/util.js"></script>
|
||||
<script src="scripts/QRCode.min.js" async></script>
|
||||
<script src="scripts/zip.min.js" async></script>
|
||||
<script src="scripts/NoSleep.min.js" async></script>
|
||||
<script src="scripts/persistent-storage.js"></script>
|
||||
<script src="scripts/ui-main.js"></script>
|
||||
<script src="scripts/main.js"></script>
|
||||
<!-- Sounds -->
|
||||
<audio id="blop" autobuffer="true">
|
||||
<source src="sounds/blop.mp3" type="audio/mpeg">
|
||||
|
|
|
@ -25,7 +25,8 @@
|
|||
"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"
|
||||
"activate-paste-mode-shared-text": "shared text",
|
||||
"webrtc-requirement": "To use PairDrop on this instance, WebRTC must be enabled!"
|
||||
},
|
||||
"footer": {
|
||||
"known-as": "You are known as:",
|
||||
|
@ -69,8 +70,9 @@
|
|||
"share": "Share",
|
||||
"download": "Download",
|
||||
"send-message-title": "Send Message",
|
||||
"send-message-to": "Send a Message to",
|
||||
"send-message-to": "To:",
|
||||
"message_title": "Insert message to send",
|
||||
"message_placeholder": "Text",
|
||||
"send": "Send",
|
||||
"receive-text-title": "Message Received",
|
||||
"copy": "Copy",
|
||||
|
|
|
@ -1,29 +1,30 @@
|
|||
{
|
||||
"name": "PairDrop",
|
||||
"short_name": "PairDrop",
|
||||
"icons": [{
|
||||
"icons": [
|
||||
{
|
||||
"src": "images/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},{
|
||||
},
|
||||
{
|
||||
"src": "images/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},{
|
||||
},
|
||||
{
|
||||
"src": "images/android-chrome-192x192-maskable.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},{
|
||||
},
|
||||
{
|
||||
"src": "images/android-chrome-512x512-maskable.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},{
|
||||
"src": "images/favicon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png"
|
||||
}],
|
||||
}
|
||||
],
|
||||
"background_color": "#efefef",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
|
|
60
public/scripts/browser-tabs-connector.js
Normal file
|
@ -0,0 +1,60 @@
|
|||
class BrowserTabsConnector {
|
||||
constructor() {
|
||||
this.bc = new BroadcastChannel('pairdrop');
|
||||
this.bc.addEventListener('message', e => this._onMessage(e));
|
||||
Events.on('broadcast-send', e => this._broadcastSend(e.detail));
|
||||
}
|
||||
|
||||
_broadcastSend(message) {
|
||||
this.bc.postMessage(message);
|
||||
}
|
||||
|
||||
_onMessage(e) {
|
||||
console.log('Broadcast:', e.data)
|
||||
switch (e.data.type) {
|
||||
case 'self-display-name-changed':
|
||||
Events.fire('self-display-name-changed', e.data.detail);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
static peerIsSameBrowser(peerId) {
|
||||
let peerIdsBrowser = JSON.parse(localStorage.getItem("peer_ids_browser"));
|
||||
return peerIdsBrowser
|
||||
? peerIdsBrowser.indexOf(peerId) !== -1
|
||||
: false;
|
||||
}
|
||||
|
||||
static async addPeerIdToLocalStorage() {
|
||||
const peerId = sessionStorage.getItem("peer_id");
|
||||
if (!peerId) return false;
|
||||
|
||||
let peerIdsBrowser = [];
|
||||
let peerIdsBrowserOld = JSON.parse(localStorage.getItem("peer_ids_browser"));
|
||||
|
||||
if (peerIdsBrowserOld) peerIdsBrowser.push(...peerIdsBrowserOld);
|
||||
peerIdsBrowser.push(peerId);
|
||||
peerIdsBrowser = peerIdsBrowser.filter(onlyUnique);
|
||||
localStorage.setItem("peer_ids_browser", JSON.stringify(peerIdsBrowser));
|
||||
|
||||
return peerIdsBrowser;
|
||||
}
|
||||
|
||||
static async removePeerIdFromLocalStorage(peerId) {
|
||||
let peerIdsBrowser = JSON.parse(localStorage.getItem("peer_ids_browser"));
|
||||
const index = peerIdsBrowser.indexOf(peerId);
|
||||
peerIdsBrowser.splice(index, 1);
|
||||
localStorage.setItem("peer_ids_browser", JSON.stringify(peerIdsBrowser));
|
||||
return peerId;
|
||||
}
|
||||
|
||||
|
||||
static async removeOtherPeerIdsFromLocalStorage() {
|
||||
const peerId = sessionStorage.getItem("peer_id");
|
||||
if (!peerId) return false;
|
||||
|
||||
let peerIdsBrowser = [peerId];
|
||||
localStorage.setItem("peer_ids_browser", JSON.stringify(peerIdsBrowser));
|
||||
return peerIdsBrowser;
|
||||
}
|
||||
}
|
|
@ -15,7 +15,8 @@ class Localization {
|
|||
? storedLanguageCode
|
||||
: Localization.systemLocale;
|
||||
|
||||
Localization.setTranslation(Localization.initialLocale)
|
||||
Localization
|
||||
.setTranslation(Localization.initialLocale)
|
||||
.then(_ => {
|
||||
console.log("Initial translation successful.");
|
||||
Events.fire("initial-translation-loaded");
|
||||
|
@ -50,7 +51,8 @@ class Localization {
|
|||
|
||||
if (Localization.isRTLLanguage(locale)) {
|
||||
htmlRootNode.setAttribute('dir', 'rtl');
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
htmlRootNode.removeAttribute('dir');
|
||||
}
|
||||
|
||||
|
@ -112,13 +114,9 @@ class Localization {
|
|||
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));
|
||||
}
|
||||
}
|
||||
else {
|
||||
element.setAttribute(attr, Localization.getTranslation(key, attr));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -156,7 +154,8 @@ class Localization {
|
|||
console.warn(`Missing translation entry for your language ${Localization.locale.toUpperCase()}. Using ${Localization.defaultLocale.toUpperCase()} instead.`, key, attr);
|
||||
console.warn(`Translate this string here: https://hosted.weblate.org/browse/pairdrop/pairdrop-spa/${Localization.locale.toLowerCase()}/?q=${key}`)
|
||||
console.log("Help translating PairDrop: https://hosted.weblate.org/engage/pairdrop/");
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
console.warn("Missing translation in default language:", key, attr);
|
||||
}
|
||||
}
|
||||
|
|
180
public/scripts/main.js
Normal file
|
@ -0,0 +1,180 @@
|
|||
class PairDrop {
|
||||
constructor() {
|
||||
this.$header = $$('header.opacity-0');
|
||||
this.$center = $$('#center');
|
||||
this.$footer = $$('footer');
|
||||
this.$xNoPeers = $$('x-no-peers');
|
||||
this.$headerNotificationButton = $('notification');
|
||||
this.$editPairedDevicesHeaderBtn = $('edit-paired-devices');
|
||||
this.$footerInstructionsPairedDevices = $$('.discovery-wrapper .badge-room-secret');
|
||||
this.$head = $$('head');
|
||||
this.$installBtn = $('install');
|
||||
|
||||
this.registerServiceWorker();
|
||||
|
||||
Events.on('beforeinstallprompt', e => this.onPwaInstallable(e));
|
||||
|
||||
const persistentStorage = new PersistentStorage();
|
||||
const themeUI = new ThemeUI();
|
||||
const backgroundCanvas = new BackgroundCanvas();
|
||||
|
||||
Events.on('initial-translation-loaded', _ => {
|
||||
// FooterUI needs translations
|
||||
const footerUI = new FooterUI();
|
||||
|
||||
Events.on('fade-in-ui', _ => this.fadeInUI())
|
||||
Events.on('fade-in-header', _ => this.fadeInHeader())
|
||||
|
||||
// Evaluate UI elements and fade in UI
|
||||
this.evaluateUI();
|
||||
|
||||
// Load deferred assets
|
||||
this.loadDeferredAssets();
|
||||
});
|
||||
|
||||
// Translate page -> fires 'initial-translation-loaded' on finish
|
||||
const localization = new Localization();
|
||||
}
|
||||
|
||||
registerServiceWorker() {
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker
|
||||
.register('service-worker.js')
|
||||
.then(serviceWorker => {
|
||||
console.log('Service Worker registered');
|
||||
window.serviceWorker = serviceWorker
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onPwaInstallable(e) {
|
||||
if (!window.matchMedia('(display-mode: minimal-ui)').matches) {
|
||||
// only display install btn when not installed
|
||||
this.$installBtn.removeAttribute('hidden');
|
||||
this.$installBtn.addEventListener('click', () => {
|
||||
this.$installBtn.setAttribute('hidden', true);
|
||||
e.prompt();
|
||||
});
|
||||
}
|
||||
return e.preventDefault();
|
||||
}
|
||||
|
||||
evaluateUI() {
|
||||
// Check whether notification permissions have already been granted
|
||||
if ('Notification' in window && Notification.permission !== 'granted') {
|
||||
this.$headerNotificationButton.removeAttribute('hidden');
|
||||
}
|
||||
|
||||
PersistentStorage
|
||||
.getAllRoomSecrets()
|
||||
.then(roomSecrets => {
|
||||
if (roomSecrets.length > 0) {
|
||||
this.$editPairedDevicesHeaderBtn.removeAttribute('hidden');
|
||||
this.$footerInstructionsPairedDevices.removeAttribute('hidden');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
Events.fire('evaluate-footer-badges');
|
||||
Events.fire('fade-in-header');
|
||||
});
|
||||
}
|
||||
|
||||
fadeInUI() {
|
||||
this.$center.classList.remove('opacity-0');
|
||||
this.$footer.classList.remove('opacity-0');
|
||||
|
||||
// Prevent flickering on load
|
||||
setTimeout(() => {
|
||||
this.$xNoPeers.classList.remove('no-animation-on-load');
|
||||
}, 600);
|
||||
}
|
||||
|
||||
fadeInHeader() {
|
||||
this.$header.classList.remove('opacity-0');
|
||||
}
|
||||
|
||||
loadDeferredAssets() {
|
||||
console.log("Load deferred assets");
|
||||
this.deferredStyles = [
|
||||
"styles/deferred-styles.css"
|
||||
];
|
||||
this.deferredScripts = [
|
||||
"scripts/browser-tabs-connector.js",
|
||||
"scripts/util.js",
|
||||
"scripts/network.js",
|
||||
"scripts/ui.js",
|
||||
"scripts/qr-code.min.js",
|
||||
"scripts/zip.min.js",
|
||||
"scripts/no-sleep.min.js"
|
||||
];
|
||||
this.deferredStyles.forEach(url => this.loadStyleSheet(url, _ => this.onStyleLoaded(url)))
|
||||
this.deferredScripts.forEach(url => this.loadScript(url, _ => this.onScriptLoaded(url)))
|
||||
}
|
||||
|
||||
loadStyleSheet(url, callback) {
|
||||
let stylesheet = document.createElement('link');
|
||||
stylesheet.rel = 'stylesheet';
|
||||
stylesheet.href = url;
|
||||
stylesheet.type = 'text/css';
|
||||
stylesheet.onload = callback;
|
||||
this.$head.appendChild(stylesheet);
|
||||
}
|
||||
|
||||
loadScript(url, callback) {
|
||||
let script = document.createElement("script");
|
||||
script.src = url;
|
||||
script.onload = callback;
|
||||
document.body.appendChild(script);
|
||||
}
|
||||
|
||||
onStyleLoaded(url) {
|
||||
// remove entry from array
|
||||
const index = this.deferredStyles.indexOf(url);
|
||||
if (index !== -1) {
|
||||
this.deferredStyles.splice(index, 1);
|
||||
}
|
||||
this.onAssetLoaded();
|
||||
}
|
||||
|
||||
onScriptLoaded(url) {
|
||||
// remove entry from array
|
||||
const index = this.deferredScripts.indexOf(url);
|
||||
if (index !== -1) {
|
||||
this.deferredScripts.splice(index, 1);
|
||||
}
|
||||
this.onAssetLoaded();
|
||||
}
|
||||
|
||||
onAssetLoaded() {
|
||||
if (this.deferredScripts.length || this.deferredStyles.length) return;
|
||||
|
||||
console.log("Loading of deferred assets completed. Start UI hydration.");
|
||||
|
||||
this.hydrate();
|
||||
}
|
||||
|
||||
hydrate() {
|
||||
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();
|
||||
const networkStatusUI = new NetworkStatusUI();
|
||||
const webShareTargetUI = new WebShareTargetUI();
|
||||
const webFileHandlersUI = new WebFileHandlersUI();
|
||||
const noSleepUI = new NoSleepUI();
|
||||
const broadCast = new BrowserTabsConnector();
|
||||
const server = new ServerConnection();
|
||||
const peers = new PeersManager(server);
|
||||
console.log("UI hydrated.")
|
||||
}
|
||||
}
|
||||
|
||||
const pairDrop = new PairDrop();
|
|
@ -1,26 +1,15 @@
|
|||
window.URL = window.URL || window.webkitURL;
|
||||
window.isRtcSupported = !!(window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection);
|
||||
|
||||
if (!window.isRtcSupported) alert("WebRTC must be enabled for PairDrop to work");
|
||||
|
||||
window.hiddenProperty = 'hidden' in document ? 'hidden' :
|
||||
'webkitHidden' in document ? 'webkitHidden' :
|
||||
'mozHidden' in document ? 'mozHidden' :
|
||||
null;
|
||||
window.visibilityChangeEvent = 'visibilitychange' in document ? 'visibilitychange' :
|
||||
'webkitvisibilitychange' in document ? 'webkitvisibilitychange' :
|
||||
'mozvisibilitychange' in document ? 'mozvisibilitychange' :
|
||||
null;
|
||||
|
||||
class ServerConnection {
|
||||
|
||||
constructor() {
|
||||
this._connect();
|
||||
Events.on('pagehide', _ => this._disconnect());
|
||||
document.addEventListener(window.visibilityChangeEvent, _ => this._onVisibilityChange());
|
||||
if (navigator.connection) navigator.connection.addEventListener('change', _ => this._reconnect());
|
||||
Events.on(window.visibilityChangeEvent, _ => this._onVisibilityChange());
|
||||
|
||||
if (navigator.connection) {
|
||||
navigator.connection.addEventListener('change', _ => this._reconnect());
|
||||
}
|
||||
|
||||
Events.on('room-secrets', e => this.send({ type: 'room-secrets', roomSecrets: e.detail }));
|
||||
Events.on('join-ip-room', e => this.send({ type: 'join-ip-room'}));
|
||||
Events.on('join-ip-room', _ => 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('pair-device-initiate', _ => this._onPairDeviceInitiate());
|
||||
|
@ -33,6 +22,49 @@ class ServerConnection {
|
|||
|
||||
Events.on('offline', _ => clearTimeout(this._reconnectTimer));
|
||||
Events.on('online', _ => this._connect());
|
||||
|
||||
this._getConfig().then(() => this._connect());
|
||||
}
|
||||
|
||||
_getConfig() {
|
||||
console.log("Loading config...")
|
||||
return new Promise((resolve, reject) => {
|
||||
let xhr = new XMLHttpRequest();
|
||||
xhr.addEventListener("load", () => {
|
||||
if (xhr.status === 200) {
|
||||
// Config received
|
||||
let config = JSON.parse(xhr.responseText);
|
||||
console.log("Config loaded:", config)
|
||||
this._config = config;
|
||||
Events.fire('config', config);
|
||||
resolve()
|
||||
} else if (xhr.status < 200 || xhr.status >= 300) {
|
||||
retry(xhr);
|
||||
}
|
||||
})
|
||||
|
||||
xhr.addEventListener("error", _ => {
|
||||
retry(xhr);
|
||||
});
|
||||
|
||||
function retry(request) {
|
||||
setTimeout(function () {
|
||||
openAndSend(request)
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function openAndSend() {
|
||||
xhr.open('GET', 'config');
|
||||
xhr.send();
|
||||
}
|
||||
|
||||
openAndSend(xhr);
|
||||
})
|
||||
}
|
||||
|
||||
_setWsConfig(wsConfig) {
|
||||
this._wsConfig = wsConfig;
|
||||
Events.fire('ws-config', wsConfig);
|
||||
}
|
||||
|
||||
_connect() {
|
||||
|
@ -69,7 +101,7 @@ class ServerConnection {
|
|||
|
||||
_onPairDeviceJoin(pairKey) {
|
||||
if (!this._isConnected()) {
|
||||
setTimeout(_ => this._onPairDeviceJoin(pairKey), 1000);
|
||||
setTimeout(() => this._onPairDeviceJoin(pairKey), 1000);
|
||||
return;
|
||||
}
|
||||
this.send({ type: 'pair-device-join', pairKey: pairKey });
|
||||
|
@ -85,7 +117,7 @@ class ServerConnection {
|
|||
|
||||
_onJoinPublicRoom(roomId, createIfInvalid) {
|
||||
if (!this._isConnected()) {
|
||||
setTimeout(_ => this._onJoinPublicRoom(roomId), 1000);
|
||||
setTimeout(() => this._onJoinPublicRoom(roomId), 1000);
|
||||
return;
|
||||
}
|
||||
this.send({ type: 'join-public-room', publicRoomId: roomId, createIfInvalid: createIfInvalid });
|
||||
|
@ -93,22 +125,18 @@ class ServerConnection {
|
|||
|
||||
_onLeavePublicRoom() {
|
||||
if (!this._isConnected()) {
|
||||
setTimeout(_ => this._onLeavePublicRoom(), 1000);
|
||||
setTimeout(() => this._onLeavePublicRoom(), 1000);
|
||||
return;
|
||||
}
|
||||
this.send({ type: 'leave-public-room' });
|
||||
}
|
||||
|
||||
_setRtcConfig(config) {
|
||||
window.rtcConfig = config;
|
||||
}
|
||||
|
||||
_onMessage(msg) {
|
||||
msg = JSON.parse(msg);
|
||||
if (msg.type !== 'ping') console.log('WS receive:', msg);
|
||||
switch (msg.type) {
|
||||
case 'rtc-config':
|
||||
this._setRtcConfig(msg.config);
|
||||
case 'ws-config':
|
||||
this._setWsConfig(msg.wsConfig);
|
||||
break;
|
||||
case 'peers':
|
||||
this._onPeers(msg);
|
||||
|
@ -158,6 +186,25 @@ class ServerConnection {
|
|||
case 'public-room-left':
|
||||
Events.fire('public-room-left');
|
||||
break;
|
||||
case 'request':
|
||||
case 'header':
|
||||
case 'partition':
|
||||
case 'partition-received':
|
||||
case 'progress':
|
||||
case 'files-transfer-response':
|
||||
case 'file-transfer-complete':
|
||||
case 'message-transfer-complete':
|
||||
case 'text':
|
||||
case 'display-name-changed':
|
||||
case 'ws-chunk':
|
||||
// ws-fallback
|
||||
if (this._wsConfig.wsFallback) {
|
||||
Events.fire('ws-relay', JSON.stringify(msg));
|
||||
}
|
||||
else {
|
||||
console.log("WS receive: message type is for websocket fallback only but websocket fallback is not activated on this instance.")
|
||||
}
|
||||
break;
|
||||
default:
|
||||
console.error('WS receive: unknown message type', msg);
|
||||
}
|
||||
|
@ -175,45 +222,57 @@ class ServerConnection {
|
|||
|
||||
_onDisplayName(msg) {
|
||||
// Add peerId and peerIdHash to sessionStorage to authenticate as the same device on page reload
|
||||
sessionStorage.setItem('peer_id', msg.message.peerId);
|
||||
sessionStorage.setItem('peer_id_hash', msg.message.peerIdHash);
|
||||
sessionStorage.setItem('peer_id', msg.peerId);
|
||||
sessionStorage.setItem('peer_id_hash', msg.peerIdHash);
|
||||
|
||||
// Add peerId to localStorage to mark it for other PairDrop tabs on the same browser
|
||||
BrowserTabsConnector.addPeerIdToLocalStorage().then(peerId => {
|
||||
if (!peerId) return;
|
||||
console.log("successfully added peerId to localStorage");
|
||||
BrowserTabsConnector
|
||||
.addPeerIdToLocalStorage()
|
||||
.then(peerId => {
|
||||
if (!peerId) return;
|
||||
console.log("successfully added peerId to localStorage");
|
||||
|
||||
// Only now join rooms
|
||||
Events.fire('join-ip-room');
|
||||
PersistentStorage.getAllRoomSecrets().then(roomSecrets => {
|
||||
Events.fire('room-secrets', roomSecrets);
|
||||
// Only now join rooms
|
||||
Events.fire('join-ip-room');
|
||||
PersistentStorage.getAllRoomSecrets()
|
||||
.then(roomSecrets => {
|
||||
Events.fire('room-secrets', roomSecrets);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Events.fire('display-name', msg);
|
||||
}
|
||||
|
||||
_endpoint() {
|
||||
// hack to detect if deployment or development environment
|
||||
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);
|
||||
// Check whether the instance specifies another signaling server otherwise use the current instance for signaling
|
||||
let wsServerDomain = this._config.signalingServer
|
||||
? this._config.signalingServer
|
||||
: location.host + location.pathname;
|
||||
|
||||
let wsUrl = new URL(protocol + '://' + wsServerDomain + 'server');
|
||||
|
||||
wsUrl.searchParams.append('webrtc_supported', window.isRtcSupported ? 'true' : 'false');
|
||||
|
||||
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);
|
||||
wsUrl.searchParams.append('peer_id', peerId);
|
||||
wsUrl.searchParams.append('peer_id_hash', peerIdHash);
|
||||
}
|
||||
return ws_url.toString();
|
||||
|
||||
return wsUrl.toString();
|
||||
}
|
||||
|
||||
_disconnect() {
|
||||
this.send({ type: 'disconnect' });
|
||||
|
||||
const peerId = sessionStorage.getItem('peer_id');
|
||||
BrowserTabsConnector.removePeerIdFromLocalStorage(peerId).then(_ => {
|
||||
console.log("successfully removed peerId from localStorage");
|
||||
});
|
||||
BrowserTabsConnector
|
||||
.removePeerIdFromLocalStorage(peerId)
|
||||
.then(_ => {
|
||||
console.log("successfully removed peerId from localStorage");
|
||||
});
|
||||
|
||||
if (!this._socket) return;
|
||||
|
||||
|
@ -229,7 +288,7 @@ class ServerConnection {
|
|||
setTimeout(() => {
|
||||
this._isReconnect = true;
|
||||
Events.fire('ws-disconnected');
|
||||
this._reconnectTimer = setTimeout(_ => this._connect(), 1000);
|
||||
this._reconnectTimer = setTimeout(() => this._connect(), 1000);
|
||||
}, 100); //delay for 100ms to prevent flickering on page reload
|
||||
}
|
||||
|
||||
|
@ -281,6 +340,9 @@ class Peer {
|
|||
this._send(JSON.stringify(message));
|
||||
}
|
||||
|
||||
// Is overwritten in expanding classes
|
||||
_send(message) {}
|
||||
|
||||
sendDisplayName(displayName) {
|
||||
this.sendJSON({type: 'display-name-changed', displayName: displayName});
|
||||
}
|
||||
|
@ -297,16 +359,27 @@ class Peer {
|
|||
return this._roomIds['secret'];
|
||||
}
|
||||
|
||||
_regenerationOfPairSecretNeeded() {
|
||||
return this._getPairSecret() && this._getPairSecret().length !== 256
|
||||
}
|
||||
|
||||
_getRoomTypes() {
|
||||
return Object.keys(this._roomIds);
|
||||
}
|
||||
|
||||
_updateRoomIds(roomType, roomId) {
|
||||
const roomTypeIsSecret = roomType === "secret";
|
||||
const roomIdIsNotPairSecret = this._getPairSecret() !== 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() && roomType === "secret" && this._isPaired() && this._getPairSecret() !== roomId) {
|
||||
if (!this._isSameBrowser()
|
||||
&& roomTypeIsSecret
|
||||
&& this._isPaired()
|
||||
&& roomIdIsNotPairSecret) {
|
||||
// multiple roomSecrets with same peer -> delete old roomSecret
|
||||
PersistentStorage.deleteRoomSecret(this._getPairSecret())
|
||||
PersistentStorage
|
||||
.deleteRoomSecret(this._getPairSecret())
|
||||
.then(deletedRoomSecret => {
|
||||
if (deletedRoomSecret) console.log("Successfully deleted duplicate room secret with same peer: ", deletedRoomSecret);
|
||||
});
|
||||
|
@ -314,8 +387,13 @@ class Peer {
|
|||
|
||||
this._roomIds[roomType] = roomId;
|
||||
|
||||
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 (<v1.7.0) to 256 chars (v1.7.0+)
|
||||
if (!this._isSameBrowser()
|
||||
&& roomTypeIsSecret
|
||||
&& this._isPaired()
|
||||
&& this._regenerationOfPairSecretNeeded()
|
||||
&& this._isCaller) {
|
||||
// increase security by initiating the increase of the roomSecret length
|
||||
// from 64 chars (<v1.7.0) to 256 chars (v1.7.0+)
|
||||
console.log('RoomSecret is regenerated to increase security')
|
||||
Events.fire('regenerate-room-secret', this._getPairSecret());
|
||||
}
|
||||
|
@ -336,7 +414,8 @@ class Peer {
|
|||
return;
|
||||
}
|
||||
|
||||
PersistentStorage.getRoomSecretEntry(this._getPairSecret())
|
||||
PersistentStorage
|
||||
.getRoomSecretEntry(this._getPairSecret())
|
||||
.then(roomSecretEntry => {
|
||||
const autoAccept = roomSecretEntry
|
||||
? roomSecretEntry.entry.auto_accept
|
||||
|
@ -367,13 +446,16 @@ class Peer {
|
|||
if (width && height) {
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
} else if (width) {
|
||||
}
|
||||
else if (width) {
|
||||
canvas.width = width;
|
||||
canvas.height = Math.floor(imageHeight * width / imageWidth)
|
||||
} else if (height) {
|
||||
}
|
||||
else if (height) {
|
||||
canvas.width = Math.floor(imageWidth * height / imageHeight);
|
||||
canvas.height = height;
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
canvas.width = imageWidth;
|
||||
canvas.height = imageHeight
|
||||
}
|
||||
|
@ -385,9 +467,11 @@ class Peer {
|
|||
resolve(dataUrl);
|
||||
}
|
||||
image.onerror = _ => reject(`Could not create an image thumbnail from type ${file.type}`);
|
||||
}).then(dataUrl => {
|
||||
})
|
||||
.then(dataUrl => {
|
||||
return dataUrl;
|
||||
}).catch(e => console.error(e));
|
||||
})
|
||||
.catch(e => console.error(e));
|
||||
}
|
||||
|
||||
async requestFileTransfer(files) {
|
||||
|
@ -622,7 +706,8 @@ class Peer {
|
|||
this._busy = false;
|
||||
Events.fire('notify-user', Localization.getTranslation("notifications.file-transfer-completed"));
|
||||
Events.fire('files-sent'); // used by 'Snapdrop & PairDrop for Android' app
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
this._dequeueFile();
|
||||
}
|
||||
}
|
||||
|
@ -673,9 +758,12 @@ class Peer {
|
|||
|
||||
class RTCPeer extends Peer {
|
||||
|
||||
constructor(serverConnection, isCaller, peerId, roomType, roomId) {
|
||||
constructor(serverConnection, isCaller, peerId, roomType, roomId, rtcConfig) {
|
||||
super(serverConnection, isCaller, peerId, roomType, roomId);
|
||||
|
||||
this.rtcSupported = true;
|
||||
this.rtcConfig = rtcConfig
|
||||
|
||||
if (!this._isCaller) return; // we will listen for a caller
|
||||
this._connect();
|
||||
}
|
||||
|
@ -685,13 +773,14 @@ class RTCPeer extends Peer {
|
|||
|
||||
if (this._isCaller) {
|
||||
this._openChannel();
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
this._conn.ondatachannel = e => this._onChannelOpened(e);
|
||||
}
|
||||
}
|
||||
|
||||
_openConnection() {
|
||||
this._conn = new RTCPeerConnection(window.rtcConfig);
|
||||
this._conn = new RTCPeerConnection(this.rtcConfig);
|
||||
this._conn.onicecandidate = e => this._onIceCandidate(e);
|
||||
this._conn.onicecandidateerror = e => this._onError(e);
|
||||
this._conn.onconnectionstatechange = _ => this._onConnectionStateChange();
|
||||
|
@ -708,14 +797,16 @@ class RTCPeer extends Peer {
|
|||
channel.onopen = e => this._onChannelOpened(e);
|
||||
channel.onerror = e => this._onError(e);
|
||||
|
||||
this._conn.createOffer()
|
||||
this._conn
|
||||
.createOffer()
|
||||
.then(d => this._onDescription(d))
|
||||
.catch(e => this._onError(e));
|
||||
}
|
||||
|
||||
_onDescription(description) {
|
||||
// description.sdp = description.sdp.replace('b=AS:30', 'b=AS:1638400');
|
||||
this._conn.setLocalDescription(description)
|
||||
this._conn
|
||||
.setLocalDescription(description)
|
||||
.then(_ => this._sendSignal({ sdp: description }))
|
||||
.catch(e => this._onError(e));
|
||||
}
|
||||
|
@ -729,16 +820,20 @@ class RTCPeer extends Peer {
|
|||
if (!this._conn) this._connect();
|
||||
|
||||
if (message.sdp) {
|
||||
this._conn.setRemoteDescription(message.sdp)
|
||||
.then( _ => {
|
||||
this._conn
|
||||
.setRemoteDescription(message.sdp)
|
||||
.then(_ => {
|
||||
if (message.sdp.type === 'offer') {
|
||||
return this._conn.createAnswer()
|
||||
return this._conn
|
||||
.createAnswer()
|
||||
.then(d => this._onDescription(d));
|
||||
}
|
||||
})
|
||||
.catch(e => this._onError(e));
|
||||
} else if (message.ice) {
|
||||
this._conn.addIceCandidate(new RTCIceCandidate(message.ice))
|
||||
}
|
||||
else if (message.ice) {
|
||||
this._conn
|
||||
.addIceCandidate(new RTCIceCandidate(message.ice))
|
||||
.catch(e => this._onError(e));
|
||||
}
|
||||
}
|
||||
|
@ -879,6 +974,48 @@ class RTCPeer extends Peer {
|
|||
}
|
||||
}
|
||||
|
||||
class WSPeer extends Peer {
|
||||
|
||||
constructor(serverConnection, isCaller, peerId, roomType, roomId) {
|
||||
super(serverConnection, isCaller, peerId, roomType, roomId);
|
||||
|
||||
this.rtcSupported = false;
|
||||
|
||||
if (!this._isCaller) return; // we will listen for a caller
|
||||
this._sendSignal();
|
||||
}
|
||||
|
||||
_send(chunk) {
|
||||
this.sendJSON({
|
||||
type: 'ws-chunk',
|
||||
chunk: arrayBufferToBase64(chunk)
|
||||
});
|
||||
}
|
||||
|
||||
sendJSON(message) {
|
||||
message.to = this._peerId;
|
||||
message.roomType = this._getRoomTypes()[0];
|
||||
message.roomId = this._roomIds[this._getRoomTypes()[0]];
|
||||
this._server.send(message);
|
||||
}
|
||||
|
||||
_sendSignal(connected = false) {
|
||||
this.sendJSON({type: 'signal', connected: connected});
|
||||
}
|
||||
|
||||
onServerMessage(message) {
|
||||
this._peerId = message.sender.id;
|
||||
Events.fire('peer-connected', {peerId: message.sender.id, connectionHash: this.getConnectionHash()})
|
||||
if (message.connected) return;
|
||||
this._sendSignal(true);
|
||||
}
|
||||
|
||||
getConnectionHash() {
|
||||
// Todo: implement SubtleCrypto asymmetric encryption and create connectionHash from public keys
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
class PeersManager {
|
||||
|
||||
constructor(serverConnection) {
|
||||
|
@ -902,10 +1039,17 @@ class PeersManager {
|
|||
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('display-name', e => this._onDisplayName(e.detail.displayName));
|
||||
Events.on('self-display-name-changed', e => this._notifyPeersDisplayNameChanged(e.detail));
|
||||
Events.on('notify-peer-display-name-changed', e => this._notifyPeerDisplayNameChanged(e.detail));
|
||||
Events.on('auto-accept-updated', e => this._onAutoAcceptUpdated(e.detail.roomSecret, e.detail.autoAccept));
|
||||
Events.on('ws-disconnected', _ => this._onWsDisconnected());
|
||||
Events.on('ws-relay', e => this._onWsRelay(e.detail));
|
||||
Events.on('ws-config', e => this._onWsConfig(e.detail));
|
||||
}
|
||||
|
||||
_onWsConfig(wsConfig) {
|
||||
this._wsConfig = wsConfig;
|
||||
}
|
||||
|
||||
_onMessage(message) {
|
||||
|
@ -913,9 +1057,10 @@ class PeersManager {
|
|||
this.peers[peerId].onServerMessage(message);
|
||||
}
|
||||
|
||||
_refreshPeer(peer, roomType, roomId) {
|
||||
if (!peer) return false;
|
||||
_refreshPeer(peerId, roomType, roomId) {
|
||||
if (!this._peerExists(peerId)) return false;
|
||||
|
||||
const peer = this.peers[peerId];
|
||||
const roomTypesDiffer = Object.keys(peer._roomIds)[0] !== roomType;
|
||||
const roomIdsDiffer = peer._roomIds[roomType] !== roomId;
|
||||
|
||||
|
@ -933,26 +1078,42 @@ class PeersManager {
|
|||
return true;
|
||||
}
|
||||
|
||||
_createOrRefreshPeer(isCaller, peerId, roomType, roomId) {
|
||||
const peer = this.peers[peerId];
|
||||
if (peer) {
|
||||
this._refreshPeer(peer, roomType, roomId);
|
||||
_createOrRefreshPeer(isCaller, peerId, roomType, roomId, rtcSupported) {
|
||||
if (this._peerExists(peerId)) {
|
||||
this._refreshPeer(peerId, roomType, roomId);
|
||||
return;
|
||||
}
|
||||
|
||||
this.peers[peerId] = new RTCPeer(this._server, isCaller, peerId, roomType, roomId);
|
||||
if (window.isRtcSupported && rtcSupported) {
|
||||
this.peers[peerId] = new RTCPeer(this._server, isCaller, peerId, roomType, roomId, this._wsConfig.rtcConfig);
|
||||
}
|
||||
else if (this._wsConfig.wsFallback) {
|
||||
this.peers[peerId] = new WSPeer(this._server, isCaller, peerId, roomType, roomId);
|
||||
}
|
||||
else {
|
||||
console.warn("Websocket fallback is not activated on this instance.\n" +
|
||||
"Activate WebRTC in this browser or ask the admin of this instance to activate the websocket fallback.")
|
||||
}
|
||||
}
|
||||
|
||||
_onPeerJoined(message) {
|
||||
this._createOrRefreshPeer(false, message.peer.id, message.roomType, message.roomId);
|
||||
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.roomId);
|
||||
this._createOrRefreshPeer(true, peer.id, message.roomType, message.roomId, peer.rtcSupported);
|
||||
})
|
||||
}
|
||||
|
||||
_onWsRelay(message) {
|
||||
if (!this._wsConfig.wsFallback) return;
|
||||
|
||||
const messageJSON = JSON.parse(message);
|
||||
if (messageJSON.type === 'ws-chunk') message = base64ToArrayBuffer(messageJSON.chunk);
|
||||
this.peers[messageJSON.sender.id]._onMessage(message);
|
||||
}
|
||||
|
||||
_onRespondToFileTransferRequest(detail) {
|
||||
this.peers[detail.to]._respondToFileTransferRequest(detail.accepted);
|
||||
}
|
||||
|
@ -978,6 +1139,9 @@ class PeersManager {
|
|||
}
|
||||
|
||||
_onPeerLeft(message) {
|
||||
if (this._peerExists(message.peerId) && this._webRtcSupported(message.peerId)) {
|
||||
console.log('WSPeer left:', message.peerId);
|
||||
}
|
||||
if (message.disconnect === true) {
|
||||
// if user actively disconnected from PairDrop server, disconnect all peer to peer connections immediately
|
||||
this._disconnectOrRemoveRoomTypeByPeerId(message.peerId, message.roomType);
|
||||
|
@ -985,10 +1149,12 @@ class PeersManager {
|
|||
// 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
|
||||
if (Object.keys(this.peers).length === 0) {
|
||||
BrowserTabsConnector.removeOtherPeerIdsFromLocalStorage().then(peerIds => {
|
||||
if (!peerIds) return;
|
||||
console.log("successfully removed other peerIds from localStorage");
|
||||
});
|
||||
BrowserTabsConnector
|
||||
.removeOtherPeerIdsFromLocalStorage()
|
||||
.then(peerIds => {
|
||||
if (!peerIds) return;
|
||||
console.log("successfully removed other peerIds from localStorage");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -997,6 +1163,24 @@ class PeersManager {
|
|||
this._notifyPeerDisplayNameChanged(peerId);
|
||||
}
|
||||
|
||||
_peerExists(peerId) {
|
||||
return !!this.peers[peerId];
|
||||
}
|
||||
|
||||
_webRtcSupported(peerId) {
|
||||
return this.peers[peerId].rtcSupported
|
||||
}
|
||||
|
||||
_onWsDisconnected() {
|
||||
if (!this._wsConfig || !this._wsConfig.wsFallback) return;
|
||||
|
||||
for (const peerId in this.peers) {
|
||||
if (!this._webRtcSupported(peerId)) {
|
||||
Events.fire('peer-disconnected', peerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_onPeerDisconnected(peerId) {
|
||||
const peer = this.peers[peerId];
|
||||
delete this.peers[peerId];
|
||||
|
@ -1038,16 +1222,19 @@ class PeersManager {
|
|||
|
||||
if (peer._getRoomTypes().length > 1) {
|
||||
peer._removeRoomType(roomType);
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
Events.fire('peer-disconnected', peerId);
|
||||
}
|
||||
}
|
||||
|
||||
_onRoomSecretRegenerated(message) {
|
||||
PersistentStorage.updateRoomSecret(message.oldRoomSecret, message.newRoomSecret).then(_ => {
|
||||
console.log("successfully regenerated room secret");
|
||||
Events.fire("room-secrets", [message.newRoomSecret]);
|
||||
})
|
||||
PersistentStorage
|
||||
.updateRoomSecret(message.oldRoomSecret, message.newRoomSecret)
|
||||
.then(_ => {
|
||||
console.log("successfully regenerated room secret");
|
||||
Events.fire("room-secrets", [message.newRoomSecret]);
|
||||
})
|
||||
}
|
||||
|
||||
_notifyPeersDisplayNameChanged(newDisplayName) {
|
||||
|
@ -1173,17 +1360,3 @@ class FileDigester {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
class Events {
|
||||
static fire(type, detail = {}) {
|
||||
window.dispatchEvent(new CustomEvent(type, { detail: detail }));
|
||||
}
|
||||
|
||||
static on(type, callback, options = false) {
|
||||
return window.addEventListener(type, callback, options);
|
||||
}
|
||||
|
||||
static off(type, callback, options = false) {
|
||||
return window.removeEventListener(type, callback, options);
|
||||
}
|
||||
}
|
||||
|
|
299
public/scripts/persistent-storage.js
Normal file
|
@ -0,0 +1,299 @@
|
|||
class PersistentStorage {
|
||||
constructor() {
|
||||
if (!('indexedDB' in window)) {
|
||||
PersistentStorage.logBrowserNotCapable();
|
||||
return;
|
||||
}
|
||||
const DBOpenRequest = window.indexedDB.open('pairdrop_store', 4);
|
||||
DBOpenRequest.onerror = e => {
|
||||
PersistentStorage.logBrowserNotCapable();
|
||||
console.log('Error initializing database: ');
|
||||
console.log(e)
|
||||
};
|
||||
DBOpenRequest.onsuccess = _ => {
|
||||
console.log('Database initialised.');
|
||||
};
|
||||
DBOpenRequest.onupgradeneeded = e => {
|
||||
const db = e.target.result;
|
||||
const txn = e.target.transaction;
|
||||
|
||||
db.onerror = e => console.log('Error loading database: ' + e);
|
||||
|
||||
console.log(`Upgrading IndexedDB database from version ${e.oldVersion} to version ${e.newVersion}`);
|
||||
|
||||
if (e.oldVersion === 0) {
|
||||
// initiate v1
|
||||
db.createObjectStore('keyval');
|
||||
let roomSecretsObjectStore1 = db.createObjectStore('room_secrets', {autoIncrement: true});
|
||||
roomSecretsObjectStore1.createIndex('secret', 'secret', { unique: true });
|
||||
}
|
||||
if (e.oldVersion <= 1) {
|
||||
// migrate to v2
|
||||
db.createObjectStore('share_target_files');
|
||||
}
|
||||
if (e.oldVersion <= 2) {
|
||||
// migrate to v3
|
||||
db.deleteObjectStore('share_target_files');
|
||||
db.createObjectStore('share_target_files', {autoIncrement: true});
|
||||
}
|
||||
if (e.oldVersion <= 3) {
|
||||
// migrate to v4
|
||||
let roomSecretsObjectStore4 = txn.objectStore('room_secrets');
|
||||
roomSecretsObjectStore4.createIndex('display_name', 'display_name');
|
||||
roomSecretsObjectStore4.createIndex('auto_accept', 'auto_accept');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static logBrowserNotCapable() {
|
||||
console.log("This browser does not support IndexedDB. Paired devices will be gone after the browser is closed.");
|
||||
}
|
||||
|
||||
static set(key, value) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const DBOpenRequest = window.indexedDB.open('pairdrop_store');
|
||||
DBOpenRequest.onsuccess = e => {
|
||||
const db = e.target.result;
|
||||
const transaction = db.transaction('keyval', 'readwrite');
|
||||
const objectStore = transaction.objectStore('keyval');
|
||||
const objectStoreRequest = objectStore.put(value, key);
|
||||
objectStoreRequest.onsuccess = _ => {
|
||||
console.log(`Request successful. Added key-pair: ${key} - ${value}`);
|
||||
resolve(value);
|
||||
};
|
||||
}
|
||||
DBOpenRequest.onerror = e => {
|
||||
reject(e);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
static get(key) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const DBOpenRequest = window.indexedDB.open('pairdrop_store');
|
||||
DBOpenRequest.onsuccess = e => {
|
||||
const db = e.target.result;
|
||||
const transaction = db.transaction('keyval', 'readonly');
|
||||
const objectStore = transaction.objectStore('keyval');
|
||||
const objectStoreRequest = objectStore.get(key);
|
||||
objectStoreRequest.onsuccess = _ => {
|
||||
console.log(`Request successful. Retrieved key-pair: ${key} - ${objectStoreRequest.result}`);
|
||||
resolve(objectStoreRequest.result);
|
||||
}
|
||||
}
|
||||
DBOpenRequest.onerror = e => {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static delete(key) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const DBOpenRequest = window.indexedDB.open('pairdrop_store');
|
||||
DBOpenRequest.onsuccess = e => {
|
||||
const db = e.target.result;
|
||||
const transaction = db.transaction('keyval', 'readwrite');
|
||||
const objectStore = transaction.objectStore('keyval');
|
||||
const objectStoreRequest = objectStore.delete(key);
|
||||
objectStoreRequest.onsuccess = _ => {
|
||||
console.log(`Request successful. Deleted key: ${key}`);
|
||||
resolve();
|
||||
};
|
||||
}
|
||||
DBOpenRequest.onerror = e => {
|
||||
reject(e);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
static addRoomSecret(roomSecret, displayName, deviceName) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const DBOpenRequest = window.indexedDB.open('pairdrop_store');
|
||||
DBOpenRequest.onsuccess = e => {
|
||||
const db = e.target.result;
|
||||
const transaction = db.transaction('room_secrets', 'readwrite');
|
||||
const objectStore = transaction.objectStore('room_secrets');
|
||||
const objectStoreRequest = objectStore.add({
|
||||
'secret': roomSecret,
|
||||
'display_name': displayName,
|
||||
'device_name': deviceName,
|
||||
'auto_accept': false
|
||||
});
|
||||
objectStoreRequest.onsuccess = e => {
|
||||
console.log(`Request successful. RoomSecret added: ${e.target.result}`);
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
DBOpenRequest.onerror = e => {
|
||||
reject(e);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
static async getAllRoomSecrets() {
|
||||
try {
|
||||
const roomSecrets = await this.getAllRoomSecretEntries();
|
||||
let secrets = [];
|
||||
for (let i = 0; i < roomSecrets.length; i++) {
|
||||
secrets.push(roomSecrets[i].secret);
|
||||
}
|
||||
console.log(`Request successful. Retrieved ${secrets.length} room_secrets`);
|
||||
return(secrets);
|
||||
} catch (e) {
|
||||
this.logBrowserNotCapable();
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
static getAllRoomSecretEntries() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const DBOpenRequest = window.indexedDB.open('pairdrop_store');
|
||||
DBOpenRequest.onsuccess = (e) => {
|
||||
const db = e.target.result;
|
||||
const transaction = db.transaction('room_secrets', 'readonly');
|
||||
const objectStore = transaction.objectStore('room_secrets');
|
||||
const objectStoreRequest = objectStore.getAll();
|
||||
objectStoreRequest.onsuccess = e => {
|
||||
resolve(e.target.result);
|
||||
}
|
||||
}
|
||||
DBOpenRequest.onerror = (e) => {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static getRoomSecretEntry(roomSecret) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const DBOpenRequest = window.indexedDB.open('pairdrop_store');
|
||||
DBOpenRequest.onsuccess = e => {
|
||||
const db = e.target.result;
|
||||
const transaction = db.transaction('room_secrets', 'readonly');
|
||||
const objectStore = transaction.objectStore('room_secrets');
|
||||
const objectStoreRequestKey = objectStore.index("secret").getKey(roomSecret);
|
||||
objectStoreRequestKey.onsuccess = e => {
|
||||
const key = e.target.result;
|
||||
if (!key) {
|
||||
console.log(`Nothing to retrieve. Entry for room_secret not existing: ${roomSecret}`);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const objectStoreRequestRetrieval = objectStore.get(key);
|
||||
objectStoreRequestRetrieval.onsuccess = e => {
|
||||
console.log(`Request successful. Retrieved entry for room_secret: ${key}`);
|
||||
resolve({
|
||||
"entry": e.target.result,
|
||||
"key": key
|
||||
});
|
||||
}
|
||||
objectStoreRequestRetrieval.onerror = (e) => {
|
||||
reject(e);
|
||||
}
|
||||
};
|
||||
}
|
||||
DBOpenRequest.onerror = (e) => {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static deleteRoomSecret(roomSecret) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const DBOpenRequest = window.indexedDB.open('pairdrop_store');
|
||||
DBOpenRequest.onsuccess = (e) => {
|
||||
const db = e.target.result;
|
||||
const transaction = db.transaction('room_secrets', 'readwrite');
|
||||
const objectStore = transaction.objectStore('room_secrets');
|
||||
const objectStoreRequestKey = objectStore.index("secret").getKey(roomSecret);
|
||||
objectStoreRequestKey.onsuccess = e => {
|
||||
if (!e.target.result) {
|
||||
console.log(`Nothing to delete. room_secret not existing: ${roomSecret}`);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const key = e.target.result;
|
||||
const objectStoreRequestDeletion = objectStore.delete(key);
|
||||
objectStoreRequestDeletion.onsuccess = _ => {
|
||||
console.log(`Request successful. Deleted room_secret: ${key}`);
|
||||
resolve(roomSecret);
|
||||
}
|
||||
objectStoreRequestDeletion.onerror = e => {
|
||||
reject(e);
|
||||
}
|
||||
};
|
||||
}
|
||||
DBOpenRequest.onerror = e => {
|
||||
reject(e);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
static clearRoomSecrets() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const DBOpenRequest = window.indexedDB.open('pairdrop_store');
|
||||
DBOpenRequest.onsuccess = (e) => {
|
||||
const db = e.target.result;
|
||||
const transaction = db.transaction('room_secrets', 'readwrite');
|
||||
const objectStore = transaction.objectStore('room_secrets');
|
||||
const objectStoreRequest = objectStore.clear();
|
||||
objectStoreRequest.onsuccess = _ => {
|
||||
console.log('Request successful. All room_secrets cleared');
|
||||
resolve();
|
||||
};
|
||||
}
|
||||
DBOpenRequest.onerror = e => {
|
||||
reject(e);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
static updateRoomSecretNames(roomSecret, displayName, deviceName) {
|
||||
return this.updateRoomSecret(roomSecret, undefined, displayName, deviceName);
|
||||
}
|
||||
|
||||
static updateRoomSecretAutoAccept(roomSecret, autoAccept) {
|
||||
return this.updateRoomSecret(roomSecret, undefined, undefined, undefined, autoAccept);
|
||||
}
|
||||
|
||||
static updateRoomSecret(roomSecret, updatedRoomSecret = undefined, updatedDisplayName = undefined, updatedDeviceName = undefined, updatedAutoAccept = undefined) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const DBOpenRequest = window.indexedDB.open('pairdrop_store');
|
||||
DBOpenRequest.onsuccess = e => {
|
||||
const db = e.target.result;
|
||||
this.getRoomSecretEntry(roomSecret)
|
||||
.then(roomSecretEntry => {
|
||||
if (!roomSecretEntry) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
const transaction = db.transaction('room_secrets', 'readwrite');
|
||||
const objectStore = transaction.objectStore('room_secrets');
|
||||
// Do not use `updatedRoomSecret ?? roomSecretEntry.entry.secret` to ensure compatibility with older browsers
|
||||
const updatedRoomSecretEntry = {
|
||||
'secret': updatedRoomSecret !== undefined ? updatedRoomSecret : roomSecretEntry.entry.secret,
|
||||
'display_name': updatedDisplayName !== undefined ? updatedDisplayName : roomSecretEntry.entry.display_name,
|
||||
'device_name': updatedDeviceName !== undefined ? updatedDeviceName : roomSecretEntry.entry.device_name,
|
||||
'auto_accept': updatedAutoAccept !== undefined ? updatedAutoAccept : roomSecretEntry.entry.auto_accept
|
||||
};
|
||||
|
||||
const objectStoreRequestUpdate = objectStore.put(updatedRoomSecretEntry, roomSecretEntry.key);
|
||||
|
||||
objectStoreRequestUpdate.onsuccess = e => {
|
||||
console.log(`Request successful. Updated room_secret: ${roomSecretEntry.key}`);
|
||||
resolve({
|
||||
"entry": updatedRoomSecretEntry,
|
||||
"key": roomSecretEntry.key
|
||||
});
|
||||
}
|
||||
|
||||
objectStoreRequestUpdate.onerror = (e) => {
|
||||
reject(e);
|
||||
}
|
||||
})
|
||||
.catch(e => reject(e));
|
||||
};
|
||||
|
||||
DBOpenRequest.onerror = e => reject(e);
|
||||
})
|
||||
}
|
||||
}
|
|
@ -1,78 +0,0 @@
|
|||
(function(){
|
||||
|
||||
const prefersDarkTheme = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const prefersLightTheme = window.matchMedia('(prefers-color-scheme: light)').matches;
|
||||
|
||||
const $themeAuto = document.getElementById('theme-auto');
|
||||
const $themeLight = document.getElementById('theme-light');
|
||||
const $themeDark = document.getElementById('theme-dark');
|
||||
|
||||
let currentTheme = localStorage.getItem('theme');
|
||||
|
||||
if (currentTheme === 'dark') {
|
||||
setModeToDark();
|
||||
} else if (currentTheme === 'light') {
|
||||
setModeToLight();
|
||||
}
|
||||
|
||||
$themeAuto.addEventListener('click', _ => {
|
||||
if (currentTheme) {
|
||||
setModeToAuto();
|
||||
} else {
|
||||
setModeToDark();
|
||||
}
|
||||
});
|
||||
$themeLight.addEventListener('click', _ => {
|
||||
if (currentTheme !== 'light') {
|
||||
setModeToLight();
|
||||
} else {
|
||||
setModeToAuto();
|
||||
}
|
||||
});
|
||||
$themeDark.addEventListener('click', _ => {
|
||||
if (currentTheme !== 'dark') {
|
||||
setModeToDark();
|
||||
} else {
|
||||
setModeToLight();
|
||||
}
|
||||
});
|
||||
|
||||
function setModeToDark() {
|
||||
document.body.classList.remove('light-theme');
|
||||
document.body.classList.add('dark-theme');
|
||||
localStorage.setItem('theme', 'dark');
|
||||
currentTheme = 'dark';
|
||||
|
||||
$themeAuto.classList.remove("selected");
|
||||
$themeLight.classList.remove("selected");
|
||||
$themeDark.classList.add("selected");
|
||||
}
|
||||
|
||||
function setModeToLight() {
|
||||
document.body.classList.remove('dark-theme');
|
||||
document.body.classList.add('light-theme');
|
||||
localStorage.setItem('theme', 'light');
|
||||
currentTheme = 'light';
|
||||
|
||||
$themeAuto.classList.remove("selected");
|
||||
$themeLight.classList.add("selected");
|
||||
$themeDark.classList.remove("selected");
|
||||
}
|
||||
|
||||
function setModeToAuto() {
|
||||
document.body.classList.remove('dark-theme');
|
||||
document.body.classList.remove('light-theme');
|
||||
if (prefersDarkTheme) {
|
||||
document.body.classList.add('dark-theme');
|
||||
} else if (prefersLightTheme) {
|
||||
document.body.classList.add('light-theme');
|
||||
}
|
||||
localStorage.removeItem('theme');
|
||||
currentTheme = undefined;
|
||||
|
||||
$themeAuto.classList.add("selected");
|
||||
$themeLight.classList.remove("selected");
|
||||
$themeDark.classList.remove("selected");
|
||||
}
|
||||
|
||||
})();
|
286
public/scripts/ui-main.js
Normal file
|
@ -0,0 +1,286 @@
|
|||
// Selector shortcuts
|
||||
const $ = query => document.getElementById(query);
|
||||
const $$ = query => document.querySelector(query);
|
||||
|
||||
// Event listener shortcuts
|
||||
class Events {
|
||||
static fire(type, detail = {}) {
|
||||
window.dispatchEvent(new CustomEvent(type, { detail: detail }));
|
||||
}
|
||||
|
||||
static on(type, callback, options) {
|
||||
return window.addEventListener(type, callback, options);
|
||||
}
|
||||
|
||||
static off(type, callback, options) {
|
||||
return window.removeEventListener(type, callback, options);
|
||||
}
|
||||
}
|
||||
|
||||
// UIs needed on start
|
||||
class ThemeUI {
|
||||
|
||||
constructor() {
|
||||
this.prefersDarkTheme = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
this.prefersLightTheme = window.matchMedia('(prefers-color-scheme: light)').matches;
|
||||
|
||||
this.$themeAutoBtn = document.getElementById('theme-auto');
|
||||
this.$themeLightBtn = document.getElementById('theme-light');
|
||||
this.$themeDarkBtn = document.getElementById('theme-dark');
|
||||
|
||||
let currentTheme = this.getCurrentTheme();
|
||||
if (currentTheme === 'dark') {
|
||||
this.setModeToDark();
|
||||
} else if (currentTheme === 'light') {
|
||||
this.setModeToLight();
|
||||
}
|
||||
|
||||
this.$themeAutoBtn.addEventListener('click', _ => this.onClickAuto());
|
||||
this.$themeLightBtn.addEventListener('click', _ => this.onClickLight());
|
||||
this.$themeDarkBtn.addEventListener('click', _ => this.onClickDark());
|
||||
}
|
||||
|
||||
getCurrentTheme() {
|
||||
return localStorage.getItem('theme');
|
||||
}
|
||||
|
||||
setCurrentTheme(theme) {
|
||||
localStorage.setItem('theme', theme);
|
||||
}
|
||||
|
||||
onClickAuto() {
|
||||
if (this.getCurrentTheme()) {
|
||||
this.setModeToAuto();
|
||||
} else {
|
||||
this.setModeToDark();
|
||||
}
|
||||
}
|
||||
|
||||
onClickLight() {
|
||||
if (this.getCurrentTheme() !== 'light') {
|
||||
this.setModeToLight();
|
||||
} else {
|
||||
this.setModeToAuto();
|
||||
}
|
||||
}
|
||||
|
||||
onClickDark() {
|
||||
if (this.getCurrentTheme() !== 'dark') {
|
||||
this.setModeToDark();
|
||||
} else {
|
||||
this.setModeToLight();
|
||||
}
|
||||
}
|
||||
|
||||
setModeToDark() {
|
||||
document.body.classList.remove('light-theme');
|
||||
document.body.classList.add('dark-theme');
|
||||
|
||||
this.setCurrentTheme('dark');
|
||||
|
||||
this.$themeAutoBtn.classList.remove("selected");
|
||||
this.$themeLightBtn.classList.remove("selected");
|
||||
this.$themeDarkBtn.classList.add("selected");
|
||||
}
|
||||
|
||||
setModeToLight() {
|
||||
document.body.classList.remove('dark-theme');
|
||||
document.body.classList.add('light-theme');
|
||||
|
||||
this.setCurrentTheme('light');
|
||||
|
||||
this.$themeAutoBtn.classList.remove("selected");
|
||||
this.$themeLightBtn.classList.add("selected");
|
||||
this.$themeDarkBtn.classList.remove("selected");
|
||||
}
|
||||
|
||||
setModeToAuto() {
|
||||
document.body.classList.remove('dark-theme');
|
||||
document.body.classList.remove('light-theme');
|
||||
if (this.prefersDarkTheme) {
|
||||
document.body.classList.add('dark-theme');
|
||||
}
|
||||
else if (this.prefersLightTheme) {
|
||||
document.body.classList.add('light-theme');
|
||||
}
|
||||
localStorage.removeItem('theme');
|
||||
|
||||
this.$themeAutoBtn.classList.add("selected");
|
||||
this.$themeLightBtn.classList.remove("selected");
|
||||
this.$themeDarkBtn.classList.remove("selected");
|
||||
}
|
||||
}
|
||||
|
||||
class FooterUI {
|
||||
|
||||
constructor() {
|
||||
this.$displayName = $('display-name');
|
||||
this.$discoveryWrapper = $$('footer .discovery-wrapper');
|
||||
|
||||
// Show "Loading…"
|
||||
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));
|
||||
|
||||
Events.on('display-name', e => this._onDisplayName(e.detail.displayName));
|
||||
Events.on('self-display-name-changed', e => this._insertDisplayName(e.detail));
|
||||
|
||||
// Load saved display name on page load
|
||||
Events.on('ws-connected', _ => this._loadSavedDisplayName());
|
||||
|
||||
Events.on('evaluate-footer-badges', _ => this._evaluateFooterBadges());
|
||||
}
|
||||
|
||||
_evaluateFooterBadges() {
|
||||
if (this.$discoveryWrapper.querySelectorAll('div:last-of-type > span[hidden]').length < 2) {
|
||||
this.$discoveryWrapper.classList.remove('row');
|
||||
this.$discoveryWrapper.classList.add('column');
|
||||
}
|
||||
else {
|
||||
this.$discoveryWrapper.classList.remove('column');
|
||||
this.$discoveryWrapper.classList.add('row');
|
||||
}
|
||||
Events.fire('redraw-canvas');
|
||||
Events.fire('fade-in-ui');
|
||||
}
|
||||
|
||||
_loadSavedDisplayName() {
|
||||
this._getSavedDisplayName()
|
||||
.then(displayName => {
|
||||
console.log("Retrieved edited display name:", displayName)
|
||||
if (displayName) {
|
||||
Events.fire('self-display-name-changed', displayName);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_onDisplayName(displayName){
|
||||
// set display name
|
||||
this.$displayName.setAttribute('placeholder', displayName);
|
||||
}
|
||||
|
||||
|
||||
_insertDisplayName(displayName) {
|
||||
this.$displayName.textContent = displayName;
|
||||
}
|
||||
|
||||
_onKeyDownDisplayName(e) {
|
||||
if (e.key === "Enter" || e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.target.blur();
|
||||
}
|
||||
}
|
||||
|
||||
_onKeyUpDisplayName(e) {
|
||||
// fix for Firefox inserting a linebreak into div on edit which prevents the placeholder from showing automatically when it is empty
|
||||
if (/^(\n|\r|\r\n)$/.test(e.target.innerText)) e.target.innerText = '';
|
||||
}
|
||||
|
||||
async _saveDisplayName(newDisplayName) {
|
||||
newDisplayName = newDisplayName.replace(/(\n|\r|\r\n)/, '')
|
||||
const savedDisplayName = await this._getSavedDisplayName();
|
||||
if (newDisplayName === savedDisplayName) return;
|
||||
|
||||
if (newDisplayName) {
|
||||
PersistentStorage.set('editedDisplayName', newDisplayName)
|
||||
.then(_ => {
|
||||
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', Localization.getTranslation("notifications.display-name-changed-temporarily"));
|
||||
})
|
||||
.finally(() => {
|
||||
Events.fire('self-display-name-changed', newDisplayName);
|
||||
Events.fire('broadcast-send', {type: 'self-display-name-changed', detail: newDisplayName});
|
||||
});
|
||||
}
|
||||
else {
|
||||
PersistentStorage.delete('editedDisplayName')
|
||||
.catch(_ => {
|
||||
console.log("This browser does not support IndexedDB. Use localStorage instead.")
|
||||
localStorage.removeItem('editedDisplayName');
|
||||
})
|
||||
.finally(() => {
|
||||
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: ''});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_getSavedDisplayName() {
|
||||
return new Promise((resolve) => {
|
||||
PersistentStorage.get('editedDisplayName')
|
||||
.then(displayName => {
|
||||
if (!displayName) displayName = "";
|
||||
resolve(displayName);
|
||||
})
|
||||
.catch(_ => {
|
||||
let displayName = localStorage.getItem('editedDisplayName');
|
||||
if (!displayName) displayName = "";
|
||||
resolve(displayName);
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class BackgroundCanvas {
|
||||
constructor() {
|
||||
this.c = $$('canvas');
|
||||
this.cCtx = this.c.getContext('2d');
|
||||
this.$footer = $$('footer');
|
||||
|
||||
// fade-in on load
|
||||
Events.on('fade-in-ui', _ => this._fadeIn());
|
||||
|
||||
// redraw canvas
|
||||
Events.on('resize', _ => this.init());
|
||||
Events.on('redraw-canvas', _ => this.init());
|
||||
Events.on('translation-loaded', _ => this.init());
|
||||
}
|
||||
|
||||
_fadeIn() {
|
||||
this.c.classList.remove('opacity-0');
|
||||
}
|
||||
|
||||
init() {
|
||||
let oldW = this.w;
|
||||
let oldH = this.h;
|
||||
let oldOffset = this.offset
|
||||
this.w = document.documentElement.clientWidth;
|
||||
this.h = document.documentElement.clientHeight;
|
||||
this.offset = this.$footer.offsetHeight - 27;
|
||||
if (this.h >= 800) this.offset += 10;
|
||||
|
||||
if (oldW === this.w && oldH === this.h && oldOffset === this.offset) return; // nothing has changed
|
||||
|
||||
this.c.width = this.w;
|
||||
this.c.height = this.h;
|
||||
this.x0 = this.w / 2;
|
||||
this.y0 = this.h - this.offset;
|
||||
this.dw = Math.round(Math.max(this.w, this.h, 1000) / 13);
|
||||
|
||||
this.drawCircles(this.cCtx);
|
||||
}
|
||||
|
||||
|
||||
drawCircle(ctx, radius) {
|
||||
ctx.beginPath();
|
||||
ctx.lineWidth = 2;
|
||||
let opacity = Math.max(0, 0.3 * (1 - 1.2 * radius / Math.max(this.w, this.h)));
|
||||
ctx.strokeStyle = `rgba(165, 165, 165, ${opacity})`;
|
||||
ctx.arc(this.x0, this.y0, radius, 0, 2 * Math.PI);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
drawCircles(ctx) {
|
||||
ctx.clearRect(0, 0, this.w, this.h);
|
||||
for (let i = 0; i < 13; i++) {
|
||||
this.drawCircle(ctx, this.dw * i + 33 + 66);
|
||||
}
|
||||
}
|
||||
}
|
1156
public/scripts/ui.js
|
@ -37,6 +37,31 @@ if (!navigator.clipboard) {
|
|||
}
|
||||
}
|
||||
|
||||
// Polyfills
|
||||
window.isRtcSupported = !!(window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection);
|
||||
|
||||
window.hiddenProperty = 'hidden' in document
|
||||
? 'hidden'
|
||||
: 'webkitHidden' in document
|
||||
? 'webkitHidden'
|
||||
: 'mozHidden' in document
|
||||
? 'mozHidden'
|
||||
: null;
|
||||
|
||||
window.visibilityChangeEvent = 'visibilitychange' in document
|
||||
? 'visibilitychange'
|
||||
: 'webkitvisibilitychange' in document
|
||||
? 'webkitvisibilitychange'
|
||||
: 'mozvisibilitychange' in document
|
||||
? 'mozvisibilitychange'
|
||||
: null;
|
||||
|
||||
window.iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
|
||||
window.android = /android/i.test(navigator.userAgent);
|
||||
window.isMobile = window.iOS || window.android;
|
||||
|
||||
|
||||
// Helper functions
|
||||
const zipper = (() => {
|
||||
|
||||
let zipWriter;
|
||||
|
@ -52,7 +77,8 @@ const zipper = (() => {
|
|||
const blobURL = URL.createObjectURL(await zipWriter.close());
|
||||
zipWriter = null;
|
||||
return blobURL;
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
throw new Error("Zip file closed");
|
||||
}
|
||||
},
|
||||
|
@ -61,7 +87,8 @@ const zipper = (() => {
|
|||
const file = new File([await zipWriter.close()], filename, {type: "application/zip"});
|
||||
zipWriter = null;
|
||||
return file;
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
throw new Error("Zip file closed");
|
||||
}
|
||||
},
|
||||
|
@ -411,3 +438,23 @@ function changeFavicon(src) {
|
|||
document.querySelector('[rel="icon"]').href = src;
|
||||
document.querySelector('[rel="shortcut icon"]').href = src;
|
||||
}
|
||||
|
||||
function arrayBufferToBase64(buffer) {
|
||||
let binary = '';
|
||||
let bytes = new Uint8Array(buffer);
|
||||
let len = bytes.byteLength;
|
||||
for (let i = 0; i < len; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return window.btoa( binary );
|
||||
}
|
||||
|
||||
function base64ToArrayBuffer(base64) {
|
||||
let binary_string = window.atob(base64);
|
||||
let len = binary_string.length;
|
||||
let bytes = new Uint8Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
bytes[i] = binary_string.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
|
@ -1,20 +1,24 @@
|
|||
const cacheVersion = 'v1.9.4';
|
||||
const cacheTitle = `pairdrop-cache-${cacheVersion}`;
|
||||
const forceFetch = false; // FOR DEVELOPMENT: Set to true to always update assets instead of using cached versions
|
||||
const urlsToCache = [
|
||||
const relativePathsToCache = [
|
||||
'./',
|
||||
'index.html',
|
||||
'manifest.json',
|
||||
'styles.css',
|
||||
'styles/styles-main.css',
|
||||
'styles/deferred-styles.css',
|
||||
'scripts/localization.js',
|
||||
'scripts/main.js',
|
||||
'scripts/network.js',
|
||||
'scripts/NoSleep.min.js',
|
||||
'scripts/QRCode.min.js',
|
||||
'scripts/theme.js',
|
||||
'scripts/no-sleep.min.js',
|
||||
'scripts/persistent-storage.js',
|
||||
'scripts/qr-code.min.js',
|
||||
'scripts/ui.js',
|
||||
'scripts/ui-main.js',
|
||||
'scripts/util.js',
|
||||
'scripts/zip.min.js',
|
||||
'sounds/blop.mp3',
|
||||
'sounds/blop.ogg',
|
||||
'images/favicon-96x96.png',
|
||||
'images/favicon-96x96-notification.png',
|
||||
'images/android-chrome-192x192.png',
|
||||
|
@ -32,32 +36,49 @@ const urlsToCache = [
|
|||
'lang/ja.json',
|
||||
'lang/nb.json',
|
||||
'lang/nl.json',
|
||||
'lang/tr.json',
|
||||
'lang/ro.json',
|
||||
'lang/ru.json',
|
||||
'lang/zh-CN.json'
|
||||
];
|
||||
const relativePathsNotToCache = [
|
||||
'config'
|
||||
]
|
||||
|
||||
self.addEventListener('install', function(event) {
|
||||
// Perform install steps
|
||||
event.waitUntil(
|
||||
caches.open(cacheTitle)
|
||||
.then(function(cache) {
|
||||
return cache.addAll(urlsToCache).then(_ => {
|
||||
console.log('All files cached.');
|
||||
});
|
||||
return cache
|
||||
.addAll(relativePathsToCache)
|
||||
.then(_ => {
|
||||
console.log('All files cached.');
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// fetch the resource from the network
|
||||
const fromNetwork = (request, timeout) =>
|
||||
new Promise((fulfill, reject) => {
|
||||
new Promise((resolve, reject) => {
|
||||
const timeoutId = setTimeout(reject, timeout);
|
||||
fetch(request).then(response => {
|
||||
clearTimeout(timeoutId);
|
||||
fulfill(response);
|
||||
update(request).then(() => console.log("Cache successfully updated for", request.url));
|
||||
}, reject);
|
||||
fetch(request)
|
||||
.then(response => {
|
||||
clearTimeout(timeoutId);
|
||||
resolve(response);
|
||||
|
||||
if (doNotCacheRequest(request)) return;
|
||||
|
||||
update(request)
|
||||
.then(() => console.log("Cache successfully updated for", request.url))
|
||||
.catch(reason => console.log("Cache could not be updated for", request.url, "Reason:", reason));
|
||||
})
|
||||
.catch(error => {
|
||||
// Handle any errors that occurred during the fetch
|
||||
console.error(`Could not fetch ${request.url}. Are you online?`);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
// fetch the resource from the browser cache
|
||||
|
@ -68,17 +89,32 @@ const fromCache = request =>
|
|||
cache.match(request)
|
||||
);
|
||||
|
||||
const rootUrl = location.href.substring(0, location.href.length - "service-worker.js".length);
|
||||
const rootUrlLength = rootUrl.length;
|
||||
|
||||
const doNotCacheRequest = request => {
|
||||
const requestRelativePath = request.url.substring(rootUrlLength);
|
||||
return relativePathsNotToCache.indexOf(requestRelativePath) !== -1
|
||||
};
|
||||
|
||||
// cache the current page to make it available for offline
|
||||
const update = request =>
|
||||
const update = request => new Promise((resolve, reject) => {
|
||||
if (doNotCacheRequest(request)) {
|
||||
reject("Url is specifically prevented from being cached in the serviceworker.");
|
||||
return;
|
||||
}
|
||||
caches
|
||||
.open(cacheTitle)
|
||||
.then(cache =>
|
||||
fetch(request)
|
||||
.then(async response => {
|
||||
await cache.put(request, response);
|
||||
fetch(request, {cache: "no-store"})
|
||||
.then(response => {
|
||||
cache
|
||||
.put(request, response)
|
||||
.then(() => resolve());
|
||||
})
|
||||
.catch(() => console.log(`Cache could not be updated. ${request.url}`))
|
||||
.catch(reason => reject(reason))
|
||||
);
|
||||
});
|
||||
|
||||
// general strategy when making a request (eg if online try to fetch it
|
||||
// from cache, if something fails fetch from network. Update cache everytime files are fetched.
|
||||
|
@ -90,16 +126,19 @@ self.addEventListener('fetch', function(event) {
|
|||
const share_url = await evaluateRequestData(event.request);
|
||||
return Response.redirect(encodeURI(share_url), 302);
|
||||
})());
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
// Regular requests not related to Web Share Target.
|
||||
if (forceFetch) {
|
||||
event.respondWith(fromNetwork(event.request, 10000));
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
event.respondWith(
|
||||
fromCache(event.request).then(rsp => {
|
||||
// if fromCache resolves to undefined fetch from network instead
|
||||
return rsp || fromNetwork(event.request, 10000);
|
||||
})
|
||||
fromCache(event.request)
|
||||
.then(rsp => {
|
||||
// if fromCache resolves to undefined fetch from network instead
|
||||
return rsp || fromNetwork(event.request, 10000);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -109,15 +148,16 @@ self.addEventListener('fetch', function(event) {
|
|||
// on activation, we clean up the previously registered service workers
|
||||
self.addEventListener('activate', evt => {
|
||||
return evt.waitUntil(
|
||||
caches.keys().then(cacheNames => {
|
||||
return Promise.all(
|
||||
cacheNames.map(cacheName => {
|
||||
if (cacheName !== cacheTitle) {
|
||||
return caches.delete(cacheName);
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
caches.keys()
|
||||
.then(cacheNames => {
|
||||
return Promise.all(
|
||||
cacheNames.map(cacheName => {
|
||||
if (cacheName !== cacheTitle) {
|
||||
return caches.delete(cacheName);
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
)
|
||||
}
|
||||
);
|
||||
|
@ -157,7 +197,8 @@ const evaluateRequestData = function (request) {
|
|||
DBOpenRequest.onerror = _ => {
|
||||
resolve(pairDropUrl);
|
||||
}
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
let urlArgument = '?share-target=text';
|
||||
|
||||
if (title) urlArgument += `&title=${title}`;
|
||||
|
|
1589
public/styles.css
735
public/styles/deferred-styles.css
Normal file
|
@ -0,0 +1,735 @@
|
|||
/* All styles in this sheet are not needed on page load and deferred */
|
||||
|
||||
/* Paste mode */
|
||||
#cancel-paste-mode {
|
||||
z-index: 21;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 56px;
|
||||
background-color: var(--primary-color);
|
||||
color: rgb(238, 238, 238);
|
||||
}
|
||||
|
||||
/* Text Input */
|
||||
.textarea {
|
||||
box-sizing: border-box;
|
||||
border: none;
|
||||
outline: none;
|
||||
padding: 16px 24px;
|
||||
border-radius: 12px;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
display: block;
|
||||
overflow: auto;
|
||||
resize: none;
|
||||
line-height: 16px;
|
||||
max-height: 300px;
|
||||
word-break: break-word;
|
||||
word-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Peers */
|
||||
|
||||
x-peers:has(> x-peer) {
|
||||
--peers-per-row: 10;
|
||||
}
|
||||
|
||||
@media screen and (min-height: 505px) and (max-height: 649px) and (max-width: 426px),
|
||||
screen and (min-height: 486px) and (max-height: 631px) and (min-width: 426px) {
|
||||
x-peers:has(> x-peer) {
|
||||
--peers-per-row: 3;
|
||||
}
|
||||
|
||||
x-peers:has(> x-peer:nth-of-type(7)) {
|
||||
--peers-per-row: 4;
|
||||
}
|
||||
|
||||
x-peers:has(> x-peer:nth-of-type(10)) {
|
||||
--peers-per-row: 5;
|
||||
}
|
||||
|
||||
x-peers:has(> x-peer:nth-of-type(13)) {
|
||||
--peers-per-row: 6;
|
||||
}
|
||||
|
||||
x-peers:has(> x-peer:nth-of-type(16)) {
|
||||
--peers-per-row: 7;
|
||||
}
|
||||
|
||||
x-peers:has(> x-peer:nth-of-type(19)) {
|
||||
--peers-per-row: 8;
|
||||
}
|
||||
|
||||
x-peers:has(> x-peer:nth-of-type(22)) {
|
||||
--peers-per-row: 9;
|
||||
}
|
||||
|
||||
x-peers:has(> x-peer:nth-of-type(25)) {
|
||||
--peers-per-row: 10;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-height: 649px) and (max-width: 425px),
|
||||
screen and (min-height: 631px) and (min-width: 426px) {
|
||||
x-peers:has(> x-peer) {
|
||||
--peers-per-row: 3;
|
||||
}
|
||||
|
||||
x-peers:has(> x-peer:nth-of-type(10)) {
|
||||
--peers-per-row: 4;
|
||||
}
|
||||
|
||||
x-peers:has(> x-peer:nth-of-type(13)) {
|
||||
--peers-per-row: 5;
|
||||
}
|
||||
|
||||
x-peers:has(> x-peer:nth-of-type(16)) {
|
||||
--peers-per-row: 6;
|
||||
}
|
||||
|
||||
x-peers:has(> x-peer:nth-of-type(19)) {
|
||||
--peers-per-row: 7;
|
||||
}
|
||||
|
||||
x-peers:has(> x-peer:nth-of-type(22)) {
|
||||
--peers-per-row: 8;
|
||||
}
|
||||
|
||||
x-peers:has(> x-peer:nth-of-type(25)) {
|
||||
--peers-per-row: 9;
|
||||
}
|
||||
|
||||
x-peers:has(> x-peer:nth-of-type(28)) {
|
||||
--peers-per-row: 10;
|
||||
}
|
||||
}
|
||||
|
||||
/* Peer */
|
||||
|
||||
x-peer {
|
||||
padding: 8px;
|
||||
align-content: start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
x-peer input[type="file"] {
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
x-peer label {
|
||||
width: var(--peer-width);
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
x-peer x-icon {
|
||||
--icon-size: 40px;
|
||||
margin-bottom: 4px;
|
||||
transition: transform 150ms;
|
||||
will-change: transform;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
x-peer .icon-wrapper {
|
||||
width: var(--icon-size);
|
||||
padding: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-color);
|
||||
background-image: linear-gradient(45deg, var(--accent-color) 40%, color-mix(in srgb, var(--accent-color) 70%, white) 100%);
|
||||
color: white;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
x-peer.type-secret .icon-wrapper {
|
||||
--accent-color: var(--paired-device-color);
|
||||
}
|
||||
|
||||
x-peer:not(.type-ip):not(.type-secret).type-public-id .icon-wrapper {
|
||||
--accent-color: var(--public-room-color);
|
||||
}
|
||||
|
||||
.highlight-wrapper {
|
||||
align-self: center;
|
||||
align-items: center;
|
||||
margin: 7px auto 0;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
width: 15px;
|
||||
height: 6px;
|
||||
border-radius: 4px;
|
||||
margin-left: 1px;
|
||||
margin-right: 1px;
|
||||
--highlight-color: var(--badge-color);
|
||||
background-color: var(--highlight-color);
|
||||
background-image: linear-gradient(180deg, var(--highlight-color) 0%, color-mix(in srgb, var(--highlight-color) 90%, black));
|
||||
}
|
||||
|
||||
.highlight-room-ip {
|
||||
--highlight-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.highlight-room-secret {
|
||||
--highlight-color: var(--paired-device-color);
|
||||
}
|
||||
|
||||
.highlight-room-public-id {
|
||||
--highlight-color: var(--public-room-color);
|
||||
}
|
||||
|
||||
x-peer:not(.type-ip) .highlight-room-ip {
|
||||
display: none;
|
||||
}
|
||||
|
||||
x-peer:not(.type-secret) .highlight-room-secret {
|
||||
display: none;
|
||||
}
|
||||
|
||||
x-peer:not(.type-public-id) .highlight-room-public-id {
|
||||
display: none;
|
||||
}
|
||||
|
||||
x-peer:not([status]):hover x-icon,
|
||||
x-peer:not([status]):focus x-icon {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
x-peer[status] x-icon {
|
||||
box-shadow: none;
|
||||
opacity: 0.8;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
x-peer.ws-peer {
|
||||
margin-top: -1.5px;
|
||||
}
|
||||
|
||||
x-peer.ws-peer .progress {
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
x-peer.ws-peer .icon-wrapper{
|
||||
border: solid 3px var(--ws-peer-color);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
.device-descriptor > div {
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status,
|
||||
.device-name {
|
||||
opacity: 0.7;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
x-peer:not([status]) .status,
|
||||
x-peer[status] .device-name {
|
||||
display: none;
|
||||
}
|
||||
|
||||
x-peer[status] {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
x-peer x-icon {
|
||||
animation: pop 600ms ease-out 1;
|
||||
}
|
||||
|
||||
@keyframes pop {
|
||||
0% {
|
||||
transform: scale(0.7);
|
||||
}
|
||||
|
||||
40% {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
x-peer[drop] x-icon {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
|
||||
/* Dialog */
|
||||
|
||||
x-dialog x-background {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
z-index: 30;
|
||||
transition: opacity 300ms;
|
||||
will-change: opacity;
|
||||
overflow: overlay;
|
||||
}
|
||||
|
||||
x-dialog x-paper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: calc(100vw - 10px);
|
||||
z-index: 3;
|
||||
border-radius: 30px;
|
||||
max-width: 400px;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
transition: transform 300ms;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
#pair-device-dialog x-paper,
|
||||
#edit-paired-devices-dialog x-paper,
|
||||
#public-room-dialog x-paper,
|
||||
#language-select-dialog x-paper {
|
||||
position: absolute;
|
||||
top: max(50%, 350px);
|
||||
margin-top: -328.5px;
|
||||
}
|
||||
|
||||
x-paper > .row:first-of-type {
|
||||
background-color: var(--accent-color);
|
||||
padding: 10px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
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 ::-moz-selection,
|
||||
#pair-device-dialog ::selection {
|
||||
color: black;
|
||||
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;
|
||||
}
|
||||
|
||||
x-dialog:not([show]) x-paper {
|
||||
transform: scale(0.1);
|
||||
}
|
||||
|
||||
x-dialog a {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* Pair Devices Dialog & Public Room Dialog */
|
||||
|
||||
.input-key-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.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;
|
||||
display: -ms-flexbox !important;
|
||||
display: flex !important;
|
||||
-webkit-justify-content: center;
|
||||
-ms-justify-content: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.input-key-container > input {
|
||||
margin: 0 3px;
|
||||
}
|
||||
|
||||
.input-key-container.six-chars > input:nth-of-type(4) {
|
||||
margin-left: 5%;
|
||||
}
|
||||
|
||||
.key {
|
||||
-webkit-user-select: text;
|
||||
-moz-user-select: text;
|
||||
user-select: text;
|
||||
display: inline-block;
|
||||
font-size: 45px;
|
||||
letter-spacing: min(calc((100vw - 80px - 99px) / 100 * 7), 20px);
|
||||
text-indent: calc(0.5 * (11px + min(calc((100vw - 80px - 99px) / 100 * 6), 28px)));
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.key-qr-code {
|
||||
width: fit-content;
|
||||
align-self: center;
|
||||
margin-top: 15px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.key-instructions {
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
x-dialog h2 {
|
||||
margin-top: 5px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
x-dialog hr {
|
||||
height: 1px;
|
||||
border: none;
|
||||
width: 100%;
|
||||
background-color: var(--border-color);
|
||||
}
|
||||
|
||||
.hr-note {
|
||||
margin-top: 23px;
|
||||
margin-bottom: 31px;
|
||||
}
|
||||
|
||||
.hr-note hr {
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.hr-note > div {
|
||||
height: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
|
||||
.hr-note > div > span {
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
color: rgb(var(--text-color));
|
||||
background-color: var(--dialog-bg-color);
|
||||
border: var(--border-color) solid 3px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
#pair-device-dialog x-background {
|
||||
padding: 16px!important;
|
||||
}
|
||||
|
||||
/* Edit Paired Devices Dialog */
|
||||
.paired-devices-wrapper:empty:before {
|
||||
content: attr(data-empty);
|
||||
}
|
||||
|
||||
.paired-devices-wrapper:empty {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.paired-devices-wrapper {
|
||||
margin-top: -5px;
|
||||
border-bottom: solid 4px var(--paired-device-color);
|
||||
max-height: 65vh;
|
||||
overflow: scroll;
|
||||
}
|
||||
|
||||
.paired-device {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.paired-device:not(:last-child) {
|
||||
border-bottom: solid 4px var(--paired-device-color);
|
||||
}
|
||||
|
||||
.paired-device > .display-name,
|
||||
.paired-device > .device-name {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
align-self: center;
|
||||
border-bottom: solid 2px rgba(128, 128, 128, 0.5);
|
||||
opacity: 1;
|
||||
}
|
||||
.paired-device span {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.paired-device > .button-wrapper {
|
||||
display: flex;
|
||||
height: 36px;
|
||||
justify-content: space-between;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.paired-device > .button-wrapper > label,
|
||||
.paired-device > .button-wrapper > button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
justify-content: center;
|
||||
width: 50%;
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.paired-device > .button-wrapper > :not(:last-child) {
|
||||
border-right: solid 1px rgba(128, 128, 128, 0.5);
|
||||
}
|
||||
|
||||
.paired-device > .button-wrapper > :not(:first-child) {
|
||||
border-left: solid 1px rgba(128, 128, 128, 0.5);
|
||||
}
|
||||
|
||||
.paired-device * {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* button row*/
|
||||
x-paper > .button-row {
|
||||
height: 50px;
|
||||
margin: 5px 10px 10px;
|
||||
}
|
||||
|
||||
x-paper > .button-row > .btn {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
html:not([dir="rtl"]) x-paper > .button-row > .btn:not(:first-child) {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
html:not([dir="rtl"]) x-paper > .button-row > .btn:not(:last-child) {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
html[dir="rtl"] x-paper > .button-row > .btn:not(:first-child) {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
html[dir="rtl"] x-paper > .button-row > .btn:not(:last-child) {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.language-buttons > button > span {
|
||||
margin: 0 0.3em;
|
||||
}
|
||||
|
||||
.language-buttons > button {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.file-description {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.file-description span {
|
||||
display: inline;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
.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 */
|
||||
x-dialog .dialog-subheader {
|
||||
padding-top: 16px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.display-name-wrapper {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
#send-text-dialog,
|
||||
#receive-text-dialog {
|
||||
font-size: 16px; /* prevents auto-zoom on edit */
|
||||
--shadow-color-rgb: var(--shadow-color-secondary-rgb);
|
||||
--shadow-color-cover-rgb: var(--shadow-color-secondary-cover-rgb);
|
||||
}
|
||||
|
||||
#edit-paired-devices-dialog {
|
||||
--shadow-color-rgb: var(--shadow-color-dialog-rgb);
|
||||
--shadow-color-cover-rgb: var(--shadow-color-dialog-cover-rgb);
|
||||
}
|
||||
|
||||
#text-input:before {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Receive Text Dialog */
|
||||
|
||||
#receive-text-dialog #text {
|
||||
word-break: break-all;
|
||||
max-height: 400px;
|
||||
padding: 10px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: scroll;
|
||||
-webkit-user-select: text;
|
||||
-moz-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
#receive-text-dialog #text a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
#receive-text-dialog h3 {
|
||||
/* Select the received text when double-clicking the dialog */
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#base64-paste-btn,
|
||||
#base64-paste-dialog .textarea {
|
||||
width: 100%;
|
||||
height: 40vh;
|
||||
border: solid 12px #438cff;
|
||||
}
|
||||
|
||||
#base64-paste-dialog .textarea {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#base64-paste-dialog .textarea::before {
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--primary-color);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* Peer loading Indicator */
|
||||
|
||||
.progress {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
clip: rect(0px, 80px, 80px, 40px);
|
||||
--progress: rotate(0deg);
|
||||
transition: transform 200ms;
|
||||
}
|
||||
|
||||
.circle {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border: 4px solid var(--primary-color);
|
||||
border-radius: 40px;
|
||||
position: absolute;
|
||||
clip: rect(0px, 40px, 80px, 0px);
|
||||
will-change: transform;
|
||||
transform: var(--progress);
|
||||
}
|
||||
|
||||
.over50 {
|
||||
clip: rect(auto, auto, auto, auto);
|
||||
}
|
||||
|
||||
.over50 .circle.right {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/*
|
||||
Color Themes
|
||||
*/
|
||||
|
||||
/* Colored Elements */
|
||||
|
||||
x-dialog x-paper {
|
||||
background-color: var(--dialog-bg-color);
|
||||
}
|
||||
|
||||
.textarea {
|
||||
color: rgb(var(--text-color)) !important;
|
||||
background-color: var(--bg-color-secondary) !important;
|
||||
}
|
||||
|
||||
.textarea * {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
color: unset !important;
|
||||
background: unset !important;
|
||||
border: unset !important;
|
||||
opacity: unset !important;
|
||||
font-family: inherit !important;
|
||||
font-size: inherit !important;
|
||||
font-style: unset !important;
|
||||
font-weight: unset !important;
|
||||
}
|
||||
|
||||
/* Image/Video/Audio Preview */
|
||||
.file-preview {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.file-preview:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-preview > img,
|
||||
.file-preview > audio,
|
||||
.file-preview > video {
|
||||
max-width: 100%;
|
||||
max-height: 40vh;
|
||||
margin: auto;
|
||||
display: block;
|
||||
}
|
970
public/styles/styles-main.css
Normal file
|
@ -0,0 +1,970 @@
|
|||
/* All styles in this sheet are needed on page load */
|
||||
|
||||
/* Layout */
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100vw;
|
||||
overflow-x: hidden;
|
||||
overscroll-behavior: none;
|
||||
overflow-y: hidden;
|
||||
/* Only allow selection on message and pair key */
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
user-select: none;
|
||||
transition: color 300ms;
|
||||
}
|
||||
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
html {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.fw {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.p1 {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.row-reverse {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.space-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.grow {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.full {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
header {
|
||||
position: absolute;
|
||||
align-items: baseline;
|
||||
padding: 8px 12px;
|
||||
box-sizing: border-box;
|
||||
width: 100vw;
|
||||
z-index: 20;
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
header > * {
|
||||
margin-left: 4px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
header > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-self: flex-start;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
header > div .icon-button {
|
||||
height: 40px;
|
||||
transition: all 300ms;
|
||||
}
|
||||
|
||||
header > div > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
header > div:not(:hover) .icon-button:not(.selected) {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
#theme-wrapper:hover::before {
|
||||
border-radius: 20px;
|
||||
background: currentColor;
|
||||
opacity: 0.1;
|
||||
transition: opacity 300ms;
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 40px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
header > div:hover .icon-button.selected::before {
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
header > div:hover .icon-button.selected:hover::before {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
header > div .icon-button:not(.selected) {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
header > div > div {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
|
||||
/* Typography */
|
||||
|
||||
@font-face {
|
||||
font-family: "Open Sans";
|
||||
src: url('../fonts/OpenSans/static/OpenSans-Medium.ttf') format('truetype');
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Open Sans", -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
font-variant-ligatures: common-ligatures;
|
||||
font-kerning: normal;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 34px;
|
||||
font-weight: 400;
|
||||
letter-spacing: -.01em;
|
||||
line-height: 40px;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 400;
|
||||
letter-spacing: -.012em;
|
||||
line-height: 32px;
|
||||
color: var(--primary-color);}
|
||||
|
||||
h3 {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
margin: 16px 0;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.font-subheading {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.font-body1,
|
||||
body {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.font-body2 {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
a,
|
||||
.icon-button {
|
||||
text-decoration: none;
|
||||
color: currentColor;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
min-width: 13px;
|
||||
}
|
||||
|
||||
x-noscript {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
|
||||
/* Icons */
|
||||
|
||||
.icon {
|
||||
width: var(--icon-size);
|
||||
height: var(--icon-size);
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Shadows */
|
||||
|
||||
[shadow="1"] {
|
||||
box-shadow: 0 3px 4px 0 rgba(0, 0, 0, 0.14),
|
||||
0 1px 8px 0 rgba(0, 0, 0, 0.12),
|
||||
0 3px 3px -2px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
[shadow="2"] {
|
||||
box-shadow: 0 4px 5px 0 rgba(0, 0, 0, 0.14),
|
||||
0 1px 10px 0 rgba(0, 0, 0, 0.12),
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.overflowing {
|
||||
background:
|
||||
/* Shadow covers */
|
||||
linear-gradient(rgb(var(--shadow-color-cover-rgb)) 30%, rgba(var(--shadow-color-cover-rgb), 0)),
|
||||
linear-gradient(rgba(var(--shadow-color-cover-rgb), 0), rgb(var(--shadow-color-cover-rgb)) 70%) 0 100%,
|
||||
/* Shadows */
|
||||
radial-gradient(farthest-side at 50% 0, rgba(var(--shadow-color-rgb), .2), rgba(var(--shadow-color-rgb), 0)),
|
||||
radial-gradient(farthest-side at 50% 100%, rgba(var(--shadow-color-rgb), .2), rgba(var(--shadow-color-rgb), 0))
|
||||
0 100%;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100% 60px, 100% 60px, 100% 24px, 100% 24px;
|
||||
background-attachment: local, local, scroll, scroll;
|
||||
}
|
||||
|
||||
|
||||
/* Animations */
|
||||
|
||||
@keyframes fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
#center {
|
||||
position: relative;
|
||||
display: flex;
|
||||
margin-top: 56px;
|
||||
flex-direction: column-reverse;
|
||||
flex-grow: 1;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
overflow-x: hidden;
|
||||
overflow-y: scroll;
|
||||
overscroll-behavior-x: none;
|
||||
}
|
||||
|
||||
|
||||
/* Peers */
|
||||
|
||||
#x-peers-filler {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
x-peers {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
flex-grow: 1;
|
||||
align-items: start !important;
|
||||
justify-content: center;
|
||||
|
||||
z-index: 2;
|
||||
transition: background-color 0.5s ease;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
overscroll-behavior-x: none;
|
||||
scrollbar-width: none;
|
||||
|
||||
--peers-per-row: 6; /* default if browser does not support :has selector */
|
||||
--x-peers-width: min(100vw, calc(var(--peers-per-row) * (var(--peer-width) + 25px) - 16px));
|
||||
width: var(--x-peers-width);
|
||||
margin-right: 20px;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
/* Empty Peers List */
|
||||
|
||||
x-no-peers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px;
|
||||
height: 137px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
x-no-peers h2,
|
||||
x-no-peers a {
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
x-peers:not(:empty)+x-no-peers {
|
||||
display: none;
|
||||
}
|
||||
|
||||
x-no-peers::before {
|
||||
color: var(--primary-color);
|
||||
font-size: 24px;
|
||||
font-weight: 400;
|
||||
letter-spacing: -.012em;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
x-no-peers[drop-bg]::before {
|
||||
content: attr(data-drop-bg);
|
||||
}
|
||||
|
||||
x-no-peers[drop-bg] * {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
|
||||
footer {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
cursor: default;
|
||||
margin: auto 5px 5px;
|
||||
}
|
||||
|
||||
footer .logo {
|
||||
--icon-size: 80px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--primary-color);
|
||||
margin-top: -10px;
|
||||
}
|
||||
|
||||
.discovery-wrapper {
|
||||
font-size: 14px;
|
||||
margin: 15px auto auto;
|
||||
border: 2px solid var(--border-color);
|
||||
padding: 2px;
|
||||
background-color: rgb(var(--bg-color));
|
||||
transition: background-color 0.5s ease;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.discovery-wrapper.column {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.discovery-wrapper.row {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/*You can be discovered wrapper*/
|
||||
.discovery-wrapper > div:first-of-type {
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
|
||||
.discovery-wrapper .badge {
|
||||
word-break: keep-all;
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
border-radius: 0.4rem;
|
||||
padding-right: 0.3rem;
|
||||
padding-left: 0.3em;
|
||||
background-color: var(--badge-color);
|
||||
color: white;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-gradient {
|
||||
background-image: linear-gradient(180deg, color-mix(in srgb, var(--badge-color) 80%, white) 0%, var(--badge-color) 50%);
|
||||
}
|
||||
|
||||
.badge-room-ip {
|
||||
--badge-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.badge-room-secret {
|
||||
--badge-color: var(--paired-device-color);
|
||||
}
|
||||
|
||||
.badge-room-public-id {
|
||||
--badge-color: var(--public-room-color);
|
||||
}
|
||||
|
||||
.known-as-wrapper {
|
||||
font-size: 16px; /* prevents auto-zoom on edit */
|
||||
}
|
||||
|
||||
#display-name {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
text-align: left;
|
||||
border: none;
|
||||
outline: none;
|
||||
max-width: 15em;
|
||||
text-overflow: ellipsis;
|
||||
cursor: text;
|
||||
margin-bottom: -6px;
|
||||
padding-bottom: 0.1rem;
|
||||
border-radius: 1.3rem/30%;
|
||||
border-right: solid 1rem transparent;
|
||||
border-left: solid 1rem transparent;
|
||||
background-clip: padding-box;
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#edit-pen {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin-bottom: -2px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
html:not([dir="rtl"]) #display-name,
|
||||
html:not([dir="rtl"]) #edit-pen {
|
||||
margin-left: -1rem;
|
||||
}
|
||||
|
||||
html[dir="rtl"] #display-name,
|
||||
html[dir="rtl"] #edit-pen {
|
||||
margin-right: -1rem;
|
||||
}
|
||||
|
||||
html[dir="rtl"] #edit-pen {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
/* Dialogs needed on page load */
|
||||
x-dialog:not([show]) x-background {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Button */
|
||||
|
||||
.btn {
|
||||
font-family: "Open Sans", -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
padding: 2px 16px 0;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
background: inherit;
|
||||
color: var(--accent-color);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn[disabled] {
|
||||
color: var(--btn-disabled-color);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
|
||||
.btn,
|
||||
.icon-button {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
touch-action: manipulation;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.btn:before,
|
||||
.icon-button:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
opacity: 0;
|
||||
background-color: var(--accent-color);
|
||||
transition: opacity 300ms;
|
||||
}
|
||||
|
||||
.btn:not([disabled]):hover:before,
|
||||
.icon-button:hover:before {
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
.btn[selected],
|
||||
.icon-button[selected] {
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
.btn:focus:before,
|
||||
.icon-button:focus:before {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.btn-rounded {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.btn-grey {
|
||||
background-color: var(--bg-color-secondary);
|
||||
}
|
||||
|
||||
button::-moz-focus-inner {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
|
||||
/* Icon Button */
|
||||
.icon-button {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.icon-button:before {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
|
||||
/* Info Animation */
|
||||
|
||||
#about {
|
||||
color: white;
|
||||
z-index: 32;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#about header {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#about:not(:target) header {
|
||||
transition-delay: 400ms;
|
||||
}
|
||||
|
||||
#about:target header {
|
||||
transition-delay: 100ms;
|
||||
}
|
||||
|
||||
#about > * {
|
||||
transition: opacity 300ms ease 300ms;
|
||||
will-change: opacity;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
#about:not(:target) > header,
|
||||
#about:not(:target) > section {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition-delay: 0s;
|
||||
}
|
||||
|
||||
#about .logo {
|
||||
--icon-size: 96px;
|
||||
}
|
||||
|
||||
#about .title-wrapper {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
#about .title-wrapper > div {
|
||||
margin-left: 0.4em;
|
||||
}
|
||||
|
||||
#about x-background {
|
||||
position: absolute;
|
||||
--size: max(max(230vw, 230vh), calc(150vh + 150vw));
|
||||
--size-half: calc(var(--size)/2);
|
||||
top: calc(28px - var(--size-half));
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
z-index: -1;
|
||||
background: var(--primary-color);
|
||||
background-image: radial-gradient(circle at calc(50% - 36px), var(--accent-color) 0%, color-mix(in srgb, var(--accent-color) 40%, black) 80%);
|
||||
--crop-size: 0px;
|
||||
clip-path: circle(var(--crop-size));
|
||||
}
|
||||
|
||||
html:not([dir="rtl"]) #about x-background {
|
||||
right: calc(36px - var(--size-half));
|
||||
}
|
||||
|
||||
html[dir="rtl"] #about x-background {
|
||||
left: calc(36px - var(--size-half));
|
||||
}
|
||||
|
||||
|
||||
/* Hack such that initial scale(0) isn't animated */
|
||||
#about x-background {
|
||||
will-change: clip-path;
|
||||
transition: clip-path 800ms cubic-bezier(0.77, 0, 0.175, 1);
|
||||
}
|
||||
|
||||
#about:target x-background {
|
||||
--crop-size: var(--size);
|
||||
}
|
||||
|
||||
#about .row a {
|
||||
margin: 8px 8px -16px;
|
||||
}
|
||||
|
||||
#about section {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
canvas.circles {
|
||||
width: 100vw;
|
||||
position: absolute;
|
||||
z-index: -10;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
/* Generic placeholder */
|
||||
[placeholder]:empty:before {
|
||||
content: attr(placeholder);
|
||||
}
|
||||
|
||||
/* Toast */
|
||||
|
||||
.toast-container {
|
||||
padding: 0 8px 24px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
x-toast {
|
||||
position: absolute;
|
||||
min-height: 48px;
|
||||
top: 50px;
|
||||
width: 100%;
|
||||
max-width: 344px;
|
||||
background-color: rgb(var(--text-color));
|
||||
color: var(--dialog-bg-color);
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 24px;
|
||||
z-index: 40;
|
||||
transition: opacity 200ms, transform 300ms ease-out;
|
||||
cursor: default;
|
||||
line-height: 24px;
|
||||
border-radius: 12px;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
x-toast:not([show]):not(:hover) {
|
||||
opacity: 0;
|
||||
transform: translateY(-100px);
|
||||
}
|
||||
|
||||
/* Instructions */
|
||||
|
||||
x-instructions {
|
||||
position: relative;
|
||||
opacity: 0.5;
|
||||
text-align: center;
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
x-instructions:not([drop-peer]):not([drop-bg]):before {
|
||||
content: attr(mobile);
|
||||
}
|
||||
|
||||
x-instructions[drop-peer]:before {
|
||||
content: attr(data-drop-peer);
|
||||
}
|
||||
|
||||
x-instructions[drop-bg]:not([drop-peer]):before {
|
||||
content: attr(data-drop-bg);
|
||||
}
|
||||
|
||||
x-instructions p {
|
||||
display: none;
|
||||
}
|
||||
|
||||
x-peers:empty~x-instructions {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
x-peer {
|
||||
transform: scale(0.95);
|
||||
padding: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Prevent Cumulative Layout Shift */
|
||||
|
||||
.fade-in {
|
||||
animation: fade-in 600ms;
|
||||
animation-fill-mode: backwards;
|
||||
}
|
||||
|
||||
.no-animation-on-load {
|
||||
animation-iteration-count: 0;
|
||||
}
|
||||
|
||||
.opacity-0 {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Responsive Styles */
|
||||
|
||||
@media screen and (min-height: 800px) {
|
||||
footer {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
x-instructions:not([drop-peer]):not([drop-bg]):before {
|
||||
content: attr(desktop);
|
||||
}
|
||||
}
|
||||
|
||||
/* Constants */
|
||||
|
||||
:root {
|
||||
--icon-size: 24px;
|
||||
--peer-width: 120px;
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
/*
|
||||
Color Themes
|
||||
*/
|
||||
|
||||
/* Default colors */
|
||||
body {
|
||||
/* Constant colors */
|
||||
--primary-color: #4285f4;
|
||||
--paired-device-color: #00a69c;
|
||||
--public-room-color: #db8500;
|
||||
--accent-color: var(--primary-color);
|
||||
--ws-peer-color: #ff6b6b;
|
||||
--btn-disabled-color: #5B5B66;
|
||||
/* shadows */
|
||||
--shadow-color-rgb: var(--text-color);
|
||||
--shadow-color-cover-rgb: var(--bg-color);
|
||||
}
|
||||
|
||||
/* Light theme colors */
|
||||
body {
|
||||
--text-color: 51,51,51;
|
||||
--dialog-bg-color: #fff;
|
||||
--bg-color: 255,255,255;
|
||||
--bg-color-secondary: #f2f2f2;
|
||||
--border-color: rgb(169, 169, 169);
|
||||
--badge-color: #a5a5a5;
|
||||
|
||||
--shadow-color-secondary-rgb: 0,0,0;
|
||||
--shadow-color-secondary-cover-rgb: 242,242,242;
|
||||
--shadow-color-dialog-rgb: 0,0,0;
|
||||
--shadow-color-dialog-cover-rgb: 242,242,242;
|
||||
}
|
||||
|
||||
/* Dark theme colors */
|
||||
body.dark-theme {
|
||||
--text-color: 238,238,238;
|
||||
--dialog-bg-color: #121212;
|
||||
--bg-color: 0,0,0;
|
||||
--bg-color-secondary: #262628;
|
||||
--border-color: rgb(91, 91, 91);
|
||||
--badge-color: #717171;
|
||||
|
||||
--shadow-color-secondary-rgb: 255,255,255;
|
||||
--shadow-color-secondary-cover-rgb: 38,38,38;
|
||||
--shadow-color-dialog-rgb: 255,255,255;
|
||||
--shadow-color-dialog-cover-rgb: 38,38,38;
|
||||
}
|
||||
|
||||
/* Styles for users who prefer dark mode at the OS level */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
|
||||
/* defaults to dark theme */
|
||||
body {
|
||||
--text-color: 238,238,238;
|
||||
--dialog-bg-color: #121212;
|
||||
--bg-color-secondary: #262628;
|
||||
--bg-color: 0,0,0;
|
||||
--border-color: rgb(91, 91, 91);
|
||||
--badge-color: #717171;
|
||||
|
||||
--shadow-color-secondary-rgb: 255,255,255;
|
||||
--shadow-color-secondary-cover-rgb: 38,38,38;
|
||||
--shadow-color-dialog-rgb: 255,255,255;
|
||||
--shadow-color-dialog-cover-rgb: 38,38,38;
|
||||
}
|
||||
|
||||
|
||||
/* Override dark mode with light mode styles if the user decides to swap */
|
||||
body.light-theme {
|
||||
--text-color: 51,51,51;
|
||||
--dialog-bg-color: #fff;
|
||||
--bg-color: 255,255,255;
|
||||
--bg-color-secondary: #f2f2f2;
|
||||
--border-color: rgb(169, 169, 169);
|
||||
--badge-color: #a5a5a5;
|
||||
|
||||
--shadow-color-secondary-rgb: 0,0,0;
|
||||
--shadow-color-secondary-cover-rgb: 242,242,242;
|
||||
--shadow-color-dialog-rgb: 0,0,0;
|
||||
--shadow-color-dialog-cover-rgb: 242,242,242;
|
||||
}
|
||||
}
|
||||
|
||||
/* Colored Elements */
|
||||
body {
|
||||
color: rgb(var(--text-color));
|
||||
background-color: rgb(var(--bg-color));
|
||||
transition: background-color 0.5s ease;
|
||||
}
|
||||
|
||||
x-dialog x-paper {
|
||||
background-color: var(--dialog-bg-color);
|
||||
}
|
||||
|
||||
.textarea {
|
||||
color: rgb(var(--text-color)) !important;
|
||||
background-color: var(--bg-color-secondary) !important;
|
||||
}
|
||||
|
||||
.textarea * {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
color: unset !important;
|
||||
background: unset !important;
|
||||
border: unset !important;
|
||||
opacity: unset !important;
|
||||
font-family: inherit !important;
|
||||
font-size: inherit !important;
|
||||
font-style: unset !important;
|
||||
font-weight: unset !important;
|
||||
}
|
||||
|
||||
/* Gradient for wifi-tether icon */
|
||||
#primaryGradient .start-color {
|
||||
stop-color: var(--primary-color);
|
||||
}
|
||||
|
||||
@supports (stop-color: color-mix(in srgb, blue 50%, black)) {
|
||||
#primaryGradient .start-color {
|
||||
stop-color: color-mix(in srgb, var(--primary-color) 80%, white);
|
||||
}
|
||||
}
|
||||
|
||||
#primaryGradient .stop-color {
|
||||
stop-color: var(--primary-color);
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
Edge specific styles
|
||||
*/
|
||||
@supports (-ms-ime-align: auto) {
|
||||
|
||||
html,
|
||||
body {
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Browser specific styles
|
||||
*/
|
||||
|
||||
body {
|
||||
/* mobile viewport bug fix */
|
||||
min-height: -moz-available; /* WebKit-based browsers will ignore this. */
|
||||
min-height: -webkit-fill-available; /* Mozilla-based browsers will ignore this. */
|
||||
min-height: fill-available;
|
||||
}
|
||||
|
||||
html {
|
||||
min-height: -moz-available; /* WebKit-based browsers will ignore this. */
|
||||
min-height: -webkit-fill-available; /* Mozilla-based browsers will ignore this. */
|
||||
min-height: fill-available;
|
||||
}
|
||||
|
||||
/* webkit scrollbar style*/
|
||||
|
||||
::-webkit-scrollbar{
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb{
|
||||
background: #bfbfbf;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-moz-selection,
|
||||
::selection {
|
||||
color: black;
|
||||
background: var(--primary-color);
|
||||
}
|
||||
|
||||
/* make elements with attribute contenteditable editable on older iOS devices.
|
||||
See note here: https://developer.mozilla.org/en-US/docs/Web/CSS/user-select */
|
||||
[contenteditable] {
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|