feat(groups): audio and video calls in group chats; release v6.6.6
A group call is N-1 ordinary 1:1 calls, one to each other member, each riding the pairwise session that member already has — a transport a human already authenticated by comparing the safety code. No mixer, no SFU, no point at which two people's media meets anywhere but on a device. Call control is separate from call media, because the two reach different sets of people. Who opened a call, who joined and who left travels as group frames signed with the sender's group identity key, so it reaches members currently reachable only through a relay — and a relaying member can drop one but cannot write one. Media flows only where a direct link exists, so a member without one shows as connecting rather than being omitted. Frames carry a per-sender sequence checked before the action, so a captured leave cannot end a later call, and simultaneous calls converge on the lower random call id. One capture is shared across every leg rather than one getUserMedia per member, and legs answer without prompting: the flag permitting that is set only locally, only while this user is in the call, and cleared when they leave. UI: a gallery that sizes itself from the space it has, a spotlight view, an active-speaker indicator read from the waveform, and the call surface in the same visual language as the 1:1 one. Also in this commit, the v6.5.0 language-suggestion work that had not been pushed yet; its notes are in the changelog. And two fixes: the safety-code input asks for digits rather than text, and starting a new chat from inside a group no longer creates it behind the group where it cannot be seen — which had made it impossible to connect to anyone new, or to add anyone to a group, while a group was open. Claude-Session: https://claude.ai/code/session_01XSxAkET3hQTkYDQfbjCQwZ
This commit is contained in:
@@ -1,5 +1,68 @@
|
||||
# Changelog
|
||||
|
||||
## v6.6.6 — Group calls
|
||||
|
||||
Groups can call now. Press the handset in a group's header for voice, the camera
|
||||
for video, and everyone in the group gets a ring. Whoever joins appears as a tile
|
||||
of their own; tap somebody to give them the whole screen and drop the rest into a
|
||||
strip along the bottom. Mute, camera, front/back, and a green ring around whoever
|
||||
is talking.
|
||||
|
||||
**There is no conference server, and there was never going to be one.** The usual
|
||||
way to do this is to send everyone's audio to a machine in the middle that mixes
|
||||
it and sends one stream back. It is cheaper, it scales further, and it means a
|
||||
computer you do not own is holding the room. A group call here is instead a
|
||||
separate encrypted call to each other member, over the connection you already
|
||||
built with them and already verified in person by reading the safety code aloud.
|
||||
Four people means three calls out of your browser. That is the honest cost of the
|
||||
room only existing on the devices that are in it.
|
||||
|
||||
**Nobody's phone rings for a call they were not invited to.** Who opened a call
|
||||
and who is in it travels as a message signed with the sender's group key — the
|
||||
same signature the group's text messages carry. Members who cannot reach each
|
||||
other directly pass those messages along for one another, which is how a group
|
||||
works here, and a member passing one along can drop it but cannot write it. So
|
||||
nobody can add you to a call you never joined, take you out of one you are in, or
|
||||
hang up on the group.
|
||||
|
||||
**Your microphone opens once, for the whole call, and only after you agree.** One
|
||||
capture is shared across every connection rather than one per person, which is
|
||||
also what stops the browser asking a group of six for permission six times. It
|
||||
stops the moment you leave — including if you close the group, or the tab.
|
||||
|
||||
**A member with no direct link is in the call, not missing from it.** Groups start
|
||||
as a star, with only the person who created it connected to everyone, and build
|
||||
the remaining connections quietly afterwards. Someone still being connected shows
|
||||
as connecting rather than being left out of the list, and joins when their link
|
||||
comes up.
|
||||
|
||||
**Also in this release:** the safety code now asks for a number instead of text,
|
||||
so phones open a keypad rather than a full keyboard for an answer that is always
|
||||
seven digits. And starting a new chat from inside a group works — it used to
|
||||
create the chat behind the group and leave it invisible, which meant that while
|
||||
you had a group open you could not connect to anybody new, or add anybody to the
|
||||
group.
|
||||
|
||||
## v6.5.0 — It asks, in your language
|
||||
|
||||
If your browser reads Arabic and you land on the English page, a small bar now
|
||||
appears in the corner: "هذه الصفحة متوفرة أيضًا بالعربية" - with a link. One
|
||||
click and you are on `/ar/`. Dismiss it and it never comes back.
|
||||
|
||||
**What it deliberately does not do is move you.** The obvious version of this
|
||||
feature reads the browser's language on load and redirects. It looks right from
|
||||
your own machine and quietly ruins the site everywhere else. Search engines
|
||||
crawl from one place with one language set, so a redirect answers every one of
|
||||
the thirteen locale addresses with the same page - and the twelve translations
|
||||
stop existing as far as the index is concerned, however carefully they are
|
||||
linked. Shared links break the same way: a link posted in a German group chat
|
||||
has to open in German for everyone who follows it, including the person whose
|
||||
laptop is set to English.
|
||||
|
||||
So the address still decides the language, always. The bar is an offer sitting
|
||||
next to it: written in the language it is offering, laid out in that language's
|
||||
direction, and gone for good once you say no.
|
||||
|
||||
## v6.4.0 — Now it reads right to left
|
||||
|
||||
Arabic, Hebrew, Persian and Urdu, at `/ar/`, `/he/`, `/fa/` and `/ur/`.
|
||||
|
||||
@@ -6,6 +6,11 @@ FROM nginx:1.27-alpine
|
||||
# Replace the default nginx config with our static-serving config.
|
||||
COPY deploy/nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
# The www.securebit.chat kill-switch worker. It lives outside the web root because
|
||||
# it is deployment plumbing, not site content: the apex must never serve it as
|
||||
# /sw.js, which is the real, caching worker.
|
||||
COPY deploy/www-sw.js /etc/nginx/www-sw.js
|
||||
|
||||
# Serve the repository (src/, assets/, libs/, dist/, config/, logo/, sw.js, ...).
|
||||
COPY . /usr/share/nginx/html
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
No accounts. No servers storing your messages. No installation required.
|
||||
|
||||
[](LICENSE)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](#install-as-an-app)
|
||||
[](#security-model)
|
||||
[](#forward-secrecy)
|
||||
@@ -50,6 +50,8 @@ It is designed for people who need a small, auditable, zero-infrastructure way t
|
||||
- **Adaptive audio**: Opus with in-band FEC, DTX and RED redundancy for intelligible speech at 15 to 20 percent packet loss; audio is prioritised and never throttled by the network controller.
|
||||
- **Adaptive video**: VP9/AV1 single-encoding SVC (H.264/VP8 fallback) that degrades by spatial/temporal layer, with a runtime controller that trims video bitrate on loss/RTT and recovers as the link clears.
|
||||
- **Live connection-quality indicator** (Excellent, Good, Fair, Weak) shown during a call, plus in-call mute and video controls.
|
||||
- **Group voice and video calls**, built as one encrypted call per member rather than a conference server: media flows directly between each pair over the link they already verified, so no machine in the middle ever holds the room. Who opened a call and who is in it is signed with each member's group key, so a member relaying that message for two people who cannot reach each other can drop it but never forge it.
|
||||
- **One microphone capture for the whole group call**, shared across every leg, released the moment you leave — you are asked for the microphone once, not once per participant.
|
||||
|
||||
**Messaging**
|
||||
- **Encrypted voice messages.** Record in the browser and send over the same end-to-end encrypted transfer channel as files. Audio is captured as PCM/WAV, integrity-protected by a signed hash, and played back inline on the recipient's device without ever touching disk.
|
||||
|
||||
+24
-24
@@ -30,7 +30,7 @@
|
||||
|
||||
<!-- PWA Manifest -->
|
||||
<link rel="manifest" href="/ar/manifest.json">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788040116230">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788237895492">
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
@@ -96,7 +96,7 @@
|
||||
<link rel="apple-touch-startup-image" media="screen and (device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/logo/splash/splash_screens/8.3__iPad_Mini_portrait.png">
|
||||
|
||||
<!-- Apple Touch Icons -->
|
||||
<link rel="apple-touch-icon" href="/logo/icon-180x180.png?v=1788040116230">
|
||||
<link rel="apple-touch-icon" href="/logo/icon-180x180.png?v=1788237895492">
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="/logo/icon-57x57.png">
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="/logo/icon-60x60.png">
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="/logo/icon-72x72.png">
|
||||
@@ -105,7 +105,7 @@
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="/logo/icon-120x120.png">
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="/logo/icon-144x144.png">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="/logo/icon-152x152.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/logo/icon-180x180.png?v=1788040116230">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/logo/icon-180x180.png?v=1788237895492">
|
||||
|
||||
<!-- Microsoft Tiles -->
|
||||
<meta name="msapplication-TileColor" content="#ff6b35">
|
||||
@@ -222,7 +222,7 @@
|
||||
<!-- Render-blocking JS is deferred: classic deferred scripts and module scripts
|
||||
both execute in document order after parsing, so React still runs before the
|
||||
app modules below, but the parser / first paint is no longer blocked. -->
|
||||
<script defer src="/config/ice-servers.js?v=1788040116230"></script>
|
||||
<script defer src="/config/ice-servers.js?v=1788237895492"></script>
|
||||
<script defer src="/libs/react/react.production.min.js"></script>
|
||||
<script defer src="/libs/react-dom/react-dom.production.min.js"></script>
|
||||
<!-- Prism syntax highlighting (vendored, offline). Tokenizes code as TEXT only —
|
||||
@@ -230,8 +230,8 @@
|
||||
Its CSS is loaded async via load-async-css.js (not paint-critical). -->
|
||||
<script defer src="/libs/prism/prism.js"></script>
|
||||
<!-- Critical, paint-defining CSS stays render-blocking (avoids FOUC / layout shift). -->
|
||||
<link rel="stylesheet" href="/assets/tailwind.css?v=1788040116230">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788040116230">
|
||||
<link rel="stylesheet" href="/assets/tailwind.css?v=1788237895492">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788237895492">
|
||||
<!-- Preload only the fonts needed for first paint. fa-solid covers the bulk of UI
|
||||
icons; fa-regular/fa-brands are loaded on demand by their CSS (rarely on the
|
||||
first screen). Inter latin 400/700 cover body text and headings/buttons. -->
|
||||
@@ -239,38 +239,38 @@
|
||||
<link rel="preload" href="/assets/fonts/inter/files/inter-latin-400.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="/assets/fonts/inter/files/inter-latin-700.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="stylesheet" href="/assets/fonts/inter/inter.css">
|
||||
<link rel="stylesheet" href="/src/styles/main.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/animations.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/components.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/main.css?v=1788237895492">
|
||||
<link rel="stylesheet" href="/src/styles/animations.css?v=1788237895492">
|
||||
<link rel="stylesheet" href="/src/styles/components.css?v=1788237895492">
|
||||
<!-- Loads after components.css on purpose: press feedback, material weight,
|
||||
size-specific tracking and the reduced-motion / -transparency / -contrast
|
||||
answers all need to settle arguments with the sheets above on source order. -->
|
||||
<link rel="stylesheet" href="/src/styles/apple-motion.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/apple-motion.css?v=1788237895492">
|
||||
<!-- Last of the hand-written sheets: the mirroring rules must win over anything
|
||||
above them, and they only ever apply under [dir="rtl"]. -->
|
||||
<link rel="stylesheet" href="/src/styles/rtl.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/rtl.css?v=1788237895492">
|
||||
<!-- Non-critical CSS (FontAwesome ~102KB, Prism) loaded async — no longer blocks paint. -->
|
||||
<script defer src="/src/scripts/load-async-css.js?v=1788040116230"></script>
|
||||
<script defer src="/src/scripts/load-async-css.js?v=1788237895492"></script>
|
||||
<noscript>
|
||||
<link rel="stylesheet" href="/assets/fontawesome/css/all.min.css">
|
||||
<link rel="stylesheet" href="/libs/prism/prism.css">
|
||||
</noscript>
|
||||
<script defer src="/src/scripts/fa-check.js?v=1788040116230"></script>
|
||||
<script defer src="/src/scripts/fa-check.js?v=1788237895492"></script>
|
||||
<!-- Update Manager - система принудительного обновления -->
|
||||
<script defer src="/src/utils/updateManager.js?v=1788040116230"></script>
|
||||
<script type="module" src="/src/components/UpdateChecker.jsx?v=1788040116230"></script>
|
||||
<script type="module" src="/dist/qr-local.js?v=1788040116230"></script>
|
||||
<script type="module" src="/src/components/QRScanner.js?v=1788040116230"></script>
|
||||
<script defer src="/src/utils/updateManager.js?v=1788237895492"></script>
|
||||
<script type="module" src="/src/components/UpdateChecker.jsx?v=1788237895492"></script>
|
||||
<script type="module" src="/dist/qr-local.js?v=1788237895492"></script>
|
||||
<script type="module" src="/src/components/QRScanner.js?v=1788237895492"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/dist/app-boot.js?v=1788040116230"></script>
|
||||
<script type="module" src="/dist/app.js?v=1788040116230"></script>
|
||||
<script type="module" src="/dist/app-boot.js?v=1788237895492"></script>
|
||||
<script type="module" src="/dist/app.js?v=1788237895492"></script>
|
||||
|
||||
<script defer src="/src/scripts/pwa-register.js?v=1788040116230"></script>
|
||||
<script src="/src/pwa/install-prompt.js?v=1788040116230" type="module"></script>
|
||||
<script src="/src/pwa/pwa-manager.js?v=1788040116230" type="module"></script>
|
||||
<script defer src="/src/scripts/pwa-offline-test.js?v=1788040116230"></script>
|
||||
<link rel="stylesheet" href="/src/styles/pwa.css?v=1788040116230">
|
||||
<script defer src="/src/scripts/pwa-register.js?v=1788237895492"></script>
|
||||
<script src="/src/pwa/install-prompt.js?v=1788237895492" type="module"></script>
|
||||
<script src="/src/pwa/pwa-manager.js?v=1788237895492" type="module"></script>
|
||||
<script defer src="/src/scripts/pwa-offline-test.js?v=1788237895492"></script>
|
||||
<link rel="stylesheet" href="/src/styles/pwa.css?v=1788237895492">
|
||||
</body>
|
||||
</html>
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+24
-24
@@ -30,7 +30,7 @@
|
||||
|
||||
<!-- PWA Manifest -->
|
||||
<link rel="manifest" href="/de/manifest.json">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788040116230">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788237895492">
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
@@ -96,7 +96,7 @@
|
||||
<link rel="apple-touch-startup-image" media="screen and (device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/logo/splash/splash_screens/8.3__iPad_Mini_portrait.png">
|
||||
|
||||
<!-- Apple Touch Icons -->
|
||||
<link rel="apple-touch-icon" href="/logo/icon-180x180.png?v=1788040116230">
|
||||
<link rel="apple-touch-icon" href="/logo/icon-180x180.png?v=1788237895492">
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="/logo/icon-57x57.png">
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="/logo/icon-60x60.png">
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="/logo/icon-72x72.png">
|
||||
@@ -105,7 +105,7 @@
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="/logo/icon-120x120.png">
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="/logo/icon-144x144.png">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="/logo/icon-152x152.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/logo/icon-180x180.png?v=1788040116230">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/logo/icon-180x180.png?v=1788237895492">
|
||||
|
||||
<!-- Microsoft Tiles -->
|
||||
<meta name="msapplication-TileColor" content="#ff6b35">
|
||||
@@ -222,7 +222,7 @@
|
||||
<!-- Render-blocking JS is deferred: classic deferred scripts and module scripts
|
||||
both execute in document order after parsing, so React still runs before the
|
||||
app modules below, but the parser / first paint is no longer blocked. -->
|
||||
<script defer src="/config/ice-servers.js?v=1788040116230"></script>
|
||||
<script defer src="/config/ice-servers.js?v=1788237895492"></script>
|
||||
<script defer src="/libs/react/react.production.min.js"></script>
|
||||
<script defer src="/libs/react-dom/react-dom.production.min.js"></script>
|
||||
<!-- Prism syntax highlighting (vendored, offline). Tokenizes code as TEXT only —
|
||||
@@ -230,8 +230,8 @@
|
||||
Its CSS is loaded async via load-async-css.js (not paint-critical). -->
|
||||
<script defer src="/libs/prism/prism.js"></script>
|
||||
<!-- Critical, paint-defining CSS stays render-blocking (avoids FOUC / layout shift). -->
|
||||
<link rel="stylesheet" href="/assets/tailwind.css?v=1788040116230">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788040116230">
|
||||
<link rel="stylesheet" href="/assets/tailwind.css?v=1788237895492">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788237895492">
|
||||
<!-- Preload only the fonts needed for first paint. fa-solid covers the bulk of UI
|
||||
icons; fa-regular/fa-brands are loaded on demand by their CSS (rarely on the
|
||||
first screen). Inter latin 400/700 cover body text and headings/buttons. -->
|
||||
@@ -239,38 +239,38 @@
|
||||
<link rel="preload" href="/assets/fonts/inter/files/inter-latin-400.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="/assets/fonts/inter/files/inter-latin-700.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="stylesheet" href="/assets/fonts/inter/inter.css">
|
||||
<link rel="stylesheet" href="/src/styles/main.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/animations.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/components.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/main.css?v=1788237895492">
|
||||
<link rel="stylesheet" href="/src/styles/animations.css?v=1788237895492">
|
||||
<link rel="stylesheet" href="/src/styles/components.css?v=1788237895492">
|
||||
<!-- Loads after components.css on purpose: press feedback, material weight,
|
||||
size-specific tracking and the reduced-motion / -transparency / -contrast
|
||||
answers all need to settle arguments with the sheets above on source order. -->
|
||||
<link rel="stylesheet" href="/src/styles/apple-motion.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/apple-motion.css?v=1788237895492">
|
||||
<!-- Last of the hand-written sheets: the mirroring rules must win over anything
|
||||
above them, and they only ever apply under [dir="rtl"]. -->
|
||||
<link rel="stylesheet" href="/src/styles/rtl.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/rtl.css?v=1788237895492">
|
||||
<!-- Non-critical CSS (FontAwesome ~102KB, Prism) loaded async — no longer blocks paint. -->
|
||||
<script defer src="/src/scripts/load-async-css.js?v=1788040116230"></script>
|
||||
<script defer src="/src/scripts/load-async-css.js?v=1788237895492"></script>
|
||||
<noscript>
|
||||
<link rel="stylesheet" href="/assets/fontawesome/css/all.min.css">
|
||||
<link rel="stylesheet" href="/libs/prism/prism.css">
|
||||
</noscript>
|
||||
<script defer src="/src/scripts/fa-check.js?v=1788040116230"></script>
|
||||
<script defer src="/src/scripts/fa-check.js?v=1788237895492"></script>
|
||||
<!-- Update Manager - система принудительного обновления -->
|
||||
<script defer src="/src/utils/updateManager.js?v=1788040116230"></script>
|
||||
<script type="module" src="/src/components/UpdateChecker.jsx?v=1788040116230"></script>
|
||||
<script type="module" src="/dist/qr-local.js?v=1788040116230"></script>
|
||||
<script type="module" src="/src/components/QRScanner.js?v=1788040116230"></script>
|
||||
<script defer src="/src/utils/updateManager.js?v=1788237895492"></script>
|
||||
<script type="module" src="/src/components/UpdateChecker.jsx?v=1788237895492"></script>
|
||||
<script type="module" src="/dist/qr-local.js?v=1788237895492"></script>
|
||||
<script type="module" src="/src/components/QRScanner.js?v=1788237895492"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/dist/app-boot.js?v=1788040116230"></script>
|
||||
<script type="module" src="/dist/app.js?v=1788040116230"></script>
|
||||
<script type="module" src="/dist/app-boot.js?v=1788237895492"></script>
|
||||
<script type="module" src="/dist/app.js?v=1788237895492"></script>
|
||||
|
||||
<script defer src="/src/scripts/pwa-register.js?v=1788040116230"></script>
|
||||
<script src="/src/pwa/install-prompt.js?v=1788040116230" type="module"></script>
|
||||
<script src="/src/pwa/pwa-manager.js?v=1788040116230" type="module"></script>
|
||||
<script defer src="/src/scripts/pwa-offline-test.js?v=1788040116230"></script>
|
||||
<link rel="stylesheet" href="/src/styles/pwa.css?v=1788040116230">
|
||||
<script defer src="/src/scripts/pwa-register.js?v=1788237895492"></script>
|
||||
<script src="/src/pwa/install-prompt.js?v=1788237895492" type="module"></script>
|
||||
<script src="/src/pwa/pwa-manager.js?v=1788237895492" type="module"></script>
|
||||
<script defer src="/src/scripts/pwa-offline-test.js?v=1788237895492"></script>
|
||||
<link rel="stylesheet" href="/src/styles/pwa.css?v=1788237895492">
|
||||
</body>
|
||||
</html>
|
||||
+42
-2
@@ -106,8 +106,8 @@ http {
|
||||
}
|
||||
|
||||
server {
|
||||
listen 8080;
|
||||
listen [::]:8080;
|
||||
listen 8080 default_server;
|
||||
listen [::]:8080 default_server;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
@@ -140,4 +140,44 @@ http {
|
||||
try_files $uri $uri/ $sb_shell;
|
||||
}
|
||||
}
|
||||
|
||||
# One canonical hostname. www.securebit.chat has its own Fly certificate (so
|
||||
# Cloudflare's origin handshake succeeds), but it must not serve the app as a
|
||||
# second address — that splits search ranking and canonical links. Cloudflare
|
||||
# passes the original Host through, so an exact server_name match catches it
|
||||
# here; everything else falls through to the default server above.
|
||||
server {
|
||||
listen 8080;
|
||||
listen [::]:8080;
|
||||
server_name www.securebit.chat;
|
||||
|
||||
# /sw.js is the one path that must NOT redirect. Visitors who reached www in
|
||||
# the window between its certificate being issued and this redirect shipping
|
||||
# registered the site's service worker against this origin, and that worker
|
||||
# still serves its cached app shell — so those browsers never see the 301,
|
||||
# while their subresource requests do get redirected and are then blocked by
|
||||
# the page's own `script-src 'self'`. A 301 here would make that permanent:
|
||||
# a service worker update fails outright if the script URL redirects. So this
|
||||
# origin answers with a worker that unregisters itself and reloads the tab.
|
||||
# See deploy/www-sw.js.
|
||||
location = /sw.js {
|
||||
alias /etc/nginx/www-sw.js;
|
||||
# add_header in a location REPLACES the server-level set rather than
|
||||
# merging, so the security header has to be restated here.
|
||||
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
|
||||
# Re-checked on every navigation instead of being trusted for 24 hours,
|
||||
# which is how quickly a stuck visitor gets rescued.
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||
add_header CDN-Cache-Control "no-store" always;
|
||||
}
|
||||
|
||||
location / {
|
||||
# The redirect itself must carry HSTS: it is the first response a
|
||||
# first-time www visitor sees, and the preload list covers subdomains.
|
||||
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
|
||||
|
||||
return 301 https://securebit.chat$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// Self-destructing service worker, served ONLY at https://www.securebit.chat/sw.js.
|
||||
//
|
||||
// www.securebit.chat answered TLS for the first time when its Fly certificate was
|
||||
// issued, and for the couple of minutes before the redirect below shipped it served
|
||||
// the real app. That was long enough for visitors to register the site's service
|
||||
// worker against the www origin. A registered worker outlives the server change: it
|
||||
// keeps serving its cached app shell, so the 301 is never reached, while every
|
||||
// subresource request does reach the network, gets redirected to the apex, and is
|
||||
// then refused by the page's own CSP (`script-src 'self'` — and 'self' is www).
|
||||
//
|
||||
// The redirect cannot clear this on its own. A service worker script is re-fetched
|
||||
// to check for updates, and the spec fails that update if the script URL redirects
|
||||
// at all, let alone cross-origin — so a plain 301 on /sw.js pins the stale worker in
|
||||
// place permanently. The only way out is to answer /sw.js with a real script that
|
||||
// takes over and then removes itself.
|
||||
//
|
||||
// It is served with no-store, so browsers re-check it on every navigation rather
|
||||
// than trusting a cached copy for 24 hours.
|
||||
|
||||
self.addEventListener('install', () => {
|
||||
// Do not wait for the old worker's clients to go away — they are exactly the
|
||||
// broken tabs this exists to rescue.
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil((async () => {
|
||||
// Drop the app shell the previous worker cached under this origin, so nothing
|
||||
// can be replayed from it if the unregister below is interrupted.
|
||||
const names = await caches.keys();
|
||||
await Promise.all(names.map((name) => caches.delete(name)));
|
||||
|
||||
await self.registration.unregister();
|
||||
|
||||
// Reload whatever is still open. With no worker left, the navigation reaches
|
||||
// nginx and follows the 301 to the apex, where the app actually lives.
|
||||
const clients = await self.clients.matchAll({ type: 'window' });
|
||||
for (const client of clients) {
|
||||
try {
|
||||
await client.navigate(client.url);
|
||||
} catch (_) {
|
||||
// A client that refuses to be navigated (cross-origin owner, or already
|
||||
// gone) is not worth failing activation over; its next reload is clean.
|
||||
}
|
||||
}
|
||||
})());
|
||||
});
|
||||
|
||||
// Deliberately no fetch handler: until the unregister lands, requests should go
|
||||
// straight to the network, which is where the redirect is.
|
||||
Vendored
+449
-27
@@ -4413,6 +4413,9 @@ var LOCALE_META = {
|
||||
var DICTIONARIES = {
|
||||
"en": {
|
||||
"language.label": "Language",
|
||||
"language.suggest.text": "This page is also available in English.",
|
||||
"language.suggest.cta": "Read in English",
|
||||
"language.suggest.dismiss": "Dismiss",
|
||||
"community.title": "Join the future of privacy",
|
||||
"community.description": "SecureBit grows thanks to its community. Your ideas and feedback shape the future of secure communication - built in the open, with complete ASN.1 validation end-to-end.",
|
||||
"community.github": "GitHub Repository",
|
||||
@@ -5158,10 +5161,35 @@ var DICTIONARIES = {
|
||||
"offline.disconnect": "Disconnect",
|
||||
"offline.learnMore": "Learn more",
|
||||
"pwa.installApp": "Install App",
|
||||
"chat.onWeb": "You're on Web"
|
||||
"chat.onWeb": "You're on Web",
|
||||
"groupCall.startVoice": "Start a group voice call",
|
||||
"groupCall.startVideo": "Start a group video call",
|
||||
"groupCall.join": "Join",
|
||||
"groupCall.dismiss": "Not now",
|
||||
"groupCall.leave": "Leave call",
|
||||
"groupCall.you": "You",
|
||||
"groupCall.connecting": "Connecting\u2026",
|
||||
"groupCall.waitingLink": "Waiting for a direct link\u2026",
|
||||
"groupCall.legFailed": "Could not connect",
|
||||
"groupCall.startedVoice": "{name} started a voice call",
|
||||
"groupCall.startedVideo": "{name} started a video call",
|
||||
"groupCall.inCall": "{count} in the call",
|
||||
"groupCall.err.permission_denied": "The call could not start - microphone and camera access is blocked. Allow it for this site, then try again.",
|
||||
"groupCall.err.device_not_found": "The call could not start - no microphone was found on this device.",
|
||||
"groupCall.err.device_busy": "The call could not start - your microphone is in use by another app. Close it and try again.",
|
||||
"groupCall.err.media_failed": "The call could not start - the microphone could not be opened.",
|
||||
"groupCall.err.call_in_progress": "A call is already running in this group. Join it instead of starting another.",
|
||||
"groupCall.err.not_ready": "Confirm the group code before calling.",
|
||||
"groupCall.speaking": "{name} is speaking",
|
||||
"groupCall.pin": "Show {name} large",
|
||||
"groupCall.unpin": "Back to everyone",
|
||||
"groupCall.showEveryone": "Everyone"
|
||||
},
|
||||
"de": {
|
||||
"language.label": "Sprache",
|
||||
"language.suggest.text": "Diese Seite gibt es auch auf Deutsch.",
|
||||
"language.suggest.cta": "Auf Deutsch lesen",
|
||||
"language.suggest.dismiss": "Schlie\xDFen",
|
||||
"community.title": "Gestalten Sie die Zukunft der Privatsph\xE4re mit",
|
||||
"community.description": "SecureBit w\xE4chst durch seine Community. Ihre Ideen und Ihr Feedback pr\xE4gen die Zukunft sicherer Kommunikation - offen entwickelt, mit vollst\xE4ndiger ASN.1-Validierung von Ende zu Ende.",
|
||||
"community.github": "GitHub-Repository",
|
||||
@@ -5907,10 +5935,35 @@ var DICTIONARIES = {
|
||||
"offline.disconnect": "Trennen",
|
||||
"offline.learnMore": "Mehr erfahren",
|
||||
"pwa.installApp": "App installieren",
|
||||
"chat.onWeb": "Sie nutzen die Web-Version"
|
||||
"chat.onWeb": "Sie nutzen die Web-Version",
|
||||
"groupCall.startVoice": "Gruppen-Sprachanruf starten",
|
||||
"groupCall.startVideo": "Gruppen-Videoanruf starten",
|
||||
"groupCall.join": "Beitreten",
|
||||
"groupCall.dismiss": "Jetzt nicht",
|
||||
"groupCall.leave": "Anruf verlassen",
|
||||
"groupCall.you": "Du",
|
||||
"groupCall.connecting": "Verbinden\u2026",
|
||||
"groupCall.waitingLink": "Warten auf eine direkte Verbindung\u2026",
|
||||
"groupCall.legFailed": "Verbindung fehlgeschlagen",
|
||||
"groupCall.startedVoice": "{name} hat einen Sprachanruf gestartet",
|
||||
"groupCall.startedVideo": "{name} hat einen Videoanruf gestartet",
|
||||
"groupCall.inCall": "{count} im Anruf",
|
||||
"groupCall.err.permission_denied": "Der Anruf konnte nicht starten - der Zugriff auf Mikrofon und Kamera ist blockiert. Erlaube ihn f\xFCr diese Seite und versuche es erneut.",
|
||||
"groupCall.err.device_not_found": "Der Anruf konnte nicht starten - auf diesem Ger\xE4t wurde kein Mikrofon gefunden.",
|
||||
"groupCall.err.device_busy": "Der Anruf konnte nicht starten - dein Mikrofon wird von einer anderen App verwendet. Schlie\xDFe sie und versuche es erneut.",
|
||||
"groupCall.err.media_failed": "Der Anruf konnte nicht starten - das Mikrofon lie\xDF sich nicht \xF6ffnen.",
|
||||
"groupCall.err.call_in_progress": "In dieser Gruppe l\xE4uft bereits ein Anruf. Tritt ihm bei, statt einen neuen zu starten.",
|
||||
"groupCall.err.not_ready": "Best\xE4tige zuerst den Gruppencode, bevor du anrufst.",
|
||||
"groupCall.speaking": "{name} spricht",
|
||||
"groupCall.pin": "{name} gro\xDF anzeigen",
|
||||
"groupCall.unpin": "Zur\xFCck zu allen",
|
||||
"groupCall.showEveryone": "Alle"
|
||||
},
|
||||
"fr": {
|
||||
"language.label": "Langue",
|
||||
"language.suggest.text": "Cette page est aussi disponible en fran\xE7ais.",
|
||||
"language.suggest.cta": "Lire en fran\xE7ais",
|
||||
"language.suggest.dismiss": "Fermer",
|
||||
"community.title": "Construisez l'avenir de la vie priv\xE9e",
|
||||
"community.description": "SecureBit grandit gr\xE2ce \xE0 sa communaut\xE9. Vos id\xE9es et vos retours fa\xE7onnent l'avenir des communications s\xE9curis\xE9es, d\xE9velopp\xE9es \xE0 ciel ouvert, avec une validation ASN.1 compl\xE8te de bout en bout.",
|
||||
"community.github": "D\xE9p\xF4t GitHub",
|
||||
@@ -6656,10 +6709,35 @@ var DICTIONARIES = {
|
||||
"offline.disconnect": "Se d\xE9connecter",
|
||||
"offline.learnMore": "En savoir plus",
|
||||
"pwa.installApp": "Installer l'application",
|
||||
"chat.onWeb": "Vous \xEAtes sur la version web"
|
||||
"chat.onWeb": "Vous \xEAtes sur la version web",
|
||||
"groupCall.startVoice": "D\xE9marrer un appel vocal de groupe",
|
||||
"groupCall.startVideo": "D\xE9marrer un appel vid\xE9o de groupe",
|
||||
"groupCall.join": "Rejoindre",
|
||||
"groupCall.dismiss": "Pas maintenant",
|
||||
"groupCall.leave": "Quitter l'appel",
|
||||
"groupCall.you": "Vous",
|
||||
"groupCall.connecting": "Connexion\u2026",
|
||||
"groupCall.waitingLink": "En attente d'un lien direct\u2026",
|
||||
"groupCall.legFailed": "Connexion impossible",
|
||||
"groupCall.startedVoice": "{name} a d\xE9marr\xE9 un appel vocal",
|
||||
"groupCall.startedVideo": "{name} a d\xE9marr\xE9 un appel vid\xE9o",
|
||||
"groupCall.inCall": "{count} dans l'appel",
|
||||
"groupCall.err.permission_denied": "L'appel n'a pas pu d\xE9marrer - l'acc\xE8s au micro et \xE0 la cam\xE9ra est bloqu\xE9. Autorisez-le pour ce site, puis r\xE9essayez.",
|
||||
"groupCall.err.device_not_found": "L'appel n'a pas pu d\xE9marrer - aucun micro n'a \xE9t\xE9 trouv\xE9 sur cet appareil.",
|
||||
"groupCall.err.device_busy": "L'appel n'a pas pu d\xE9marrer - votre micro est utilis\xE9 par une autre application. Fermez-la et r\xE9essayez.",
|
||||
"groupCall.err.media_failed": "L'appel n'a pas pu d\xE9marrer - le micro n'a pas pu \xEAtre ouvert.",
|
||||
"groupCall.err.call_in_progress": "Un appel est d\xE9j\xE0 en cours dans ce groupe. Rejoignez-le plut\xF4t que d'en d\xE9marrer un autre.",
|
||||
"groupCall.err.not_ready": "Confirmez le code du groupe avant d'appeler.",
|
||||
"groupCall.speaking": "{name} parle",
|
||||
"groupCall.pin": "Afficher {name} en grand",
|
||||
"groupCall.unpin": "Revenir \xE0 tous",
|
||||
"groupCall.showEveryone": "Tout le monde"
|
||||
},
|
||||
"es": {
|
||||
"language.label": "Idioma",
|
||||
"language.suggest.text": "Esta p\xE1gina tambi\xE9n est\xE1 disponible en espa\xF1ol.",
|
||||
"language.suggest.cta": "Leer en espa\xF1ol",
|
||||
"language.suggest.dismiss": "Cerrar",
|
||||
"community.title": "Construye el futuro de la privacidad",
|
||||
"community.description": "SecureBit crece gracias a su comunidad. Tus ideas y comentarios dan forma al futuro de la comunicaci\xF3n segura, desarrollada de forma abierta y con validaci\xF3n ASN.1 completa de extremo a extremo.",
|
||||
"community.github": "Repositorio en GitHub",
|
||||
@@ -7405,10 +7483,35 @@ var DICTIONARIES = {
|
||||
"offline.disconnect": "Desconectar",
|
||||
"offline.learnMore": "M\xE1s informaci\xF3n",
|
||||
"pwa.installApp": "Instalar la app",
|
||||
"chat.onWeb": "Est\xE1s en la versi\xF3n web"
|
||||
"chat.onWeb": "Est\xE1s en la versi\xF3n web",
|
||||
"groupCall.startVoice": "Iniciar llamada de voz grupal",
|
||||
"groupCall.startVideo": "Iniciar videollamada grupal",
|
||||
"groupCall.join": "Unirse",
|
||||
"groupCall.dismiss": "Ahora no",
|
||||
"groupCall.leave": "Salir de la llamada",
|
||||
"groupCall.you": "T\xFA",
|
||||
"groupCall.connecting": "Conectando\u2026",
|
||||
"groupCall.waitingLink": "Esperando un enlace directo\u2026",
|
||||
"groupCall.legFailed": "No se pudo conectar",
|
||||
"groupCall.startedVoice": "{name} inici\xF3 una llamada de voz",
|
||||
"groupCall.startedVideo": "{name} inici\xF3 una videollamada",
|
||||
"groupCall.inCall": "{count} en la llamada",
|
||||
"groupCall.err.permission_denied": "La llamada no pudo iniciarse - el acceso al micr\xF3fono y a la c\xE1mara est\xE1 bloqueado. Perm\xEDtelo para este sitio e int\xE9ntalo de nuevo.",
|
||||
"groupCall.err.device_not_found": "La llamada no pudo iniciarse - no se encontr\xF3 ning\xFAn micr\xF3fono en este dispositivo.",
|
||||
"groupCall.err.device_busy": "La llamada no pudo iniciarse - otra aplicaci\xF3n est\xE1 usando tu micr\xF3fono. Ci\xE9rrala e int\xE9ntalo de nuevo.",
|
||||
"groupCall.err.media_failed": "La llamada no pudo iniciarse - no se pudo abrir el micr\xF3fono.",
|
||||
"groupCall.err.call_in_progress": "Ya hay una llamada en este grupo. \xDAnete en lugar de iniciar otra.",
|
||||
"groupCall.err.not_ready": "Confirma el c\xF3digo del grupo antes de llamar.",
|
||||
"groupCall.speaking": "{name} est\xE1 hablando",
|
||||
"groupCall.pin": "Ver a {name} en grande",
|
||||
"groupCall.unpin": "Volver a todos",
|
||||
"groupCall.showEveryone": "Todos"
|
||||
},
|
||||
"uk": {
|
||||
"language.label": "\u041C\u043E\u0432\u0430",
|
||||
"language.suggest.text": "\u0426\u044F \u0441\u0442\u043E\u0440\u0456\u043D\u043A\u0430 \u0442\u0430\u043A\u043E\u0436 \u0434\u043E\u0441\u0442\u0443\u043F\u043D\u0430 \u0443\u043A\u0440\u0430\u0457\u043D\u0441\u044C\u043A\u043E\u044E.",
|
||||
"language.suggest.cta": "\u0427\u0438\u0442\u0430\u0442\u0438 \u0443\u043A\u0440\u0430\u0457\u043D\u0441\u044C\u043A\u043E\u044E",
|
||||
"language.suggest.dismiss": "\u0417\u0430\u043A\u0440\u0438\u0442\u0438",
|
||||
"community.title": "\u0414\u043E\u043B\u0443\u0447\u0430\u0439\u0442\u0435\u0441\u044F \u0434\u043E \u043C\u0430\u0439\u0431\u0443\u0442\u043D\u044C\u043E\u0433\u043E \u043F\u0440\u0438\u0432\u0430\u0442\u043D\u043E\u0441\u0442\u0456",
|
||||
"community.description": "SecureBit \u0440\u043E\u0441\u0442\u0435 \u0437\u0430\u0432\u0434\u044F\u043A\u0438 \u0441\u0432\u043E\u0457\u0439 \u0441\u043F\u0456\u043B\u044C\u043D\u043E\u0442\u0456. \u0412\u0430\u0448\u0456 \u0456\u0434\u0435\u0457 \u0442\u0430 \u0432\u0456\u0434\u0433\u0443\u043A\u0438 \u0444\u043E\u0440\u043C\u0443\u044E\u0442\u044C \u043C\u0430\u0439\u0431\u0443\u0442\u043D\u0454 \u0437\u0430\u0445\u0438\u0449\u0435\u043D\u043E\u0433\u043E \u0441\u043F\u0456\u043B\u043A\u0443\u0432\u0430\u043D\u043D\u044F - \u0440\u043E\u0437\u0440\u043E\u0431\u043A\u0430 \u0432\u0435\u0434\u0435\u0442\u044C\u0441\u044F \u0432\u0456\u0434\u043A\u0440\u0438\u0442\u043E, \u0437 \u043F\u043E\u0432\u043D\u043E\u044E \u0432\u0430\u043B\u0456\u0434\u0430\u0446\u0456\u0454\u044E ASN.1 \u043D\u0430 \u0432\u0441\u044C\u043E\u043C\u0443 \u0448\u043B\u044F\u0445\u0443.",
|
||||
"community.github": "\u0420\u0435\u043F\u043E\u0437\u0438\u0442\u043E\u0440\u0456\u0439 \u043D\u0430 GitHub",
|
||||
@@ -8154,10 +8257,35 @@ var DICTIONARIES = {
|
||||
"offline.disconnect": "\u0412\u0456\u0434'\u0454\u0434\u043D\u0430\u0442\u0438\u0441\u044F",
|
||||
"offline.learnMore": "\u0414\u043E\u043A\u043B\u0430\u0434\u043D\u0456\u0448\u0435",
|
||||
"pwa.installApp": "\u0412\u0441\u0442\u0430\u043D\u043E\u0432\u0438\u0442\u0438 \u0437\u0430\u0441\u0442\u043E\u0441\u0443\u043D\u043E\u043A",
|
||||
"chat.onWeb": "\u0412\u0438 \u0443 \u0432\u0435\u0431\u0432\u0435\u0440\u0441\u0456\u0457"
|
||||
"chat.onWeb": "\u0412\u0438 \u0443 \u0432\u0435\u0431\u0432\u0435\u0440\u0441\u0456\u0457",
|
||||
"groupCall.startVoice": "\u041F\u043E\u0447\u0430\u0442\u0438 \u0433\u0440\u0443\u043F\u043E\u0432\u0438\u0439 \u0433\u043E\u043B\u043E\u0441\u043E\u0432\u0438\u0439 \u0434\u0437\u0432\u0456\u043D\u043E\u043A",
|
||||
"groupCall.startVideo": "\u041F\u043E\u0447\u0430\u0442\u0438 \u0433\u0440\u0443\u043F\u043E\u0432\u0438\u0439 \u0432\u0456\u0434\u0435\u043E\u0434\u0437\u0432\u0456\u043D\u043E\u043A",
|
||||
"groupCall.join": "\u041F\u0440\u0438\u0454\u0434\u043D\u0430\u0442\u0438\u0441\u044F",
|
||||
"groupCall.dismiss": "\u041D\u0435 \u0437\u0430\u0440\u0430\u0437",
|
||||
"groupCall.leave": "\u0412\u0438\u0439\u0442\u0438 \u0437 \u0434\u0437\u0432\u0456\u043D\u043A\u0430",
|
||||
"groupCall.you": "\u0412\u0438",
|
||||
"groupCall.connecting": "\u0417'\u0454\u0434\u043D\u0430\u043D\u043D\u044F\u2026",
|
||||
"groupCall.waitingLink": "\u041E\u0447\u0456\u043A\u0443\u0432\u0430\u043D\u043D\u044F \u043F\u0440\u044F\u043C\u043E\u0433\u043E \u0437'\u0454\u0434\u043D\u0430\u043D\u043D\u044F\u2026",
|
||||
"groupCall.legFailed": "\u041D\u0435 \u0432\u0434\u0430\u043B\u043E\u0441\u044F \u0437'\u0454\u0434\u043D\u0430\u0442\u0438\u0441\u044F",
|
||||
"groupCall.startedVoice": "{name} \u043F\u043E\u0447\u0430\u0432 \u0433\u043E\u043B\u043E\u0441\u043E\u0432\u0438\u0439 \u0434\u0437\u0432\u0456\u043D\u043E\u043A",
|
||||
"groupCall.startedVideo": "{name} \u043F\u043E\u0447\u0430\u0432 \u0432\u0456\u0434\u0435\u043E\u0434\u0437\u0432\u0456\u043D\u043E\u043A",
|
||||
"groupCall.inCall": "{count} \u0443 \u0434\u0437\u0432\u0456\u043D\u043A\u0443",
|
||||
"groupCall.err.permission_denied": "\u0414\u0437\u0432\u0456\u043D\u043E\u043A \u043D\u0435 \u043F\u043E\u0447\u0430\u0432\u0441\u044F - \u0434\u043E\u0441\u0442\u0443\u043F \u0434\u043E \u043C\u0456\u043A\u0440\u043E\u0444\u043E\u043D\u0430 \u0439 \u043A\u0430\u043C\u0435\u0440\u0438 \u0437\u0430\u0431\u043B\u043E\u043A\u043E\u0432\u0430\u043D\u043E. \u0414\u043E\u0437\u0432\u043E\u043B\u044C\u0442\u0435 \u0439\u043E\u0433\u043E \u0434\u043B\u044F \u0446\u044C\u043E\u0433\u043E \u0441\u0430\u0439\u0442\u0443 \u0439 \u0441\u043F\u0440\u043E\u0431\u0443\u0439\u0442\u0435 \u0449\u0435 \u0440\u0430\u0437.",
|
||||
"groupCall.err.device_not_found": "\u0414\u0437\u0432\u0456\u043D\u043E\u043A \u043D\u0435 \u043F\u043E\u0447\u0430\u0432\u0441\u044F - \u043D\u0430 \u0446\u044C\u043E\u043C\u0443 \u043F\u0440\u0438\u0441\u0442\u0440\u043E\u0457 \u043D\u0435 \u0437\u043D\u0430\u0439\u0434\u0435\u043D\u043E \u043C\u0456\u043A\u0440\u043E\u0444\u043E\u043D\u0430.",
|
||||
"groupCall.err.device_busy": "\u0414\u0437\u0432\u0456\u043D\u043E\u043A \u043D\u0435 \u043F\u043E\u0447\u0430\u0432\u0441\u044F - \u043C\u0456\u043A\u0440\u043E\u0444\u043E\u043D \u0437\u0430\u0439\u043D\u044F\u0442\u0438\u0439 \u0456\u043D\u0448\u0438\u043C \u0437\u0430\u0441\u0442\u043E\u0441\u0443\u043D\u043A\u043E\u043C. \u0417\u0430\u043A\u0440\u0438\u0439\u0442\u0435 \u0439\u043E\u0433\u043E \u0439 \u0441\u043F\u0440\u043E\u0431\u0443\u0439\u0442\u0435 \u0449\u0435 \u0440\u0430\u0437.",
|
||||
"groupCall.err.media_failed": "\u0414\u0437\u0432\u0456\u043D\u043E\u043A \u043D\u0435 \u043F\u043E\u0447\u0430\u0432\u0441\u044F - \u043D\u0435 \u0432\u0434\u0430\u043B\u043E\u0441\u044F \u0432\u0456\u0434\u043A\u0440\u0438\u0442\u0438 \u043C\u0456\u043A\u0440\u043E\u0444\u043E\u043D.",
|
||||
"groupCall.err.call_in_progress": "\u0423 \u0446\u0456\u0439 \u0433\u0440\u0443\u043F\u0456 \u0432\u0436\u0435 \u0442\u0440\u0438\u0432\u0430\u0454 \u0434\u0437\u0432\u0456\u043D\u043E\u043A. \u041F\u0440\u0438\u0454\u0434\u043D\u0430\u0439\u0442\u0435\u0441\u044F \u0434\u043E \u043D\u044C\u043E\u0433\u043E, \u0430 \u043D\u0435 \u043F\u043E\u0447\u0438\u043D\u0430\u0439\u0442\u0435 \u043D\u043E\u0432\u0438\u0439.",
|
||||
"groupCall.err.not_ready": "\u0421\u043F\u0435\u0440\u0448\u0443 \u043F\u0456\u0434\u0442\u0432\u0435\u0440\u0434\u044C\u0442\u0435 \u043A\u043E\u0434 \u0433\u0440\u0443\u043F\u0438.",
|
||||
"groupCall.speaking": "{name} \u0433\u043E\u0432\u043E\u0440\u0438\u0442\u044C",
|
||||
"groupCall.pin": "\u041F\u043E\u043A\u0430\u0437\u0430\u0442\u0438 {name} \u0432\u0435\u043B\u0438\u043A\u0438\u043C",
|
||||
"groupCall.unpin": "\u041F\u043E\u0432\u0435\u0440\u043D\u0443\u0442\u0438\u0441\u044F \u0434\u043E \u0432\u0441\u0456\u0445",
|
||||
"groupCall.showEveryone": "\u0423\u0441\u0456"
|
||||
},
|
||||
"ru": {
|
||||
"language.label": "\u042F\u0437\u044B\u043A",
|
||||
"language.suggest.text": "\u042D\u0442\u0430 \u0441\u0442\u0440\u0430\u043D\u0438\u0446\u0430 \u0442\u0430\u043A\u0436\u0435 \u0434\u043E\u0441\u0442\u0443\u043F\u043D\u0430 \u043D\u0430 \u0440\u0443\u0441\u0441\u043A\u043E\u043C.",
|
||||
"language.suggest.cta": "\u0427\u0438\u0442\u0430\u0442\u044C \u043F\u043E-\u0440\u0443\u0441\u0441\u043A\u0438",
|
||||
"language.suggest.dismiss": "\u0417\u0430\u043A\u0440\u044B\u0442\u044C",
|
||||
"community.title": "\u041F\u0440\u0438\u0441\u043E\u0435\u0434\u0438\u043D\u044F\u0439\u0442\u0435\u0441\u044C \u043A \u0431\u0443\u0434\u0443\u0449\u0435\u043C\u0443 \u043F\u0440\u0438\u0432\u0430\u0442\u043D\u043E\u0441\u0442\u0438",
|
||||
"community.description": "SecureBit \u0440\u0430\u0441\u0442\u0451\u0442 \u0431\u043B\u0430\u0433\u043E\u0434\u0430\u0440\u044F \u0441\u043E\u043E\u0431\u0449\u0435\u0441\u0442\u0432\u0443. \u0412\u0430\u0448\u0438 \u0438\u0434\u0435\u0438 \u0438 \u043E\u0442\u0437\u044B\u0432\u044B \u0444\u043E\u0440\u043C\u0438\u0440\u0443\u044E\u0442 \u0431\u0443\u0434\u0443\u0449\u0435\u0435 \u0437\u0430\u0449\u0438\u0449\u0451\u043D\u043D\u043E\u0433\u043E \u043E\u0431\u0449\u0435\u043D\u0438\u044F - \u0440\u0430\u0437\u0440\u0430\u0431\u043E\u0442\u043A\u0430 \u0432\u0435\u0434\u0451\u0442\u0441\u044F \u043E\u0442\u043A\u0440\u044B\u0442\u043E, \u0441 \u043F\u043E\u043B\u043D\u043E\u0439 \u043F\u0440\u043E\u0432\u0435\u0440\u043A\u043E\u0439 ASN.1 \u043D\u0430 \u0432\u0441\u0451\u043C \u043F\u0443\u0442\u0438.",
|
||||
"community.github": "\u0420\u0435\u043F\u043E\u0437\u0438\u0442\u043E\u0440\u0438\u0439 \u043D\u0430 GitHub",
|
||||
@@ -8903,10 +9031,35 @@ var DICTIONARIES = {
|
||||
"offline.disconnect": "\u041E\u0442\u043A\u043B\u044E\u0447\u0438\u0442\u044C\u0441\u044F",
|
||||
"offline.learnMore": "\u041F\u043E\u0434\u0440\u043E\u0431\u043D\u0435\u0435",
|
||||
"pwa.installApp": "\u0423\u0441\u0442\u0430\u043D\u043E\u0432\u0438\u0442\u044C \u043F\u0440\u0438\u043B\u043E\u0436\u0435\u043D\u0438\u0435",
|
||||
"chat.onWeb": "\u0412\u044B \u0432 \u0432\u0435\u0431-\u0432\u0435\u0440\u0441\u0438\u0438"
|
||||
"chat.onWeb": "\u0412\u044B \u0432 \u0432\u0435\u0431-\u0432\u0435\u0440\u0441\u0438\u0438",
|
||||
"groupCall.startVoice": "\u041D\u0430\u0447\u0430\u0442\u044C \u0433\u0440\u0443\u043F\u043F\u043E\u0432\u043E\u0439 \u0433\u043E\u043B\u043E\u0441\u043E\u0432\u043E\u0439 \u0437\u0432\u043E\u043D\u043E\u043A",
|
||||
"groupCall.startVideo": "\u041D\u0430\u0447\u0430\u0442\u044C \u0433\u0440\u0443\u043F\u043F\u043E\u0432\u043E\u0439 \u0432\u0438\u0434\u0435\u043E\u0437\u0432\u043E\u043D\u043E\u043A",
|
||||
"groupCall.join": "\u041F\u0440\u0438\u0441\u043E\u0435\u0434\u0438\u043D\u0438\u0442\u044C\u0441\u044F",
|
||||
"groupCall.dismiss": "\u041D\u0435 \u0441\u0435\u0439\u0447\u0430\u0441",
|
||||
"groupCall.leave": "\u0412\u044B\u0439\u0442\u0438 \u0438\u0437 \u0437\u0432\u043E\u043D\u043A\u0430",
|
||||
"groupCall.you": "\u0412\u044B",
|
||||
"groupCall.connecting": "\u0421\u043E\u0435\u0434\u0438\u043D\u0435\u043D\u0438\u0435\u2026",
|
||||
"groupCall.waitingLink": "\u041E\u0436\u0438\u0434\u0430\u043D\u0438\u0435 \u043F\u0440\u044F\u043C\u043E\u0433\u043E \u0441\u043E\u0435\u0434\u0438\u043D\u0435\u043D\u0438\u044F\u2026",
|
||||
"groupCall.legFailed": "\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0441\u043E\u0435\u0434\u0438\u043D\u0438\u0442\u044C\u0441\u044F",
|
||||
"groupCall.startedVoice": "{name} \u043D\u0430\u0447\u0430\u043B \u0433\u043E\u043B\u043E\u0441\u043E\u0432\u043E\u0439 \u0437\u0432\u043E\u043D\u043E\u043A",
|
||||
"groupCall.startedVideo": "{name} \u043D\u0430\u0447\u0430\u043B \u0432\u0438\u0434\u0435\u043E\u0437\u0432\u043E\u043D\u043E\u043A",
|
||||
"groupCall.inCall": "{count} \u0432 \u0437\u0432\u043E\u043D\u043A\u0435",
|
||||
"groupCall.err.permission_denied": "\u0417\u0432\u043E\u043D\u043E\u043A \u043D\u0435 \u043D\u0430\u0447\u0430\u043B\u0441\u044F - \u0434\u043E\u0441\u0442\u0443\u043F \u043A \u043C\u0438\u043A\u0440\u043E\u0444\u043E\u043D\u0443 \u0438 \u043A\u0430\u043C\u0435\u0440\u0435 \u0437\u0430\u0431\u043B\u043E\u043A\u0438\u0440\u043E\u0432\u0430\u043D. \u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u0435 \u0435\u0433\u043E \u0434\u043B\u044F \u044D\u0442\u043E\u0433\u043E \u0441\u0430\u0439\u0442\u0430 \u0438 \u043F\u043E\u043F\u0440\u043E\u0431\u0443\u0439\u0442\u0435 \u0441\u043D\u043E\u0432\u0430.",
|
||||
"groupCall.err.device_not_found": "\u0417\u0432\u043E\u043D\u043E\u043A \u043D\u0435 \u043D\u0430\u0447\u0430\u043B\u0441\u044F - \u043D\u0430 \u044D\u0442\u043E\u043C \u0443\u0441\u0442\u0440\u043E\u0439\u0441\u0442\u0432\u0435 \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D \u043C\u0438\u043A\u0440\u043E\u0444\u043E\u043D.",
|
||||
"groupCall.err.device_busy": "\u0417\u0432\u043E\u043D\u043E\u043A \u043D\u0435 \u043D\u0430\u0447\u0430\u043B\u0441\u044F - \u043C\u0438\u043A\u0440\u043E\u0444\u043E\u043D \u0437\u0430\u043D\u044F\u0442 \u0434\u0440\u0443\u0433\u0438\u043C \u043F\u0440\u0438\u043B\u043E\u0436\u0435\u043D\u0438\u0435\u043C. \u0417\u0430\u043A\u0440\u043E\u0439\u0442\u0435 \u0435\u0433\u043E \u0438 \u043F\u043E\u043F\u0440\u043E\u0431\u0443\u0439\u0442\u0435 \u0441\u043D\u043E\u0432\u0430.",
|
||||
"groupCall.err.media_failed": "\u0417\u0432\u043E\u043D\u043E\u043A \u043D\u0435 \u043D\u0430\u0447\u0430\u043B\u0441\u044F - \u043D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043E\u0442\u043A\u0440\u044B\u0442\u044C \u043C\u0438\u043A\u0440\u043E\u0444\u043E\u043D.",
|
||||
"groupCall.err.call_in_progress": "\u0412 \u044D\u0442\u043E\u0439 \u0433\u0440\u0443\u043F\u043F\u0435 \u0443\u0436\u0435 \u0438\u0434\u0451\u0442 \u0437\u0432\u043E\u043D\u043E\u043A. \u041F\u0440\u0438\u0441\u043E\u0435\u0434\u0438\u043D\u0438\u0442\u0435\u0441\u044C \u043A \u043D\u0435\u043C\u0443, \u0430 \u043D\u0435 \u043D\u0430\u0447\u0438\u043D\u0430\u0439\u0442\u0435 \u043D\u043E\u0432\u044B\u0439.",
|
||||
"groupCall.err.not_ready": "\u0421\u043D\u0430\u0447\u0430\u043B\u0430 \u043F\u043E\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u0435 \u043A\u043E\u0434 \u0433\u0440\u0443\u043F\u043F\u044B.",
|
||||
"groupCall.speaking": "{name} \u0433\u043E\u0432\u043E\u0440\u0438\u0442",
|
||||
"groupCall.pin": "\u041F\u043E\u043A\u0430\u0437\u0430\u0442\u044C {name} \u043A\u0440\u0443\u043F\u043D\u043E",
|
||||
"groupCall.unpin": "\u0412\u0435\u0440\u043D\u0443\u0442\u044C\u0441\u044F \u043A\u043E \u0432\u0441\u0435\u043C",
|
||||
"groupCall.showEveryone": "\u0412\u0441\u0435"
|
||||
},
|
||||
"zh": {
|
||||
"language.label": "\u8BED\u8A00",
|
||||
"language.suggest.text": "\u672C\u9875\u9762\u4E5F\u6709\u7B80\u4F53\u4E2D\u6587\u7248\u672C\u3002",
|
||||
"language.suggest.cta": "\u9605\u8BFB\u7B80\u4F53\u4E2D\u6587",
|
||||
"language.suggest.dismiss": "\u5173\u95ED",
|
||||
"community.title": "\u4E00\u8D77\u6784\u7B51\u9690\u79C1\u7684\u672A\u6765",
|
||||
"community.description": "SecureBit \u56E0\u793E\u533A\u800C\u6210\u957F\u3002\u4F60\u7684\u60F3\u6CD5\u4E0E\u53CD\u9988\u5851\u9020\u7740\u5B89\u5168\u901A\u8BAF\u7684\u672A\u6765 \u2014\u2014 \u5F00\u653E\u5F00\u53D1\uFF0C\u5168\u7A0B\u5B8C\u6574\u7684 ASN.1 \u6821\u9A8C\u3002",
|
||||
"community.github": "GitHub \u4ED3\u5E93",
|
||||
@@ -9652,10 +9805,35 @@ var DICTIONARIES = {
|
||||
"offline.disconnect": "\u65AD\u5F00\u8FDE\u63A5",
|
||||
"offline.learnMore": "\u4E86\u89E3\u66F4\u591A",
|
||||
"pwa.installApp": "\u5B89\u88C5\u5E94\u7528",
|
||||
"chat.onWeb": "\u4F60\u6B63\u5728\u4F7F\u7528\u7F51\u9875\u7248"
|
||||
"chat.onWeb": "\u4F60\u6B63\u5728\u4F7F\u7528\u7F51\u9875\u7248",
|
||||
"groupCall.startVoice": "\u53D1\u8D77\u7FA4\u7EC4\u8BED\u97F3\u901A\u8BDD",
|
||||
"groupCall.startVideo": "\u53D1\u8D77\u7FA4\u7EC4\u89C6\u9891\u901A\u8BDD",
|
||||
"groupCall.join": "\u52A0\u5165",
|
||||
"groupCall.dismiss": "\u6682\u4E0D\u52A0\u5165",
|
||||
"groupCall.leave": "\u79BB\u5F00\u901A\u8BDD",
|
||||
"groupCall.you": "\u4F60",
|
||||
"groupCall.connecting": "\u8FDE\u63A5\u4E2D\u2026",
|
||||
"groupCall.waitingLink": "\u6B63\u5728\u7B49\u5F85\u76F4\u8FDE\u2026",
|
||||
"groupCall.legFailed": "\u65E0\u6CD5\u8FDE\u63A5",
|
||||
"groupCall.startedVoice": "{name} \u53D1\u8D77\u4E86\u8BED\u97F3\u901A\u8BDD",
|
||||
"groupCall.startedVideo": "{name} \u53D1\u8D77\u4E86\u89C6\u9891\u901A\u8BDD",
|
||||
"groupCall.inCall": "{count} \u4EBA\u5728\u901A\u8BDD\u4E2D",
|
||||
"groupCall.err.permission_denied": "\u901A\u8BDD\u65E0\u6CD5\u5F00\u59CB - \u9EA6\u514B\u98CE\u548C\u6444\u50CF\u5934\u6743\u9650\u88AB\u62D2\u7EDD\u3002\u8BF7\u4E3A\u672C\u7AD9\u70B9\u5141\u8BB8\u540E\u91CD\u8BD5\u3002",
|
||||
"groupCall.err.device_not_found": "\u901A\u8BDD\u65E0\u6CD5\u5F00\u59CB - \u6B64\u8BBE\u5907\u4E0A\u672A\u627E\u5230\u9EA6\u514B\u98CE\u3002",
|
||||
"groupCall.err.device_busy": "\u901A\u8BDD\u65E0\u6CD5\u5F00\u59CB - \u9EA6\u514B\u98CE\u6B63\u88AB\u5176\u4ED6\u5E94\u7528\u5360\u7528\u3002\u8BF7\u5173\u95ED\u540E\u91CD\u8BD5\u3002",
|
||||
"groupCall.err.media_failed": "\u901A\u8BDD\u65E0\u6CD5\u5F00\u59CB - \u65E0\u6CD5\u6253\u5F00\u9EA6\u514B\u98CE\u3002",
|
||||
"groupCall.err.call_in_progress": "\u8BE5\u7FA4\u7EC4\u5DF2\u6709\u901A\u8BDD\u6B63\u5728\u8FDB\u884C\u3002\u8BF7\u52A0\u5165\uFF0C\u800C\u4E0D\u662F\u53E6\u5F00\u4E00\u4E2A\u3002",
|
||||
"groupCall.err.not_ready": "\u8BF7\u5148\u786E\u8BA4\u7FA4\u7EC4\u5B89\u5168\u7801\u518D\u901A\u8BDD\u3002",
|
||||
"groupCall.speaking": "{name} \u6B63\u5728\u8BB2\u8BDD",
|
||||
"groupCall.pin": "\u653E\u5927\u663E\u793A {name}",
|
||||
"groupCall.unpin": "\u8FD4\u56DE\u6240\u6709\u4EBA",
|
||||
"groupCall.showEveryone": "\u6240\u6709\u4EBA"
|
||||
},
|
||||
"ko": {
|
||||
"language.label": "\uC5B8\uC5B4",
|
||||
"language.suggest.text": "\uC774 \uD398\uC774\uC9C0\uB294 \uD55C\uAD6D\uC5B4\uB85C\uB3C4 \uBCFC \uC218 \uC788\uC2B5\uB2C8\uB2E4.",
|
||||
"language.suggest.cta": "\uD55C\uAD6D\uC5B4\uB85C \uBCF4\uAE30",
|
||||
"language.suggest.dismiss": "\uB2EB\uAE30",
|
||||
"community.title": "\uD504\uB77C\uC774\uBC84\uC2DC\uC758 \uBBF8\uB798\uB97C \uD568\uAED8 \uB9CC\uB4ED\uB2C8\uB2E4",
|
||||
"community.description": "SecureBit\uC740 \uCEE4\uBBA4\uB2C8\uD2F0 \uB355\uBD84\uC5D0 \uC790\uB78D\uB2C8\uB2E4. \uC5EC\uB7EC\uBD84\uC758 \uC544\uC774\uB514\uC5B4\uC640 \uC758\uACAC\uC774 \uC548\uC804\uD55C \uC18C\uD1B5\uC758 \uBBF8\uB798\uB97C \uB9CC\uB4ED\uB2C8\uB2E4 - \uBAA8\uB4E0 \uACFC\uC815\uC774 \uACF5\uAC1C\uB418\uC5B4 \uC788\uACE0, \uC804 \uAD6C\uAC04\uC5D0\uC11C ASN.1 \uAC80\uC99D\uC744 \uC218\uD589\uD569\uB2C8\uB2E4.",
|
||||
"community.github": "GitHub \uC800\uC7A5\uC18C",
|
||||
@@ -10401,10 +10579,35 @@ var DICTIONARIES = {
|
||||
"offline.disconnect": "\uC5F0\uACB0 \uB04A\uAE30",
|
||||
"offline.learnMore": "\uC790\uC138\uD788 \uBCF4\uAE30",
|
||||
"pwa.installApp": "\uC571 \uC124\uCE58",
|
||||
"chat.onWeb": "\uC6F9 \uBC84\uC804\uC744 \uC4F0\uACE0 \uC788\uC2B5\uB2C8\uB2E4"
|
||||
"chat.onWeb": "\uC6F9 \uBC84\uC804\uC744 \uC4F0\uACE0 \uC788\uC2B5\uB2C8\uB2E4",
|
||||
"groupCall.startVoice": "\uADF8\uB8F9 \uC74C\uC131 \uD1B5\uD654 \uC2DC\uC791",
|
||||
"groupCall.startVideo": "\uADF8\uB8F9 \uC601\uC0C1 \uD1B5\uD654 \uC2DC\uC791",
|
||||
"groupCall.join": "\uCC38\uC5EC",
|
||||
"groupCall.dismiss": "\uB098\uC911\uC5D0",
|
||||
"groupCall.leave": "\uD1B5\uD654 \uB098\uAC00\uAE30",
|
||||
"groupCall.you": "\uB098",
|
||||
"groupCall.connecting": "\uC5F0\uACB0 \uC911\u2026",
|
||||
"groupCall.waitingLink": "\uC9C1\uC811 \uC5F0\uACB0\uC744 \uAE30\uB2E4\uB9AC\uB294 \uC911\u2026",
|
||||
"groupCall.legFailed": "\uC5F0\uACB0\uD558\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4",
|
||||
"groupCall.startedVoice": "{name} \uB2D8\uC774 \uC74C\uC131 \uD1B5\uD654\uB97C \uC2DC\uC791\uD588\uC2B5\uB2C8\uB2E4",
|
||||
"groupCall.startedVideo": "{name} \uB2D8\uC774 \uC601\uC0C1 \uD1B5\uD654\uB97C \uC2DC\uC791\uD588\uC2B5\uB2C8\uB2E4",
|
||||
"groupCall.inCall": "\uD1B5\uD654 \uC911 {count}\uBA85",
|
||||
"groupCall.err.permission_denied": "\uD1B5\uD654\uB97C \uC2DC\uC791\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4 - \uB9C8\uC774\uD06C\uC640 \uCE74\uBA54\uB77C \uC811\uADFC\uC774 \uCC28\uB2E8\uB418\uC5B4 \uC788\uC2B5\uB2C8\uB2E4. \uC774 \uC0AC\uC774\uD2B8\uC5D0 \uD5C8\uC6A9\uD55C \uB4A4 \uB2E4\uC2DC \uC2DC\uB3C4\uD558\uC138\uC694.",
|
||||
"groupCall.err.device_not_found": "\uD1B5\uD654\uB97C \uC2DC\uC791\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4 - \uC774 \uAE30\uAE30\uC5D0\uC11C \uB9C8\uC774\uD06C\uB97C \uCC3E\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.",
|
||||
"groupCall.err.device_busy": "\uD1B5\uD654\uB97C \uC2DC\uC791\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4 - \uB9C8\uC774\uD06C\uB97C \uB2E4\uB978 \uC571\uC774 \uC0AC\uC6A9 \uC911\uC785\uB2C8\uB2E4. \uC885\uB8CC\uD55C \uB4A4 \uB2E4\uC2DC \uC2DC\uB3C4\uD558\uC138\uC694.",
|
||||
"groupCall.err.media_failed": "\uD1B5\uD654\uB97C \uC2DC\uC791\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4 - \uB9C8\uC774\uD06C\uB97C \uC5F4\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4.",
|
||||
"groupCall.err.call_in_progress": "\uC774 \uADF8\uB8F9\uC5D0\uC11C \uC774\uBBF8 \uD1B5\uD654\uAC00 \uC9C4\uD589 \uC911\uC785\uB2C8\uB2E4. \uC0C8\uB85C \uC2DC\uC791\uD558\uC9C0 \uB9D0\uACE0 \uCC38\uC5EC\uD558\uC138\uC694.",
|
||||
"groupCall.err.not_ready": "\uD1B5\uD654\uD558\uAE30 \uC804\uC5D0 \uADF8\uB8F9 \uCF54\uB4DC\uB97C \uD655\uC778\uD558\uC138\uC694.",
|
||||
"groupCall.speaking": "{name} \uB2D8\uC774 \uB9D0\uD558\uB294 \uC911",
|
||||
"groupCall.pin": "{name} \uD06C\uAC8C \uBCF4\uAE30",
|
||||
"groupCall.unpin": "\uC804\uCCB4 \uBCF4\uAE30\uB85C \uB3CC\uC544\uAC00\uAE30",
|
||||
"groupCall.showEveryone": "\uC804\uCCB4"
|
||||
},
|
||||
"hi": {
|
||||
"language.label": "\u092D\u093E\u0937\u093E",
|
||||
"language.suggest.text": "\u092F\u0939 \u092A\u0943\u0937\u094D\u0920 \u0939\u093F\u0928\u094D\u0926\u0940 \u092E\u0947\u0902 \u092D\u0940 \u0909\u092A\u0932\u092C\u094D\u0927 \u0939\u0948\u0964",
|
||||
"language.suggest.cta": "\u0939\u093F\u0928\u094D\u0926\u0940 \u092E\u0947\u0902 \u092A\u0922\u093C\u0947\u0902",
|
||||
"language.suggest.dismiss": "\u092C\u0902\u0926 \u0915\u0930\u0947\u0902",
|
||||
"community.title": "\u0928\u093F\u091C\u0924\u093E \u0915\u0947 \u092D\u0935\u093F\u0937\u094D\u092F \u092E\u0947\u0902 \u0936\u093E\u092E\u093F\u0932 \u0939\u094B\u0902",
|
||||
"community.description": "SecureBit \u0905\u092A\u0928\u0947 \u0938\u092E\u0941\u0926\u093E\u092F \u0938\u0947 \u092C\u0922\u093C\u0924\u093E \u0939\u0948\u0964 \u0906\u092A\u0915\u0947 \u0935\u093F\u091A\u093E\u0930 \u0914\u0930 \u0938\u0941\u091D\u093E\u0935 \u0938\u0941\u0930\u0915\u094D\u0937\u093F\u0924 \u0938\u0902\u0935\u093E\u0926 \u0915\u093E \u092D\u0935\u093F\u0937\u094D\u092F \u0917\u0922\u093C\u0924\u0947 \u0939\u0948\u0902 - \u0916\u0941\u0932\u0947 \u092E\u0947\u0902 \u092C\u0928\u093E, \u0914\u0930 \u092A\u0942\u0930\u0947 \u0930\u093E\u0938\u094D\u0924\u0947 \u092A\u0942\u0930\u0940 ASN.1 \u091C\u093E\u0901\u091A \u0915\u0947 \u0938\u093E\u0925\u0964",
|
||||
"community.github": "GitHub \u0930\u093F\u092A\u0949\u091C\u093C\u093F\u091F\u0930\u0940",
|
||||
@@ -11150,10 +11353,35 @@ var DICTIONARIES = {
|
||||
"offline.disconnect": "\u091C\u0941\u0921\u093C\u093E\u0935 \u0924\u094B\u0921\u093C\u0947\u0902",
|
||||
"offline.learnMore": "\u0914\u0930 \u091C\u093E\u0928\u0947\u0902",
|
||||
"pwa.installApp": "\u0910\u092A \u0907\u0902\u0938\u094D\u091F\u0949\u0932 \u0915\u0930\u0947\u0902",
|
||||
"chat.onWeb": "\u0906\u092A \u0935\u0947\u092C \u0938\u0902\u0938\u094D\u0915\u0930\u0923 \u092A\u0930 \u0939\u0948\u0902"
|
||||
"chat.onWeb": "\u0906\u092A \u0935\u0947\u092C \u0938\u0902\u0938\u094D\u0915\u0930\u0923 \u092A\u0930 \u0939\u0948\u0902",
|
||||
"groupCall.startVoice": "\u0938\u092E\u0942\u0939 \u0935\u0949\u0907\u0938 \u0915\u0949\u0932 \u0936\u0941\u0930\u0942 \u0915\u0930\u0947\u0902",
|
||||
"groupCall.startVideo": "\u0938\u092E\u0942\u0939 \u0935\u0940\u0921\u093F\u092F\u094B \u0915\u0949\u0932 \u0936\u0941\u0930\u0942 \u0915\u0930\u0947\u0902",
|
||||
"groupCall.join": "\u0936\u093E\u092E\u093F\u0932 \u0939\u094B\u0902",
|
||||
"groupCall.dismiss": "\u0905\u092D\u0940 \u0928\u0939\u0940\u0902",
|
||||
"groupCall.leave": "\u0915\u0949\u0932 \u091B\u094B\u0921\u093C\u0947\u0902",
|
||||
"groupCall.you": "\u0906\u092A",
|
||||
"groupCall.connecting": "\u091C\u0941\u0921\u093C \u0930\u0939\u093E \u0939\u0948\u2026",
|
||||
"groupCall.waitingLink": "\u0938\u0940\u0927\u0947 \u0932\u093F\u0902\u0915 \u0915\u0940 \u092A\u094D\u0930\u0924\u0940\u0915\u094D\u0937\u093E\u2026",
|
||||
"groupCall.legFailed": "\u0915\u0928\u0947\u0915\u094D\u091F \u0928\u0939\u0940\u0902 \u0939\u094B \u0938\u0915\u093E",
|
||||
"groupCall.startedVoice": "{name} \u0928\u0947 \u0935\u0949\u0907\u0938 \u0915\u0949\u0932 \u0936\u0941\u0930\u0942 \u0915\u0940",
|
||||
"groupCall.startedVideo": "{name} \u0928\u0947 \u0935\u0940\u0921\u093F\u092F\u094B \u0915\u0949\u0932 \u0936\u0941\u0930\u0942 \u0915\u0940",
|
||||
"groupCall.inCall": "\u0915\u0949\u0932 \u092E\u0947\u0902 {count}",
|
||||
"groupCall.err.permission_denied": "\u0915\u0949\u0932 \u0936\u0941\u0930\u0942 \u0928\u0939\u0940\u0902 \u0939\u094B \u0938\u0915\u0940 - \u092E\u093E\u0907\u0915\u094D\u0930\u094B\u092B\u093C\u094B\u0928 \u0914\u0930 \u0915\u0948\u092E\u0930\u0947 \u0915\u0940 \u0905\u0928\u0941\u092E\u0924\u093F \u0905\u0935\u0930\u0941\u0926\u094D\u0927 \u0939\u0948\u0964 \u0907\u0938 \u0938\u093E\u0907\u091F \u0915\u0947 \u0932\u093F\u090F \u0905\u0928\u0941\u092E\u0924\u093F \u0926\u0947\u0902 \u0914\u0930 \u092B\u093F\u0930 \u0915\u094B\u0936\u093F\u0936 \u0915\u0930\u0947\u0902\u0964",
|
||||
"groupCall.err.device_not_found": "\u0915\u0949\u0932 \u0936\u0941\u0930\u0942 \u0928\u0939\u0940\u0902 \u0939\u094B \u0938\u0915\u0940 - \u0907\u0938 \u0921\u093F\u0935\u093E\u0907\u0938 \u092A\u0930 \u0915\u094B\u0908 \u092E\u093E\u0907\u0915\u094D\u0930\u094B\u092B\u093C\u094B\u0928 \u0928\u0939\u0940\u0902 \u092E\u093F\u0932\u093E\u0964",
|
||||
"groupCall.err.device_busy": "\u0915\u0949\u0932 \u0936\u0941\u0930\u0942 \u0928\u0939\u0940\u0902 \u0939\u094B \u0938\u0915\u0940 - \u0906\u092A\u0915\u093E \u092E\u093E\u0907\u0915\u094D\u0930\u094B\u092B\u093C\u094B\u0928 \u0915\u093F\u0938\u0940 \u0905\u0928\u094D\u092F \u0910\u092A \u0915\u0947 \u0909\u092A\u092F\u094B\u0917 \u092E\u0947\u0902 \u0939\u0948\u0964 \u0909\u0938\u0947 \u092C\u0902\u0926 \u0915\u0930\u0915\u0947 \u092B\u093F\u0930 \u0915\u094B\u0936\u093F\u0936 \u0915\u0930\u0947\u0902\u0964",
|
||||
"groupCall.err.media_failed": "\u0915\u0949\u0932 \u0936\u0941\u0930\u0942 \u0928\u0939\u0940\u0902 \u0939\u094B \u0938\u0915\u0940 - \u092E\u093E\u0907\u0915\u094D\u0930\u094B\u092B\u093C\u094B\u0928 \u0928\u0939\u0940\u0902 \u0916\u0941\u0932 \u0938\u0915\u093E\u0964",
|
||||
"groupCall.err.call_in_progress": "\u0907\u0938 \u0938\u092E\u0942\u0939 \u092E\u0947\u0902 \u092A\u0939\u0932\u0947 \u0938\u0947 \u090F\u0915 \u0915\u0949\u0932 \u091A\u0932 \u0930\u0939\u0940 \u0939\u0948\u0964 \u0928\u0908 \u0936\u0941\u0930\u0942 \u0915\u0930\u0928\u0947 \u0915\u0947 \u092C\u091C\u093E\u092F \u0909\u0938\u092E\u0947\u0902 \u0936\u093E\u092E\u093F\u0932 \u0939\u094B\u0902\u0964",
|
||||
"groupCall.err.not_ready": "\u0915\u0949\u0932 \u0915\u0930\u0928\u0947 \u0938\u0947 \u092A\u0939\u0932\u0947 \u0938\u092E\u0942\u0939 \u0915\u094B\u0921 \u0915\u0940 \u092A\u0941\u0937\u094D\u091F\u093F \u0915\u0930\u0947\u0902\u0964",
|
||||
"groupCall.speaking": "{name} \u092C\u094B\u0932 \u0930\u0939\u0947 \u0939\u0948\u0902",
|
||||
"groupCall.pin": "{name} \u0915\u094B \u092C\u0921\u093C\u093E \u0926\u093F\u0916\u093E\u090F\u0901",
|
||||
"groupCall.unpin": "\u0938\u092D\u0940 \u092A\u0930 \u0932\u094C\u091F\u0947\u0902",
|
||||
"groupCall.showEveryone": "\u0938\u092D\u0940"
|
||||
},
|
||||
"ar": {
|
||||
"language.label": "\u0627\u0644\u0644\u063A\u0629",
|
||||
"language.suggest.text": "\u0647\u0630\u0647 \u0627\u0644\u0635\u0641\u062D\u0629 \u0645\u062A\u0648\u0641\u0631\u0629 \u0623\u064A\u0636\u064B\u0627 \u0628\u0627\u0644\u0639\u0631\u0628\u064A\u0629.",
|
||||
"language.suggest.cta": "\u0627\u0642\u0631\u0623 \u0628\u0627\u0644\u0639\u0631\u0628\u064A\u0629",
|
||||
"language.suggest.dismiss": "\u0625\u063A\u0644\u0627\u0642",
|
||||
"community.title": "\u0627\u0646\u0636\u0645\u0651 \u0625\u0644\u0649 \u0645\u0633\u062A\u0642\u0628\u0644 \u0627\u0644\u062E\u0635\u0648\u0635\u064A\u0629",
|
||||
"community.description": "\u064A\u0646\u0645\u0648 SecureBit \u0628\u0641\u0636\u0644 \u0645\u062C\u062A\u0645\u0639\u0647. \u0623\u0641\u0643\u0627\u0631\u0643\u0645 \u0648\u0645\u0644\u0627\u062D\u0638\u0627\u062A\u0643\u0645 \u0647\u064A \u0645\u0627 \u064A\u0635\u0648\u063A \u0645\u0633\u062A\u0642\u0628\u0644 \u0627\u0644\u062A\u0648\u0627\u0635\u0644 \u0627\u0644\u0622\u0645\u0646 - \u0645\u0628\u0646\u064A\u0651 \u0641\u064A \u0627\u0644\u0639\u0644\u0646\u060C \u0645\u0639 \u062A\u062D\u0642\u0651\u0642 ASN.1 \u0643\u0627\u0645\u0644 \u0645\u0646 \u0637\u0631\u0641 \u0625\u0644\u0649 \u0637\u0631\u0641.",
|
||||
"community.github": "\u0645\u0633\u062A\u0648\u062F\u0639 GitHub",
|
||||
@@ -11899,10 +12127,35 @@ var DICTIONARIES = {
|
||||
"offline.disconnect": "\u0642\u0637\u0639 \u0627\u0644\u0627\u062A\u0635\u0627\u0644",
|
||||
"offline.learnMore": "\u0627\u0639\u0631\u0641 \u0627\u0644\u0645\u0632\u064A\u062F",
|
||||
"pwa.installApp": "\u062B\u0628\u0651\u062A \u0627\u0644\u062A\u0637\u0628\u064A\u0642",
|
||||
"chat.onWeb": "\u0623\u0646\u062A \u0639\u0644\u0649 \u0627\u0644\u0648\u064A\u0628"
|
||||
"chat.onWeb": "\u0623\u0646\u062A \u0639\u0644\u0649 \u0627\u0644\u0648\u064A\u0628",
|
||||
"groupCall.startVoice": "\u0628\u062F\u0621 \u0645\u0643\u0627\u0644\u0645\u0629 \u0635\u0648\u062A\u064A\u0629 \u062C\u0645\u0627\u0639\u064A\u0629",
|
||||
"groupCall.startVideo": "\u0628\u062F\u0621 \u0645\u0643\u0627\u0644\u0645\u0629 \u0641\u064A\u062F\u064A\u0648 \u062C\u0645\u0627\u0639\u064A\u0629",
|
||||
"groupCall.join": "\u0627\u0646\u0636\u0645\u0627\u0645",
|
||||
"groupCall.dismiss": "\u0644\u064A\u0633 \u0627\u0644\u0622\u0646",
|
||||
"groupCall.leave": "\u0645\u063A\u0627\u062F\u0631\u0629 \u0627\u0644\u0645\u0643\u0627\u0644\u0645\u0629",
|
||||
"groupCall.you": "\u0623\u0646\u062A",
|
||||
"groupCall.connecting": "\u062C\u0627\u0631\u064D \u0627\u0644\u0627\u062A\u0635\u0627\u0644\u2026",
|
||||
"groupCall.waitingLink": "\u0641\u064A \u0627\u0646\u062A\u0638\u0627\u0631 \u0631\u0627\u0628\u0637 \u0645\u0628\u0627\u0634\u0631\u2026",
|
||||
"groupCall.legFailed": "\u062A\u0639\u0630\u0651\u0631 \u0627\u0644\u0627\u062A\u0635\u0627\u0644",
|
||||
"groupCall.startedVoice": "\u0628\u062F\u0623 {name} \u0645\u0643\u0627\u0644\u0645\u0629 \u0635\u0648\u062A\u064A\u0629",
|
||||
"groupCall.startedVideo": "\u0628\u062F\u0623 {name} \u0645\u0643\u0627\u0644\u0645\u0629 \u0641\u064A\u062F\u064A\u0648",
|
||||
"groupCall.inCall": "{count} \u0641\u064A \u0627\u0644\u0645\u0643\u0627\u0644\u0645\u0629",
|
||||
"groupCall.err.permission_denied": "\u062A\u0639\u0630\u0651\u0631 \u0628\u062F\u0621 \u0627\u0644\u0645\u0643\u0627\u0644\u0645\u0629 - \u0627\u0644\u0648\u0635\u0648\u0644 \u0625\u0644\u0649 \u0627\u0644\u0645\u064A\u0643\u0631\u0648\u0641\u0648\u0646 \u0648\u0627\u0644\u0643\u0627\u0645\u064A\u0631\u0627 \u0645\u062D\u0638\u0648\u0631. \u0627\u0633\u0645\u062D \u0628\u0647 \u0644\u0647\u0630\u0627 \u0627\u0644\u0645\u0648\u0642\u0639 \u062B\u0645 \u0623\u0639\u062F \u0627\u0644\u0645\u062D\u0627\u0648\u0644\u0629.",
|
||||
"groupCall.err.device_not_found": "\u062A\u0639\u0630\u0651\u0631 \u0628\u062F\u0621 \u0627\u0644\u0645\u0643\u0627\u0644\u0645\u0629 - \u0644\u0645 \u064A\u064F\u0639\u062B\u0631 \u0639\u0644\u0649 \u0645\u064A\u0643\u0631\u0648\u0641\u0648\u0646 \u0641\u064A \u0647\u0630\u0627 \u0627\u0644\u062C\u0647\u0627\u0632.",
|
||||
"groupCall.err.device_busy": "\u062A\u0639\u0630\u0651\u0631 \u0628\u062F\u0621 \u0627\u0644\u0645\u0643\u0627\u0644\u0645\u0629 - \u0627\u0644\u0645\u064A\u0643\u0631\u0648\u0641\u0648\u0646 \u0645\u0633\u062A\u062E\u062F\u064E\u0645 \u0645\u0646 \u062A\u0637\u0628\u064A\u0642 \u0622\u062E\u0631. \u0623\u063A\u0644\u0642\u0647 \u062B\u0645 \u0623\u0639\u062F \u0627\u0644\u0645\u062D\u0627\u0648\u0644\u0629.",
|
||||
"groupCall.err.media_failed": "\u062A\u0639\u0630\u0651\u0631 \u0628\u062F\u0621 \u0627\u0644\u0645\u0643\u0627\u0644\u0645\u0629 - \u0644\u0645 \u064A\u0645\u0643\u0646 \u0641\u062A\u062D \u0627\u0644\u0645\u064A\u0643\u0631\u0648\u0641\u0648\u0646.",
|
||||
"groupCall.err.call_in_progress": "\u0647\u0646\u0627\u0643 \u0645\u0643\u0627\u0644\u0645\u0629 \u062C\u0627\u0631\u064A\u0629 \u0628\u0627\u0644\u0641\u0639\u0644 \u0641\u064A \u0647\u0630\u0647 \u0627\u0644\u0645\u062C\u0645\u0648\u0639\u0629. \u0627\u0646\u0636\u0645 \u0625\u0644\u064A\u0647\u0627 \u0628\u062F\u0644 \u0628\u062F\u0621 \u0623\u062E\u0631\u0649.",
|
||||
"groupCall.err.not_ready": "\u0623\u0643\u0651\u062F \u0631\u0645\u0632 \u0627\u0644\u0645\u062C\u0645\u0648\u0639\u0629 \u0642\u0628\u0644 \u0627\u0644\u0627\u062A\u0635\u0627\u0644.",
|
||||
"groupCall.speaking": "{name} \u064A\u062A\u062D\u062F\u0651\u062B \u0627\u0644\u0622\u0646",
|
||||
"groupCall.pin": "\u0639\u0631\u0636 {name} \u0628\u062D\u062C\u0645 \u0643\u0628\u064A\u0631",
|
||||
"groupCall.unpin": "\u0627\u0644\u0639\u0648\u062F\u0629 \u0625\u0644\u0649 \u0627\u0644\u062C\u0645\u064A\u0639",
|
||||
"groupCall.showEveryone": "\u0627\u0644\u062C\u0645\u064A\u0639"
|
||||
},
|
||||
"he": {
|
||||
"language.label": "\u05E9\u05E4\u05D4",
|
||||
"language.suggest.text": "\u05D4\u05D3\u05E3 \u05D4\u05D6\u05D4 \u05D6\u05DE\u05D9\u05DF \u05D2\u05DD \u05D1\u05E2\u05D1\u05E8\u05D9\u05EA.",
|
||||
"language.suggest.cta": "\u05DC\u05E7\u05E8\u05D9\u05D0\u05D4 \u05D1\u05E2\u05D1\u05E8\u05D9\u05EA",
|
||||
"language.suggest.dismiss": "\u05E1\u05D2\u05D9\u05E8\u05D4",
|
||||
"community.title": "\u05D4\u05E6\u05D8\u05E8\u05E4\u05D5 \u05DC\u05E2\u05EA\u05D9\u05D3 \u05E9\u05DC \u05D4\u05E4\u05E8\u05D8\u05D9\u05D5\u05EA",
|
||||
"community.description": "SecureBit \u05D2\u05D3\u05DC \u05D1\u05D6\u05DB\u05D5\u05EA \u05D4\u05E7\u05D4\u05D9\u05DC\u05D4 \u05E9\u05DC\u05D5. \u05D4\u05E8\u05E2\u05D9\u05D5\u05E0\u05D5\u05EA \u05D5\u05D4\u05DE\u05E9\u05D5\u05D1 \u05E9\u05DC\u05DB\u05DD \u05DE\u05E2\u05E6\u05D1\u05D9\u05DD \u05D0\u05EA \u05E2\u05EA\u05D9\u05D3 \u05D4\u05EA\u05E7\u05E9\u05D5\u05E8\u05EA \u05D4\u05DE\u05D0\u05D5\u05D1\u05D8\u05D7\u05EA - \u05E0\u05D1\u05E0\u05D4 \u05D1\u05D2\u05DC\u05D5\u05D9, \u05E2\u05DD \u05D0\u05D9\u05DE\u05D5\u05EA ASN.1 \u05DE\u05DC\u05D0 \u05DE\u05E7\u05E6\u05D4 \u05DC\u05E7\u05E6\u05D4.",
|
||||
"community.github": "\u05DE\u05D0\u05D2\u05E8 GitHub",
|
||||
@@ -12648,10 +12901,35 @@ var DICTIONARIES = {
|
||||
"offline.disconnect": "\u05E0\u05D9\u05EA\u05D5\u05E7",
|
||||
"offline.learnMore": "\u05DE\u05D9\u05D3\u05E2 \u05E0\u05D5\u05E1\u05E3",
|
||||
"pwa.installApp": "\u05D4\u05EA\u05E7\u05E0\u05EA \u05D4\u05D0\u05E4\u05DC\u05D9\u05E7\u05E6\u05D9\u05D4",
|
||||
"chat.onWeb": "\u05D0\u05EA\u05DD \u05D1\u05D2\u05E8\u05E1\u05EA \u05D4\u05D5\u05D5\u05D1"
|
||||
"chat.onWeb": "\u05D0\u05EA\u05DD \u05D1\u05D2\u05E8\u05E1\u05EA \u05D4\u05D5\u05D5\u05D1",
|
||||
"groupCall.startVoice": "\u05D4\u05EA\u05D7\u05DC\u05EA \u05E9\u05D9\u05D7\u05D4 \u05E7\u05D5\u05DC\u05D9\u05EA \u05E7\u05D1\u05D5\u05E6\u05EA\u05D9\u05EA",
|
||||
"groupCall.startVideo": "\u05D4\u05EA\u05D7\u05DC\u05EA \u05E9\u05D9\u05D7\u05EA \u05D5\u05D9\u05D3\u05D0\u05D5 \u05E7\u05D1\u05D5\u05E6\u05EA\u05D9\u05EA",
|
||||
"groupCall.join": "\u05D4\u05E6\u05D8\u05E8\u05E4\u05D5\u05EA",
|
||||
"groupCall.dismiss": "\u05DC\u05D0 \u05E2\u05DB\u05E9\u05D9\u05D5",
|
||||
"groupCall.leave": "\u05D9\u05E6\u05D9\u05D0\u05D4 \u05DE\u05D4\u05E9\u05D9\u05D7\u05D4",
|
||||
"groupCall.you": "\u05D0\u05EA/\u05D4",
|
||||
"groupCall.connecting": "\u05DE\u05EA\u05D7\u05D1\u05E8\u2026",
|
||||
"groupCall.waitingLink": "\u05DE\u05DE\u05EA\u05D9\u05DF \u05DC\u05E7\u05D9\u05E9\u05D5\u05E8 \u05D9\u05E9\u05D9\u05E8\u2026",
|
||||
"groupCall.legFailed": "\u05D4\u05D4\u05EA\u05D7\u05D1\u05E8\u05D5\u05EA \u05E0\u05DB\u05E9\u05DC\u05D4",
|
||||
"groupCall.startedVoice": "{name} \u05D4\u05EA\u05D7\u05D9\u05DC \u05E9\u05D9\u05D7\u05D4 \u05E7\u05D5\u05DC\u05D9\u05EA",
|
||||
"groupCall.startedVideo": "{name} \u05D4\u05EA\u05D7\u05D9\u05DC \u05E9\u05D9\u05D7\u05EA \u05D5\u05D9\u05D3\u05D0\u05D5",
|
||||
"groupCall.inCall": "{count} \u05D1\u05E9\u05D9\u05D7\u05D4",
|
||||
"groupCall.err.permission_denied": "\u05D4\u05E9\u05D9\u05D7\u05D4 \u05DC\u05D0 \u05D4\u05EA\u05D7\u05D9\u05DC\u05D4 - \u05D4\u05D2\u05D9\u05E9\u05D4 \u05DC\u05DE\u05D9\u05E7\u05E8\u05D5\u05E4\u05D5\u05DF \u05D5\u05DC\u05DE\u05E6\u05DC\u05DE\u05D4 \u05D7\u05E1\u05D5\u05DE\u05D4. \u05D0\u05E4\u05E9\u05E8\u05D5 \u05D0\u05D5\u05EA\u05D4 \u05DC\u05D0\u05EA\u05E8 \u05D4\u05D6\u05D4 \u05D5\u05E0\u05E1\u05D5 \u05E9\u05D5\u05D1.",
|
||||
"groupCall.err.device_not_found": "\u05D4\u05E9\u05D9\u05D7\u05D4 \u05DC\u05D0 \u05D4\u05EA\u05D7\u05D9\u05DC\u05D4 - \u05DC\u05D0 \u05E0\u05DE\u05E6\u05D0 \u05DE\u05D9\u05E7\u05E8\u05D5\u05E4\u05D5\u05DF \u05D1\u05DE\u05DB\u05E9\u05D9\u05E8 \u05D4\u05D6\u05D4.",
|
||||
"groupCall.err.device_busy": "\u05D4\u05E9\u05D9\u05D7\u05D4 \u05DC\u05D0 \u05D4\u05EA\u05D7\u05D9\u05DC\u05D4 - \u05D4\u05DE\u05D9\u05E7\u05E8\u05D5\u05E4\u05D5\u05DF \u05D1\u05E9\u05D9\u05DE\u05D5\u05E9 \u05D0\u05E4\u05DC\u05D9\u05E7\u05E6\u05D9\u05D4 \u05D0\u05D7\u05E8\u05EA. \u05E1\u05D2\u05E8\u05D5 \u05D0\u05D5\u05EA\u05D4 \u05D5\u05E0\u05E1\u05D5 \u05E9\u05D5\u05D1.",
|
||||
"groupCall.err.media_failed": "\u05D4\u05E9\u05D9\u05D7\u05D4 \u05DC\u05D0 \u05D4\u05EA\u05D7\u05D9\u05DC\u05D4 - \u05DC\u05D0 \u05E0\u05D9\u05EA\u05DF \u05D4\u05D9\u05D4 \u05DC\u05E4\u05EA\u05D5\u05D7 \u05D0\u05EA \u05D4\u05DE\u05D9\u05E7\u05E8\u05D5\u05E4\u05D5\u05DF.",
|
||||
"groupCall.err.call_in_progress": "\u05DB\u05D1\u05E8 \u05DE\u05EA\u05E7\u05D9\u05D9\u05DE\u05EA \u05E9\u05D9\u05D7\u05D4 \u05D1\u05E7\u05D1\u05D5\u05E6\u05D4 \u05D4\u05D6\u05D5. \u05D4\u05E6\u05D8\u05E8\u05E4\u05D5 \u05D0\u05DC\u05D9\u05D4 \u05D1\u05DE\u05E7\u05D5\u05DD \u05DC\u05D4\u05EA\u05D7\u05D9\u05DC \u05D0\u05D7\u05EA \u05D7\u05D3\u05E9\u05D4.",
|
||||
"groupCall.err.not_ready": "\u05D0\u05E9\u05E8\u05D5 \u05D0\u05EA \u05E7\u05D5\u05D3 \u05D4\u05E7\u05D1\u05D5\u05E6\u05D4 \u05DC\u05E4\u05E0\u05D9 \u05E9\u05D9\u05D7\u05D4.",
|
||||
"groupCall.speaking": "{name} \u05DE\u05D3\u05D1\u05E8/\u05EA",
|
||||
"groupCall.pin": "\u05D4\u05E6\u05D2\u05EA {name} \u05D1\u05D2\u05D3\u05D5\u05DC",
|
||||
"groupCall.unpin": "\u05D7\u05D6\u05E8\u05D4 \u05DC\u05DB\u05D5\u05DC\u05DD",
|
||||
"groupCall.showEveryone": "\u05DB\u05D5\u05DC\u05DD"
|
||||
},
|
||||
"fa": {
|
||||
"language.label": "\u0632\u0628\u0627\u0646",
|
||||
"language.suggest.text": "\u0627\u06CC\u0646 \u0635\u0641\u062D\u0647 \u0628\u0647 \u0641\u0627\u0631\u0633\u06CC \u0647\u0645 \u062F\u0631 \u062F\u0633\u062A\u0631\u0633 \u0627\u0633\u062A.",
|
||||
"language.suggest.cta": "\u062E\u0648\u0627\u0646\u062F\u0646 \u0628\u0647 \u0641\u0627\u0631\u0633\u06CC",
|
||||
"language.suggest.dismiss": "\u0628\u0633\u062A\u0646",
|
||||
"community.title": "\u0628\u0647 \u0622\u06CC\u0646\u062F\u0647\u0654 \u062D\u0631\u06CC\u0645 \u062E\u0635\u0648\u0635\u06CC \u0628\u067E\u06CC\u0648\u0646\u062F\u06CC\u062F",
|
||||
"community.description": "\u200FSecureBit \u0628\u0647\u200C\u0644\u0637\u0641 \u062C\u0627\u0645\u0639\u0647\u200C\u0627\u0634 \u0631\u0634\u062F \u0645\u06CC\u200C\u06A9\u0646\u062F. \u0627\u06CC\u062F\u0647\u200C\u0647\u0627 \u0648 \u0628\u0627\u0632\u062E\u0648\u0631\u062F\u0647\u0627\u06CC \u0634\u0645\u0627 \u0622\u06CC\u0646\u062F\u0647\u0654 \u0627\u0631\u062A\u0628\u0627\u0637 \u0627\u0645\u0646 \u0631\u0627 \u0634\u06A9\u0644 \u0645\u06CC\u200C\u062F\u0647\u062F - \u0633\u0627\u062E\u062A\u0647\u200C\u0634\u062F\u0647 \u062F\u0631 \u0641\u0636\u0627\u06CC \u0628\u0627\u0632\u060C \u0628\u0627 \u0627\u0639\u062A\u0628\u0627\u0631\u0633\u0646\u062C\u06CC \u06A9\u0627\u0645\u0644 ASN.1 \u0627\u0632 \u06CC\u06A9 \u0633\u0631 \u062A\u0627 \u0633\u0631 \u062F\u06CC\u06AF\u0631.",
|
||||
"community.github": "\u0645\u062E\u0632\u0646 GitHub",
|
||||
@@ -13397,10 +13675,35 @@ var DICTIONARIES = {
|
||||
"offline.disconnect": "\u0642\u0637\u0639 \u0627\u062A\u0635\u0627\u0644",
|
||||
"offline.learnMore": "\u0628\u06CC\u0634\u062A\u0631 \u0628\u062F\u0627\u0646\u06CC\u062F",
|
||||
"pwa.installApp": "\u0646\u0635\u0628 \u0628\u0631\u0646\u0627\u0645\u0647",
|
||||
"chat.onWeb": "\u0634\u0645\u0627 \u0631\u0648\u06CC \u0646\u0633\u062E\u0647\u0654 \u0648\u0628 \u0647\u0633\u062A\u06CC\u062F"
|
||||
"chat.onWeb": "\u0634\u0645\u0627 \u0631\u0648\u06CC \u0646\u0633\u062E\u0647\u0654 \u0648\u0628 \u0647\u0633\u062A\u06CC\u062F",
|
||||
"groupCall.startVoice": "\u0634\u0631\u0648\u0639 \u062A\u0645\u0627\u0633 \u0635\u0648\u062A\u06CC \u06AF\u0631\u0648\u0647\u06CC",
|
||||
"groupCall.startVideo": "\u0634\u0631\u0648\u0639 \u062A\u0645\u0627\u0633 \u062A\u0635\u0648\u06CC\u0631\u06CC \u06AF\u0631\u0648\u0647\u06CC",
|
||||
"groupCall.join": "\u067E\u06CC\u0648\u0633\u062A\u0646",
|
||||
"groupCall.dismiss": "\u0627\u0644\u0627\u0646 \u0646\u0647",
|
||||
"groupCall.leave": "\u062E\u0631\u0648\u062C \u0627\u0632 \u062A\u0645\u0627\u0633",
|
||||
"groupCall.you": "\u0634\u0645\u0627",
|
||||
"groupCall.connecting": "\u062F\u0631 \u062D\u0627\u0644 \u0627\u062A\u0635\u0627\u0644\u2026",
|
||||
"groupCall.waitingLink": "\u062F\u0631 \u0627\u0646\u062A\u0638\u0627\u0631 \u06CC\u06A9 \u067E\u06CC\u0648\u0646\u062F \u0645\u0633\u062A\u0642\u06CC\u0645\u2026",
|
||||
"groupCall.legFailed": "\u0627\u062A\u0635\u0627\u0644 \u0628\u0631\u0642\u0631\u0627\u0631 \u0646\u0634\u062F",
|
||||
"groupCall.startedVoice": "{name} \u06CC\u06A9 \u062A\u0645\u0627\u0633 \u0635\u0648\u062A\u06CC \u0622\u063A\u0627\u0632 \u06A9\u0631\u062F",
|
||||
"groupCall.startedVideo": "{name} \u06CC\u06A9 \u062A\u0645\u0627\u0633 \u062A\u0635\u0648\u06CC\u0631\u06CC \u0622\u063A\u0627\u0632 \u06A9\u0631\u062F",
|
||||
"groupCall.inCall": "{count} \u0646\u0641\u0631 \u062F\u0631 \u062A\u0645\u0627\u0633",
|
||||
"groupCall.err.permission_denied": "\u062A\u0645\u0627\u0633 \u0622\u063A\u0627\u0632 \u0646\u0634\u062F - \u062F\u0633\u062A\u0631\u0633\u06CC \u0628\u0647 \u0645\u06CC\u06A9\u0631\u0648\u0641\u0648\u0646 \u0648 \u062F\u0648\u0631\u0628\u06CC\u0646 \u0645\u0633\u062F\u0648\u062F \u0627\u0633\u062A. \u0622\u0646 \u0631\u0627 \u0628\u0631\u0627\u06CC \u0627\u06CC\u0646 \u0633\u0627\u06CC\u062A \u0645\u062C\u0627\u0632 \u06A9\u0646\u06CC\u062F \u0648 \u062F\u0648\u0628\u0627\u0631\u0647 \u062A\u0644\u0627\u0634 \u06A9\u0646\u06CC\u062F.",
|
||||
"groupCall.err.device_not_found": "\u062A\u0645\u0627\u0633 \u0622\u063A\u0627\u0632 \u0646\u0634\u062F - \u0645\u06CC\u06A9\u0631\u0648\u0641\u0648\u0646\u06CC \u0631\u0648\u06CC \u0627\u06CC\u0646 \u062F\u0633\u062A\u06AF\u0627\u0647 \u067E\u06CC\u062F\u0627 \u0646\u0634\u062F.",
|
||||
"groupCall.err.device_busy": "\u062A\u0645\u0627\u0633 \u0622\u063A\u0627\u0632 \u0646\u0634\u062F - \u0645\u06CC\u06A9\u0631\u0648\u0641\u0648\u0646 \u0634\u0645\u0627 \u062F\u0631 \u062D\u0627\u0644 \u0627\u0633\u062A\u0641\u0627\u062F\u0647 \u062A\u0648\u0633\u0637 \u0628\u0631\u0646\u0627\u0645\u0647 \u062F\u06CC\u06AF\u0631\u06CC \u0627\u0633\u062A. \u0622\u0646 \u0631\u0627 \u0628\u0628\u0646\u062F\u06CC\u062F \u0648 \u062F\u0648\u0628\u0627\u0631\u0647 \u062A\u0644\u0627\u0634 \u06A9\u0646\u06CC\u062F.",
|
||||
"groupCall.err.media_failed": "\u062A\u0645\u0627\u0633 \u0622\u063A\u0627\u0632 \u0646\u0634\u062F - \u0645\u06CC\u06A9\u0631\u0648\u0641\u0648\u0646 \u0628\u0627\u0632 \u0646\u0634\u062F.",
|
||||
"groupCall.err.call_in_progress": "\u0647\u0645\u06CC\u0646 \u062D\u0627\u0644\u0627 \u062A\u0645\u0627\u0633\u06CC \u062F\u0631 \u0627\u06CC\u0646 \u06AF\u0631\u0648\u0647 \u062F\u0631 \u062C\u0631\u06CC\u0627\u0646 \u0627\u0633\u062A. \u0628\u0647\u200C\u062C\u0627\u06CC \u0634\u0631\u0648\u0639 \u062A\u0645\u0627\u0633 \u062A\u0627\u0632\u0647\u060C \u0628\u0647 \u0622\u0646 \u0628\u067E\u06CC\u0648\u0646\u062F\u06CC\u062F.",
|
||||
"groupCall.err.not_ready": "\u067E\u06CC\u0634 \u0627\u0632 \u062A\u0645\u0627\u0633\u060C \u06A9\u062F \u06AF\u0631\u0648\u0647 \u0631\u0627 \u062A\u0623\u06CC\u06CC\u062F \u06A9\u0646\u06CC\u062F.",
|
||||
"groupCall.speaking": "{name} \u062F\u0631 \u062D\u0627\u0644 \u0635\u062D\u0628\u062A \u0627\u0633\u062A",
|
||||
"groupCall.pin": "\u0646\u0645\u0627\u06CC\u0634 \u0628\u0632\u0631\u06AF {name}",
|
||||
"groupCall.unpin": "\u0628\u0627\u0632\u06AF\u0634\u062A \u0628\u0647 \u0647\u0645\u0647",
|
||||
"groupCall.showEveryone": "\u0647\u0645\u0647"
|
||||
},
|
||||
"ur": {
|
||||
"language.label": "\u0632\u0628\u0627\u0646",
|
||||
"language.suggest.text": "\u06CC\u06C1 \u0635\u0641\u062D\u06C1 \u0627\u0631\u062F\u0648 \u0645\u06CC\u06BA \u0628\u06BE\u06CC \u062F\u0633\u062A\u06CC\u0627\u0628 \u06C1\u06D2\u06D4",
|
||||
"language.suggest.cta": "\u0627\u0631\u062F\u0648 \u0645\u06CC\u06BA \u067E\u0691\u06BE\u06CC\u06BA",
|
||||
"language.suggest.dismiss": "\u0628\u0646\u062F \u06A9\u0631\u06CC\u06BA",
|
||||
"community.title": "\u0646\u062C\u06CC\u062A \u06A9\u06D2 \u0645\u0633\u062A\u0642\u0628\u0644 \u0645\u06CC\u06BA \u0634\u0627\u0645\u0644 \u06C1\u0648\u06BA",
|
||||
"community.description": "\u200FSecureBit \u0627\u067E\u0646\u06CC \u0628\u0631\u0627\u062F\u0631\u06CC \u06A9\u06CC \u0628\u062F\u0648\u0644\u062A \u0628\u0691\u06BE\u062A\u0627 \u06C1\u06D2\u06D4 \u0622\u067E \u06A9\u06D2 \u062E\u06CC\u0627\u0644\u0627\u062A \u0627\u0648\u0631 \u0622\u0631\u0627\u0621 \u0645\u062D\u0641\u0648\u0638 \u0631\u0627\u0628\u0637\u06D2 \u06A9\u0627 \u0645\u0633\u062A\u0642\u0628\u0644 \u062A\u0634\u06A9\u06CC\u0644 \u062F\u06CC\u062A\u06D2 \u06C1\u06CC\u06BA - \u06A9\u06BE\u0644\u06D2 \u0639\u0627\u0645 \u0628\u0646\u0627\u06CC\u0627 \u06AF\u06CC\u0627\u060C \u0633\u0631\u06D2 \u0633\u06D2 \u0633\u0631\u06D2 \u062A\u06A9 \u0645\u06A9\u0645\u0644 ASN.1 \u062A\u0648\u062B\u06CC\u0642 \u06A9\u06D2 \u0633\u0627\u062A\u06BE\u06D4",
|
||||
"community.github": "\u200FGitHub \u0645\u062E\u0632\u0646",
|
||||
@@ -14146,7 +14449,29 @@ var DICTIONARIES = {
|
||||
"offline.disconnect": "\u0645\u0646\u0642\u0637\u0639 \u06A9\u0631\u06CC\u06BA",
|
||||
"offline.learnMore": "\u0645\u0632\u06CC\u062F \u062C\u0627\u0646\u06CC\u06BA",
|
||||
"pwa.installApp": "\u0627\u06CC\u067E \u0646\u0635\u0628 \u06A9\u0631\u06CC\u06BA",
|
||||
"chat.onWeb": "\u0622\u067E \u0648\u06CC\u0628 \u067E\u0631 \u06C1\u06CC\u06BA"
|
||||
"chat.onWeb": "\u0622\u067E \u0648\u06CC\u0628 \u067E\u0631 \u06C1\u06CC\u06BA",
|
||||
"groupCall.startVoice": "\u06AF\u0631\u0648\u067E \u0648\u0627\u0626\u0633 \u06A9\u0627\u0644 \u0634\u0631\u0648\u0639 \u06A9\u0631\u06CC\u06BA",
|
||||
"groupCall.startVideo": "\u06AF\u0631\u0648\u067E \u0648\u06CC\u0688\u06CC\u0648 \u06A9\u0627\u0644 \u0634\u0631\u0648\u0639 \u06A9\u0631\u06CC\u06BA",
|
||||
"groupCall.join": "\u0634\u0627\u0645\u0644 \u06C1\u0648\u06BA",
|
||||
"groupCall.dismiss": "\u0627\u0628\u06BE\u06CC \u0646\u06C1\u06CC\u06BA",
|
||||
"groupCall.leave": "\u06A9\u0627\u0644 \u0686\u06BE\u0648\u0691\u06CC\u06BA",
|
||||
"groupCall.you": "\u0622\u067E",
|
||||
"groupCall.connecting": "\u0645\u0646\u0633\u0644\u06A9 \u06C1\u0648 \u0631\u06C1\u0627 \u06C1\u06D2\u2026",
|
||||
"groupCall.waitingLink": "\u0628\u0631\u0627\u06C1\u0650 \u0631\u0627\u0633\u062A \u0631\u0627\u0628\u0637\u06D2 \u06A9\u0627 \u0627\u0646\u062A\u0638\u0627\u0631\u2026",
|
||||
"groupCall.legFailed": "\u0645\u0646\u0633\u0644\u06A9 \u0646\u06C1\u06CC\u06BA \u06C1\u0648 \u0633\u06A9\u0627",
|
||||
"groupCall.startedVoice": "{name} \u0646\u06D2 \u0648\u0627\u0626\u0633 \u06A9\u0627\u0644 \u0634\u0631\u0648\u0639 \u06A9\u06CC",
|
||||
"groupCall.startedVideo": "{name} \u0646\u06D2 \u0648\u06CC\u0688\u06CC\u0648 \u06A9\u0627\u0644 \u0634\u0631\u0648\u0639 \u06A9\u06CC",
|
||||
"groupCall.inCall": "\u06A9\u0627\u0644 \u0645\u06CC\u06BA {count}",
|
||||
"groupCall.err.permission_denied": "\u06A9\u0627\u0644 \u0634\u0631\u0648\u0639 \u0646\u06C1\u06CC\u06BA \u06C1\u0648 \u0633\u06A9\u06CC - \u0645\u0627\u0626\u06CC\u06A9\u0631\u0648\u0641\u0648\u0646 \u0627\u0648\u0631 \u06A9\u06CC\u0645\u0631\u06D2 \u062A\u06A9 \u0631\u0633\u0627\u0626\u06CC \u0628\u0646\u062F \u06C1\u06D2\u06D4 \u0627\u0633 \u0633\u0627\u0626\u0679 \u06A9\u06D2 \u0644\u06CC\u06D2 \u0627\u062C\u0627\u0632\u062A \u062F\u06CC\u06BA \u0627\u0648\u0631 \u062F\u0648\u0628\u0627\u0631\u06C1 \u06A9\u0648\u0634\u0634 \u06A9\u0631\u06CC\u06BA\u06D4",
|
||||
"groupCall.err.device_not_found": "\u06A9\u0627\u0644 \u0634\u0631\u0648\u0639 \u0646\u06C1\u06CC\u06BA \u06C1\u0648 \u0633\u06A9\u06CC - \u0627\u0633 \u0688\u06CC\u0648\u0627\u0626\u0633 \u067E\u0631 \u06A9\u0648\u0626\u06CC \u0645\u0627\u0626\u06CC\u06A9\u0631\u0648\u0641\u0648\u0646 \u0646\u06C1\u06CC\u06BA \u0645\u0644\u0627\u06D4",
|
||||
"groupCall.err.device_busy": "\u06A9\u0627\u0644 \u0634\u0631\u0648\u0639 \u0646\u06C1\u06CC\u06BA \u06C1\u0648 \u0633\u06A9\u06CC - \u0622\u067E \u06A9\u0627 \u0645\u0627\u0626\u06CC\u06A9\u0631\u0648\u0641\u0648\u0646 \u06A9\u0633\u06CC \u0627\u0648\u0631 \u0627\u06CC\u067E \u06A9\u06D2 \u0632\u06CC\u0631\u0650 \u0627\u0633\u062A\u0639\u0645\u0627\u0644 \u06C1\u06D2\u06D4 \u0627\u0633\u06D2 \u0628\u0646\u062F \u06A9\u0631 \u06A9\u06D2 \u062F\u0648\u0628\u0627\u0631\u06C1 \u06A9\u0648\u0634\u0634 \u06A9\u0631\u06CC\u06BA\u06D4",
|
||||
"groupCall.err.media_failed": "\u06A9\u0627\u0644 \u0634\u0631\u0648\u0639 \u0646\u06C1\u06CC\u06BA \u06C1\u0648 \u0633\u06A9\u06CC - \u0645\u0627\u0626\u06CC\u06A9\u0631\u0648\u0641\u0648\u0646 \u0646\u06C1\u06CC\u06BA \u06A9\u06BE\u0644 \u0633\u06A9\u0627\u06D4",
|
||||
"groupCall.err.call_in_progress": "\u0627\u0633 \u06AF\u0631\u0648\u067E \u0645\u06CC\u06BA \u067E\u06C1\u0644\u06D2 \u06C1\u06CC \u0627\u06CC\u06A9 \u06A9\u0627\u0644 \u062C\u0627\u0631\u06CC \u06C1\u06D2\u06D4 \u0646\u0626\u06CC \u0634\u0631\u0648\u0639 \u06A9\u0631\u0646\u06D2 \u06A9\u06D2 \u0628\u062C\u0627\u0626\u06D2 \u0627\u0633 \u0645\u06CC\u06BA \u0634\u0627\u0645\u0644 \u06C1\u0648\u06BA\u06D4",
|
||||
"groupCall.err.not_ready": "\u06A9\u0627\u0644 \u0633\u06D2 \u067E\u06C1\u0644\u06D2 \u06AF\u0631\u0648\u067E \u06A9\u0648\u0688 \u06A9\u06CC \u062A\u0635\u062F\u06CC\u0642 \u06A9\u0631\u06CC\u06BA\u06D4",
|
||||
"groupCall.speaking": "{name} \u0628\u0648\u0644 \u0631\u06C1\u06D2 \u06C1\u06CC\u06BA",
|
||||
"groupCall.pin": "{name} \u06A9\u0648 \u0628\u0691\u0627 \u062F\u06A9\u06BE\u0627\u0626\u06CC\u06BA",
|
||||
"groupCall.unpin": "\u0633\u0628 \u067E\u0631 \u0648\u0627\u067E\u0633 \u062C\u0627\u0626\u06CC\u06BA",
|
||||
"groupCall.showEveryone": "\u0633\u0628"
|
||||
}
|
||||
};
|
||||
|
||||
@@ -18486,6 +18811,8 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
|
||||
callId: null,
|
||||
quality: null,
|
||||
// 'excellent'|'good'|'fair'|'poor'|null — link quality for the UI
|
||||
groupCallId: null,
|
||||
// set while this call is one leg of a group call
|
||||
error: null
|
||||
};
|
||||
this.localMediaStream = null;
|
||||
@@ -18496,6 +18823,9 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
|
||||
this._callVideoSender = null;
|
||||
this._callFacingMode = "user";
|
||||
this._adaptationController = null;
|
||||
this._callGroupContext = null;
|
||||
this._externalMediaStream = null;
|
||||
this._callStateListeners = /* @__PURE__ */ new Set();
|
||||
this.keyRotationInterval = null;
|
||||
this.lastKeyRotation = Date.now();
|
||||
this.currentKeyVersion = 0;
|
||||
@@ -29219,6 +29549,9 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
|
||||
}
|
||||
this._callAudioSender = null;
|
||||
this._callVideoSender = null;
|
||||
this._callGroupContext = null;
|
||||
this._externalMediaStream = null;
|
||||
this._callStateListeners?.clear?.();
|
||||
this.intentionalDisconnect = true;
|
||||
window.EnhancedSecureCryptoUtils.secureLog.log("info", "Starting intentional disconnect");
|
||||
this.sendDisconnectNotification();
|
||||
@@ -29740,6 +30073,57 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
|
||||
getCallState() {
|
||||
return { ...this.callState };
|
||||
}
|
||||
/**
|
||||
* Subscribe to call-state changes without taking the onCallStateChanged slot.
|
||||
* @param {(state: object) => void} listener
|
||||
* @returns {() => void} unsubscribe
|
||||
*/
|
||||
addCallStateListener(listener) {
|
||||
if (typeof listener !== "function") return () => {
|
||||
};
|
||||
this._callStateListeners.add(listener);
|
||||
return () => this._callStateListeners.delete(listener);
|
||||
}
|
||||
removeCallStateListener(listener) {
|
||||
this._callStateListeners.delete(listener);
|
||||
}
|
||||
/**
|
||||
* Mark this session as one leg of a group call, or clear it.
|
||||
*
|
||||
* While set, an inbound call offer is answered without ringing. That is safe
|
||||
* only because of who sets it: the group-call controller, and only while the
|
||||
* local user is in that group's call. Nothing on the wire can set it, so a
|
||||
* peer cannot cause this session to open a microphone on its own.
|
||||
*
|
||||
* @param {string|null} groupCallId
|
||||
*/
|
||||
setCallGroupContext(groupCallId) {
|
||||
this._callGroupContext = typeof groupCallId === "string" && groupCallId ? groupCallId : null;
|
||||
this._updateCallState({});
|
||||
if (this._callGroupContext && this._pendingCallOffer && this.callState.phase === "incoming") {
|
||||
this.acceptCall().catch((error) => {
|
||||
this._secureLog("warn", "\u26A0\uFE0F Failed to answer a group call leg that was already ringing", {
|
||||
errorType: error?.constructor?.name
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Use a capture owned by somebody else for the next call on this session.
|
||||
*
|
||||
* The group-call controller captures the microphone and camera ONCE and
|
||||
* hands the same stream to every leg. This manager attaches its tracks and
|
||||
* must never stop them: the other legs are still sending them.
|
||||
*
|
||||
* @param {MediaStream|null} stream
|
||||
*/
|
||||
setExternalMediaStream(stream) {
|
||||
this._externalMediaStream = stream || null;
|
||||
}
|
||||
/** True when the live capture belongs to somebody else. */
|
||||
_usingExternalMedia() {
|
||||
return !!this._externalMediaStream && this.localMediaStream === this._externalMediaStream;
|
||||
}
|
||||
getRemoteMediaStream() {
|
||||
return this.remoteMediaStream;
|
||||
}
|
||||
@@ -29779,7 +30163,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
|
||||
}, 1200);
|
||||
}
|
||||
_updateCallState(patch) {
|
||||
this.callState = { ...this.callState, ...patch };
|
||||
this.callState = { ...this.callState, ...patch, groupCallId: this._callGroupContext };
|
||||
const snapshot = this.getCallState();
|
||||
if (snapshot.phase === "active") this._startAdaptation();
|
||||
else if (snapshot.phase === "idle") this._stopAdaptation();
|
||||
@@ -29787,6 +30171,12 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
|
||||
this.onCallStateChanged?.(snapshot);
|
||||
} catch (_) {
|
||||
}
|
||||
for (const listener of this._callStateListeners) {
|
||||
try {
|
||||
listener(snapshot);
|
||||
} catch (_) {
|
||||
}
|
||||
}
|
||||
if (typeof document !== "undefined") {
|
||||
try {
|
||||
this._dispatchAppEvent?.(new CustomEvent("securebit-call-state", {
|
||||
@@ -29813,7 +30203,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
|
||||
return { facingMode: this._callFacingMode, width: { ideal: 1280 }, height: { ideal: 720 } };
|
||||
}
|
||||
async _acquireLocalMedia(withVideo) {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
const stream = this._externalMediaStream || await navigator.mediaDevices.getUserMedia({
|
||||
audio: this._audioConstraints(),
|
||||
video: withVideo ? this._videoConstraints() : false
|
||||
});
|
||||
@@ -29835,7 +30225,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
|
||||
// Fully release the mic/camera — only on session disconnect, not between calls.
|
||||
_stopLocalMediaPermanently() {
|
||||
try {
|
||||
if (this.localMediaStream) {
|
||||
if (this.localMediaStream && !this._usingExternalMedia()) {
|
||||
for (const t2 of this.localMediaStream.getTracks()) {
|
||||
try {
|
||||
t2.stop();
|
||||
@@ -30011,6 +30401,9 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
|
||||
remoteHasVideo: !!data.withVideo,
|
||||
error: null
|
||||
});
|
||||
if (this._callGroupContext) {
|
||||
await this.acceptCall();
|
||||
}
|
||||
}
|
||||
async _answerCallOffer(data, renegotiation = false) {
|
||||
await this.peerConnection.setRemoteDescription({ type: "offer", sdp: data.sdp });
|
||||
@@ -30078,7 +30471,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
|
||||
this._stopAdaptation();
|
||||
const pc = this.peerConnection;
|
||||
try {
|
||||
if (this.localMediaStream) {
|
||||
if (this.localMediaStream && !this._usingExternalMedia()) {
|
||||
for (const track of this.localMediaStream.getTracks()) {
|
||||
try {
|
||||
track.stop();
|
||||
@@ -30150,17 +30543,44 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
|
||||
const camStream = await navigator.mediaDevices.getUserMedia({ video: this._videoConstraints() });
|
||||
const videoTrack = camStream.getVideoTracks()[0];
|
||||
if (!videoTrack) return;
|
||||
this.localMediaStream.addTrack(videoTrack);
|
||||
if (this._callVideoSender) await this._callVideoSender.replaceTrack(videoTrack);
|
||||
else this._callVideoSender = this.peerConnection.addTrack(videoTrack, this.localMediaStream);
|
||||
this._applyCallCodecPrefs();
|
||||
this._updateCallState({ cameraEnabled: true, withVideo: true });
|
||||
await this._renegotiateCall();
|
||||
await this.addVideoTrack(videoTrack);
|
||||
} catch (error) {
|
||||
this._secureLog("error", "\u274C upgradeToVideo failed", { errorType: error?.constructor?.name });
|
||||
this._updateCallState({ cameraEnabled: false, error: "camera_failed" });
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Put an already-captured camera track on this call and renegotiate.
|
||||
*
|
||||
* Split out of upgradeToVideo so a group call can capture its camera once
|
||||
* and add the SAME track to every leg — N captures of one camera is both
|
||||
* wasteful and, on several devices, simply not permitted.
|
||||
*
|
||||
* @param {MediaStreamTrack} videoTrack
|
||||
*/
|
||||
async addVideoTrack(videoTrack) {
|
||||
if (!videoTrack || !this.localMediaStream || !this.peerConnection) return;
|
||||
if (!this.localMediaStream.getVideoTracks().includes(videoTrack)) {
|
||||
this.localMediaStream.addTrack(videoTrack);
|
||||
}
|
||||
if (this._callVideoSender) await this._callVideoSender.replaceTrack(videoTrack);
|
||||
else this._callVideoSender = this.peerConnection.addTrack(videoTrack, this.localMediaStream);
|
||||
this._applyCallCodecPrefs();
|
||||
this._updateCallState({ cameraEnabled: true, withVideo: true });
|
||||
await this._renegotiateCall();
|
||||
}
|
||||
/**
|
||||
* Swap the outgoing camera track without renegotiating.
|
||||
*
|
||||
* The group's camera flip captures one new track and hands it to every leg;
|
||||
* replaceTrack keeps the transceiver, so no leg has to renegotiate for it.
|
||||
*
|
||||
* @param {MediaStreamTrack} videoTrack
|
||||
*/
|
||||
async replaceVideoTrack(videoTrack) {
|
||||
if (!videoTrack || !this._callVideoSender) return;
|
||||
await this._callVideoSender.replaceTrack(videoTrack);
|
||||
}
|
||||
// Flip between front/back cameras without renegotiation (replaceTrack).
|
||||
async switchCamera() {
|
||||
if (!this._callVideoSender || !this.localMediaStream) return;
|
||||
@@ -31264,7 +31684,7 @@ var SecureMasterKeyManager = class {
|
||||
var import_NotificationIntegration = __toESM(require_NotificationIntegration());
|
||||
|
||||
// package.json
|
||||
var version = "6.4.0";
|
||||
var version = "6.6.6";
|
||||
|
||||
// src/components/ui/LanguageSwitcher.jsx
|
||||
var LanguageSwitcher = () => {
|
||||
@@ -31815,8 +32235,9 @@ Right-click or Ctrl+click to disconnect`,
|
||||
}, []);
|
||||
const GLASS = "blur(20px) saturate(180%)";
|
||||
const MATERIALISE = "background .25s ease, backdrop-filter .25s ease, -webkit-backdrop-filter .25s ease, border-color .25s ease";
|
||||
const overlay = { position: "fixed", top: 0, left: 0, right: 0 };
|
||||
const headerStyle = onLanding ? scrolled ? { ...overlay, background: "rgba(15,15,17,0.72)", backdropFilter: GLASS, WebkitBackdropFilter: GLASS, borderBottom: "1px solid rgba(255,255,255,0.06)", transition: MATERIALISE } : { ...overlay, background: "transparent", backdropFilter: "blur(0px) saturate(100%)", WebkitBackdropFilter: "blur(0px) saturate(100%)", borderBottom: "1px solid transparent", transition: MATERIALISE } : { background: "rgba(18,18,20,0.72)", backdropFilter: GLASS, WebkitBackdropFilter: GLASS, borderBottom: "1px solid rgba(255,255,255,0.06)" };
|
||||
const safeTop = { paddingTop: "var(--sb-safe-top, 0px)", paddingBottom: "var(--sb-bar-extra, 0px)" };
|
||||
const overlay = { position: "fixed", top: 0, left: 0, right: 0, ...safeTop };
|
||||
const headerStyle = onLanding ? scrolled ? { ...overlay, background: "rgba(15,15,17,0.72)", backdropFilter: GLASS, WebkitBackdropFilter: GLASS, borderBottom: "1px solid rgba(255,255,255,0.06)", transition: MATERIALISE } : { ...overlay, background: "transparent", backdropFilter: "blur(0px) saturate(100%)", WebkitBackdropFilter: "blur(0px) saturate(100%)", borderBottom: "1px solid transparent", transition: MATERIALISE } : { ...safeTop, background: "rgba(18,18,20,0.72)", backdropFilter: GLASS, WebkitBackdropFilter: GLASS, borderBottom: "1px solid rgba(255,255,255,0.06)" };
|
||||
return React.createElement("header", {
|
||||
className: onLanding ? "header-minimal z-50" : "header-minimal sticky top-0 z-50",
|
||||
style: headerStyle
|
||||
@@ -31829,7 +32250,7 @@ Right-click or Ctrl+click to disconnect`,
|
||||
React.createElement("div", {
|
||||
key: "content",
|
||||
className: "flex items-center justify-between",
|
||||
style: { height: "64px", gap: "16px" }
|
||||
style: { height: "var(--sb-bar-h, 64px)", gap: "16px" }
|
||||
}, [
|
||||
// Left: logo + wordmark
|
||||
React.createElement("div", { key: "left", style: { display: "flex", alignItems: "center", gap: "12px", minWidth: 0 } }, [
|
||||
@@ -33584,6 +34005,7 @@ var CallUIComponent = ({ webrtcManager, peerTitle }) => {
|
||||
if (phase === "idle") setMinimized(false);
|
||||
}, [phase]);
|
||||
if (!active || phase === "idle" || phase === "ended") return null;
|
||||
if (call.groupCallId) return null;
|
||||
const fmt = (s) => `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`;
|
||||
const ringing = phase === "outgoing" || phase === "connecting";
|
||||
const callStatus = phase === "outgoing" ? t("call.ringing") : phase === "connecting" ? t("call.connecting") : phase === "active" ? fmt(seconds) : t("call.ringing");
|
||||
|
||||
Vendored
+2
-2
File diff suppressed because one or more lines are too long
Vendored
+2777
-354
File diff suppressed because it is too large
Load Diff
Vendored
+4
-4
File diff suppressed because one or more lines are too long
+41
-2
@@ -7,7 +7,7 @@ this document describes.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Release | v6.4.0 |
|
||||
| Release | v6.6.6 |
|
||||
| Protocol version | 4.1 |
|
||||
| Ratchet wire version | 1 |
|
||||
|
||||
@@ -306,7 +306,46 @@ from the shared secret and nobody else can, so a probe replayed onto a different
|
||||
chat does not verify — which is what stops a member claiming to be someone else
|
||||
and receiving their group traffic.
|
||||
|
||||
### Group calls
|
||||
|
||||
A group call is not a conference. It is N-1 ordinary 1:1 calls, one to each other
|
||||
member, each carried by that member's own pairwise session — so every leg's media
|
||||
rides a DTLS-SRTP transport that a human already authenticated by comparing the
|
||||
pairwise safety code. There is no mixer and no selective forwarding unit, and at
|
||||
no point does two members' media meet anywhere but on a device.
|
||||
|
||||
Call control is separate from call media, because the two can reach different
|
||||
sets of people. Control — a call was opened, a member joined, a member left —
|
||||
travels as group frames and therefore reaches members who are currently reachable
|
||||
only through a relay; media flows only where a direct link exists, so a member
|
||||
without one is shown as connecting rather than omitted.
|
||||
|
||||
Each control frame is signed with the sender's group identity key over group id,
|
||||
epoch, call id, action, sender fingerprint, a per-sender sequence number and
|
||||
whether the call carries video. The signature is what makes relaying these frames
|
||||
safe: a relaying member can refuse to carry one — the availability cost relaying
|
||||
always has — but cannot add a member to a call, remove one, or end a call on
|
||||
somebody else's behalf. The sequence number is checked before the action is
|
||||
considered, so a captured frame cannot be replayed to drag a member back into a
|
||||
call they left or to close one that is running. Frames are refused outright
|
||||
unless the group is READY and its code confirmed.
|
||||
|
||||
Call ids are 16 random bytes. Two members opening a call in the same instant
|
||||
therefore produce two ids, and every member resolves it identically by keeping
|
||||
the lower one — nothing is negotiated and no member arbitrates. Which side of a
|
||||
pair places its leg is the mesh's rule again: the smaller fingerprint dials, the
|
||||
other answers.
|
||||
|
||||
A leg answers without prompting the user, and that is the one place a call is
|
||||
opened without a per-call confirmation. The flag permitting it is set only by the
|
||||
local group-call controller, only while this user is in that call, and is cleared
|
||||
when they leave; nothing arriving on the wire can set it. The alternative —
|
||||
prompting once per member — would be seven prompts for one decision and would
|
||||
train users to accept them. A single capture is opened when the user joins,
|
||||
shared across every leg, and stopped when the call, the group or the tab ends;
|
||||
no leg may stop a capture it borrowed.
|
||||
|
||||
## Scope
|
||||
|
||||
This describes the browser implementation as it stands in v6.4.0. It is not a
|
||||
This describes the browser implementation as it stands in v6.6.6. It is not a
|
||||
substitute for independent cryptographic review.
|
||||
|
||||
+24
-24
@@ -30,7 +30,7 @@
|
||||
|
||||
<!-- PWA Manifest -->
|
||||
<link rel="manifest" href="/es/manifest.json">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788040116230">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788237895492">
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
@@ -96,7 +96,7 @@
|
||||
<link rel="apple-touch-startup-image" media="screen and (device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/logo/splash/splash_screens/8.3__iPad_Mini_portrait.png">
|
||||
|
||||
<!-- Apple Touch Icons -->
|
||||
<link rel="apple-touch-icon" href="/logo/icon-180x180.png?v=1788040116230">
|
||||
<link rel="apple-touch-icon" href="/logo/icon-180x180.png?v=1788237895492">
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="/logo/icon-57x57.png">
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="/logo/icon-60x60.png">
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="/logo/icon-72x72.png">
|
||||
@@ -105,7 +105,7 @@
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="/logo/icon-120x120.png">
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="/logo/icon-144x144.png">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="/logo/icon-152x152.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/logo/icon-180x180.png?v=1788040116230">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/logo/icon-180x180.png?v=1788237895492">
|
||||
|
||||
<!-- Microsoft Tiles -->
|
||||
<meta name="msapplication-TileColor" content="#ff6b35">
|
||||
@@ -222,7 +222,7 @@
|
||||
<!-- Render-blocking JS is deferred: classic deferred scripts and module scripts
|
||||
both execute in document order after parsing, so React still runs before the
|
||||
app modules below, but the parser / first paint is no longer blocked. -->
|
||||
<script defer src="/config/ice-servers.js?v=1788040116230"></script>
|
||||
<script defer src="/config/ice-servers.js?v=1788237895492"></script>
|
||||
<script defer src="/libs/react/react.production.min.js"></script>
|
||||
<script defer src="/libs/react-dom/react-dom.production.min.js"></script>
|
||||
<!-- Prism syntax highlighting (vendored, offline). Tokenizes code as TEXT only —
|
||||
@@ -230,8 +230,8 @@
|
||||
Its CSS is loaded async via load-async-css.js (not paint-critical). -->
|
||||
<script defer src="/libs/prism/prism.js"></script>
|
||||
<!-- Critical, paint-defining CSS stays render-blocking (avoids FOUC / layout shift). -->
|
||||
<link rel="stylesheet" href="/assets/tailwind.css?v=1788040116230">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788040116230">
|
||||
<link rel="stylesheet" href="/assets/tailwind.css?v=1788237895492">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788237895492">
|
||||
<!-- Preload only the fonts needed for first paint. fa-solid covers the bulk of UI
|
||||
icons; fa-regular/fa-brands are loaded on demand by their CSS (rarely on the
|
||||
first screen). Inter latin 400/700 cover body text and headings/buttons. -->
|
||||
@@ -239,38 +239,38 @@
|
||||
<link rel="preload" href="/assets/fonts/inter/files/inter-latin-400.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="/assets/fonts/inter/files/inter-latin-700.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="stylesheet" href="/assets/fonts/inter/inter.css">
|
||||
<link rel="stylesheet" href="/src/styles/main.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/animations.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/components.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/main.css?v=1788237895492">
|
||||
<link rel="stylesheet" href="/src/styles/animations.css?v=1788237895492">
|
||||
<link rel="stylesheet" href="/src/styles/components.css?v=1788237895492">
|
||||
<!-- Loads after components.css on purpose: press feedback, material weight,
|
||||
size-specific tracking and the reduced-motion / -transparency / -contrast
|
||||
answers all need to settle arguments with the sheets above on source order. -->
|
||||
<link rel="stylesheet" href="/src/styles/apple-motion.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/apple-motion.css?v=1788237895492">
|
||||
<!-- Last of the hand-written sheets: the mirroring rules must win over anything
|
||||
above them, and they only ever apply under [dir="rtl"]. -->
|
||||
<link rel="stylesheet" href="/src/styles/rtl.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/rtl.css?v=1788237895492">
|
||||
<!-- Non-critical CSS (FontAwesome ~102KB, Prism) loaded async — no longer blocks paint. -->
|
||||
<script defer src="/src/scripts/load-async-css.js?v=1788040116230"></script>
|
||||
<script defer src="/src/scripts/load-async-css.js?v=1788237895492"></script>
|
||||
<noscript>
|
||||
<link rel="stylesheet" href="/assets/fontawesome/css/all.min.css">
|
||||
<link rel="stylesheet" href="/libs/prism/prism.css">
|
||||
</noscript>
|
||||
<script defer src="/src/scripts/fa-check.js?v=1788040116230"></script>
|
||||
<script defer src="/src/scripts/fa-check.js?v=1788237895492"></script>
|
||||
<!-- Update Manager - система принудительного обновления -->
|
||||
<script defer src="/src/utils/updateManager.js?v=1788040116230"></script>
|
||||
<script type="module" src="/src/components/UpdateChecker.jsx?v=1788040116230"></script>
|
||||
<script type="module" src="/dist/qr-local.js?v=1788040116230"></script>
|
||||
<script type="module" src="/src/components/QRScanner.js?v=1788040116230"></script>
|
||||
<script defer src="/src/utils/updateManager.js?v=1788237895492"></script>
|
||||
<script type="module" src="/src/components/UpdateChecker.jsx?v=1788237895492"></script>
|
||||
<script type="module" src="/dist/qr-local.js?v=1788237895492"></script>
|
||||
<script type="module" src="/src/components/QRScanner.js?v=1788237895492"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/dist/app-boot.js?v=1788040116230"></script>
|
||||
<script type="module" src="/dist/app.js?v=1788040116230"></script>
|
||||
<script type="module" src="/dist/app-boot.js?v=1788237895492"></script>
|
||||
<script type="module" src="/dist/app.js?v=1788237895492"></script>
|
||||
|
||||
<script defer src="/src/scripts/pwa-register.js?v=1788040116230"></script>
|
||||
<script src="/src/pwa/install-prompt.js?v=1788040116230" type="module"></script>
|
||||
<script src="/src/pwa/pwa-manager.js?v=1788040116230" type="module"></script>
|
||||
<script defer src="/src/scripts/pwa-offline-test.js?v=1788040116230"></script>
|
||||
<link rel="stylesheet" href="/src/styles/pwa.css?v=1788040116230">
|
||||
<script defer src="/src/scripts/pwa-register.js?v=1788237895492"></script>
|
||||
<script src="/src/pwa/install-prompt.js?v=1788237895492" type="module"></script>
|
||||
<script src="/src/pwa/pwa-manager.js?v=1788237895492" type="module"></script>
|
||||
<script defer src="/src/scripts/pwa-offline-test.js?v=1788237895492"></script>
|
||||
<link rel="stylesheet" href="/src/styles/pwa.css?v=1788237895492">
|
||||
</body>
|
||||
</html>
|
||||
+24
-24
@@ -30,7 +30,7 @@
|
||||
|
||||
<!-- PWA Manifest -->
|
||||
<link rel="manifest" href="/fa/manifest.json">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788040116230">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788237895492">
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
@@ -96,7 +96,7 @@
|
||||
<link rel="apple-touch-startup-image" media="screen and (device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/logo/splash/splash_screens/8.3__iPad_Mini_portrait.png">
|
||||
|
||||
<!-- Apple Touch Icons -->
|
||||
<link rel="apple-touch-icon" href="/logo/icon-180x180.png?v=1788040116230">
|
||||
<link rel="apple-touch-icon" href="/logo/icon-180x180.png?v=1788237895492">
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="/logo/icon-57x57.png">
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="/logo/icon-60x60.png">
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="/logo/icon-72x72.png">
|
||||
@@ -105,7 +105,7 @@
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="/logo/icon-120x120.png">
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="/logo/icon-144x144.png">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="/logo/icon-152x152.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/logo/icon-180x180.png?v=1788040116230">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/logo/icon-180x180.png?v=1788237895492">
|
||||
|
||||
<!-- Microsoft Tiles -->
|
||||
<meta name="msapplication-TileColor" content="#ff6b35">
|
||||
@@ -222,7 +222,7 @@
|
||||
<!-- Render-blocking JS is deferred: classic deferred scripts and module scripts
|
||||
both execute in document order after parsing, so React still runs before the
|
||||
app modules below, but the parser / first paint is no longer blocked. -->
|
||||
<script defer src="/config/ice-servers.js?v=1788040116230"></script>
|
||||
<script defer src="/config/ice-servers.js?v=1788237895492"></script>
|
||||
<script defer src="/libs/react/react.production.min.js"></script>
|
||||
<script defer src="/libs/react-dom/react-dom.production.min.js"></script>
|
||||
<!-- Prism syntax highlighting (vendored, offline). Tokenizes code as TEXT only —
|
||||
@@ -230,8 +230,8 @@
|
||||
Its CSS is loaded async via load-async-css.js (not paint-critical). -->
|
||||
<script defer src="/libs/prism/prism.js"></script>
|
||||
<!-- Critical, paint-defining CSS stays render-blocking (avoids FOUC / layout shift). -->
|
||||
<link rel="stylesheet" href="/assets/tailwind.css?v=1788040116230">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788040116230">
|
||||
<link rel="stylesheet" href="/assets/tailwind.css?v=1788237895492">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788237895492">
|
||||
<!-- Preload only the fonts needed for first paint. fa-solid covers the bulk of UI
|
||||
icons; fa-regular/fa-brands are loaded on demand by their CSS (rarely on the
|
||||
first screen). Inter latin 400/700 cover body text and headings/buttons. -->
|
||||
@@ -239,38 +239,38 @@
|
||||
<link rel="preload" href="/assets/fonts/inter/files/inter-latin-400.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="/assets/fonts/inter/files/inter-latin-700.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="stylesheet" href="/assets/fonts/inter/inter.css">
|
||||
<link rel="stylesheet" href="/src/styles/main.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/animations.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/components.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/main.css?v=1788237895492">
|
||||
<link rel="stylesheet" href="/src/styles/animations.css?v=1788237895492">
|
||||
<link rel="stylesheet" href="/src/styles/components.css?v=1788237895492">
|
||||
<!-- Loads after components.css on purpose: press feedback, material weight,
|
||||
size-specific tracking and the reduced-motion / -transparency / -contrast
|
||||
answers all need to settle arguments with the sheets above on source order. -->
|
||||
<link rel="stylesheet" href="/src/styles/apple-motion.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/apple-motion.css?v=1788237895492">
|
||||
<!-- Last of the hand-written sheets: the mirroring rules must win over anything
|
||||
above them, and they only ever apply under [dir="rtl"]. -->
|
||||
<link rel="stylesheet" href="/src/styles/rtl.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/rtl.css?v=1788237895492">
|
||||
<!-- Non-critical CSS (FontAwesome ~102KB, Prism) loaded async — no longer blocks paint. -->
|
||||
<script defer src="/src/scripts/load-async-css.js?v=1788040116230"></script>
|
||||
<script defer src="/src/scripts/load-async-css.js?v=1788237895492"></script>
|
||||
<noscript>
|
||||
<link rel="stylesheet" href="/assets/fontawesome/css/all.min.css">
|
||||
<link rel="stylesheet" href="/libs/prism/prism.css">
|
||||
</noscript>
|
||||
<script defer src="/src/scripts/fa-check.js?v=1788040116230"></script>
|
||||
<script defer src="/src/scripts/fa-check.js?v=1788237895492"></script>
|
||||
<!-- Update Manager - система принудительного обновления -->
|
||||
<script defer src="/src/utils/updateManager.js?v=1788040116230"></script>
|
||||
<script type="module" src="/src/components/UpdateChecker.jsx?v=1788040116230"></script>
|
||||
<script type="module" src="/dist/qr-local.js?v=1788040116230"></script>
|
||||
<script type="module" src="/src/components/QRScanner.js?v=1788040116230"></script>
|
||||
<script defer src="/src/utils/updateManager.js?v=1788237895492"></script>
|
||||
<script type="module" src="/src/components/UpdateChecker.jsx?v=1788237895492"></script>
|
||||
<script type="module" src="/dist/qr-local.js?v=1788237895492"></script>
|
||||
<script type="module" src="/src/components/QRScanner.js?v=1788237895492"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/dist/app-boot.js?v=1788040116230"></script>
|
||||
<script type="module" src="/dist/app.js?v=1788040116230"></script>
|
||||
<script type="module" src="/dist/app-boot.js?v=1788237895492"></script>
|
||||
<script type="module" src="/dist/app.js?v=1788237895492"></script>
|
||||
|
||||
<script defer src="/src/scripts/pwa-register.js?v=1788040116230"></script>
|
||||
<script src="/src/pwa/install-prompt.js?v=1788040116230" type="module"></script>
|
||||
<script src="/src/pwa/pwa-manager.js?v=1788040116230" type="module"></script>
|
||||
<script defer src="/src/scripts/pwa-offline-test.js?v=1788040116230"></script>
|
||||
<link rel="stylesheet" href="/src/styles/pwa.css?v=1788040116230">
|
||||
<script defer src="/src/scripts/pwa-register.js?v=1788237895492"></script>
|
||||
<script src="/src/pwa/install-prompt.js?v=1788237895492" type="module"></script>
|
||||
<script src="/src/pwa/pwa-manager.js?v=1788237895492" type="module"></script>
|
||||
<script defer src="/src/scripts/pwa-offline-test.js?v=1788237895492"></script>
|
||||
<link rel="stylesheet" href="/src/styles/pwa.css?v=1788237895492">
|
||||
</body>
|
||||
</html>
|
||||
+24
-24
@@ -30,7 +30,7 @@
|
||||
|
||||
<!-- PWA Manifest -->
|
||||
<link rel="manifest" href="/fr/manifest.json">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788040116230">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788237895492">
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
@@ -96,7 +96,7 @@
|
||||
<link rel="apple-touch-startup-image" media="screen and (device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/logo/splash/splash_screens/8.3__iPad_Mini_portrait.png">
|
||||
|
||||
<!-- Apple Touch Icons -->
|
||||
<link rel="apple-touch-icon" href="/logo/icon-180x180.png?v=1788040116230">
|
||||
<link rel="apple-touch-icon" href="/logo/icon-180x180.png?v=1788237895492">
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="/logo/icon-57x57.png">
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="/logo/icon-60x60.png">
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="/logo/icon-72x72.png">
|
||||
@@ -105,7 +105,7 @@
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="/logo/icon-120x120.png">
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="/logo/icon-144x144.png">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="/logo/icon-152x152.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/logo/icon-180x180.png?v=1788040116230">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/logo/icon-180x180.png?v=1788237895492">
|
||||
|
||||
<!-- Microsoft Tiles -->
|
||||
<meta name="msapplication-TileColor" content="#ff6b35">
|
||||
@@ -222,7 +222,7 @@
|
||||
<!-- Render-blocking JS is deferred: classic deferred scripts and module scripts
|
||||
both execute in document order after parsing, so React still runs before the
|
||||
app modules below, but the parser / first paint is no longer blocked. -->
|
||||
<script defer src="/config/ice-servers.js?v=1788040116230"></script>
|
||||
<script defer src="/config/ice-servers.js?v=1788237895492"></script>
|
||||
<script defer src="/libs/react/react.production.min.js"></script>
|
||||
<script defer src="/libs/react-dom/react-dom.production.min.js"></script>
|
||||
<!-- Prism syntax highlighting (vendored, offline). Tokenizes code as TEXT only —
|
||||
@@ -230,8 +230,8 @@
|
||||
Its CSS is loaded async via load-async-css.js (not paint-critical). -->
|
||||
<script defer src="/libs/prism/prism.js"></script>
|
||||
<!-- Critical, paint-defining CSS stays render-blocking (avoids FOUC / layout shift). -->
|
||||
<link rel="stylesheet" href="/assets/tailwind.css?v=1788040116230">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788040116230">
|
||||
<link rel="stylesheet" href="/assets/tailwind.css?v=1788237895492">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788237895492">
|
||||
<!-- Preload only the fonts needed for first paint. fa-solid covers the bulk of UI
|
||||
icons; fa-regular/fa-brands are loaded on demand by their CSS (rarely on the
|
||||
first screen). Inter latin 400/700 cover body text and headings/buttons. -->
|
||||
@@ -239,38 +239,38 @@
|
||||
<link rel="preload" href="/assets/fonts/inter/files/inter-latin-400.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="/assets/fonts/inter/files/inter-latin-700.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="stylesheet" href="/assets/fonts/inter/inter.css">
|
||||
<link rel="stylesheet" href="/src/styles/main.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/animations.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/components.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/main.css?v=1788237895492">
|
||||
<link rel="stylesheet" href="/src/styles/animations.css?v=1788237895492">
|
||||
<link rel="stylesheet" href="/src/styles/components.css?v=1788237895492">
|
||||
<!-- Loads after components.css on purpose: press feedback, material weight,
|
||||
size-specific tracking and the reduced-motion / -transparency / -contrast
|
||||
answers all need to settle arguments with the sheets above on source order. -->
|
||||
<link rel="stylesheet" href="/src/styles/apple-motion.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/apple-motion.css?v=1788237895492">
|
||||
<!-- Last of the hand-written sheets: the mirroring rules must win over anything
|
||||
above them, and they only ever apply under [dir="rtl"]. -->
|
||||
<link rel="stylesheet" href="/src/styles/rtl.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/rtl.css?v=1788237895492">
|
||||
<!-- Non-critical CSS (FontAwesome ~102KB, Prism) loaded async — no longer blocks paint. -->
|
||||
<script defer src="/src/scripts/load-async-css.js?v=1788040116230"></script>
|
||||
<script defer src="/src/scripts/load-async-css.js?v=1788237895492"></script>
|
||||
<noscript>
|
||||
<link rel="stylesheet" href="/assets/fontawesome/css/all.min.css">
|
||||
<link rel="stylesheet" href="/libs/prism/prism.css">
|
||||
</noscript>
|
||||
<script defer src="/src/scripts/fa-check.js?v=1788040116230"></script>
|
||||
<script defer src="/src/scripts/fa-check.js?v=1788237895492"></script>
|
||||
<!-- Update Manager - система принудительного обновления -->
|
||||
<script defer src="/src/utils/updateManager.js?v=1788040116230"></script>
|
||||
<script type="module" src="/src/components/UpdateChecker.jsx?v=1788040116230"></script>
|
||||
<script type="module" src="/dist/qr-local.js?v=1788040116230"></script>
|
||||
<script type="module" src="/src/components/QRScanner.js?v=1788040116230"></script>
|
||||
<script defer src="/src/utils/updateManager.js?v=1788237895492"></script>
|
||||
<script type="module" src="/src/components/UpdateChecker.jsx?v=1788237895492"></script>
|
||||
<script type="module" src="/dist/qr-local.js?v=1788237895492"></script>
|
||||
<script type="module" src="/src/components/QRScanner.js?v=1788237895492"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/dist/app-boot.js?v=1788040116230"></script>
|
||||
<script type="module" src="/dist/app.js?v=1788040116230"></script>
|
||||
<script type="module" src="/dist/app-boot.js?v=1788237895492"></script>
|
||||
<script type="module" src="/dist/app.js?v=1788237895492"></script>
|
||||
|
||||
<script defer src="/src/scripts/pwa-register.js?v=1788040116230"></script>
|
||||
<script src="/src/pwa/install-prompt.js?v=1788040116230" type="module"></script>
|
||||
<script src="/src/pwa/pwa-manager.js?v=1788040116230" type="module"></script>
|
||||
<script defer src="/src/scripts/pwa-offline-test.js?v=1788040116230"></script>
|
||||
<link rel="stylesheet" href="/src/styles/pwa.css?v=1788040116230">
|
||||
<script defer src="/src/scripts/pwa-register.js?v=1788237895492"></script>
|
||||
<script src="/src/pwa/install-prompt.js?v=1788237895492" type="module"></script>
|
||||
<script src="/src/pwa/pwa-manager.js?v=1788237895492" type="module"></script>
|
||||
<script defer src="/src/scripts/pwa-offline-test.js?v=1788237895492"></script>
|
||||
<link rel="stylesheet" href="/src/styles/pwa.css?v=1788237895492">
|
||||
</body>
|
||||
</html>
|
||||
+24
-24
@@ -30,7 +30,7 @@
|
||||
|
||||
<!-- PWA Manifest -->
|
||||
<link rel="manifest" href="/he/manifest.json">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788040116230">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788237895492">
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
@@ -96,7 +96,7 @@
|
||||
<link rel="apple-touch-startup-image" media="screen and (device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/logo/splash/splash_screens/8.3__iPad_Mini_portrait.png">
|
||||
|
||||
<!-- Apple Touch Icons -->
|
||||
<link rel="apple-touch-icon" href="/logo/icon-180x180.png?v=1788040116230">
|
||||
<link rel="apple-touch-icon" href="/logo/icon-180x180.png?v=1788237895492">
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="/logo/icon-57x57.png">
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="/logo/icon-60x60.png">
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="/logo/icon-72x72.png">
|
||||
@@ -105,7 +105,7 @@
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="/logo/icon-120x120.png">
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="/logo/icon-144x144.png">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="/logo/icon-152x152.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/logo/icon-180x180.png?v=1788040116230">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/logo/icon-180x180.png?v=1788237895492">
|
||||
|
||||
<!-- Microsoft Tiles -->
|
||||
<meta name="msapplication-TileColor" content="#ff6b35">
|
||||
@@ -222,7 +222,7 @@
|
||||
<!-- Render-blocking JS is deferred: classic deferred scripts and module scripts
|
||||
both execute in document order after parsing, so React still runs before the
|
||||
app modules below, but the parser / first paint is no longer blocked. -->
|
||||
<script defer src="/config/ice-servers.js?v=1788040116230"></script>
|
||||
<script defer src="/config/ice-servers.js?v=1788237895492"></script>
|
||||
<script defer src="/libs/react/react.production.min.js"></script>
|
||||
<script defer src="/libs/react-dom/react-dom.production.min.js"></script>
|
||||
<!-- Prism syntax highlighting (vendored, offline). Tokenizes code as TEXT only —
|
||||
@@ -230,8 +230,8 @@
|
||||
Its CSS is loaded async via load-async-css.js (not paint-critical). -->
|
||||
<script defer src="/libs/prism/prism.js"></script>
|
||||
<!-- Critical, paint-defining CSS stays render-blocking (avoids FOUC / layout shift). -->
|
||||
<link rel="stylesheet" href="/assets/tailwind.css?v=1788040116230">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788040116230">
|
||||
<link rel="stylesheet" href="/assets/tailwind.css?v=1788237895492">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788237895492">
|
||||
<!-- Preload only the fonts needed for first paint. fa-solid covers the bulk of UI
|
||||
icons; fa-regular/fa-brands are loaded on demand by their CSS (rarely on the
|
||||
first screen). Inter latin 400/700 cover body text and headings/buttons. -->
|
||||
@@ -239,38 +239,38 @@
|
||||
<link rel="preload" href="/assets/fonts/inter/files/inter-latin-400.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="/assets/fonts/inter/files/inter-latin-700.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="stylesheet" href="/assets/fonts/inter/inter.css">
|
||||
<link rel="stylesheet" href="/src/styles/main.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/animations.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/components.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/main.css?v=1788237895492">
|
||||
<link rel="stylesheet" href="/src/styles/animations.css?v=1788237895492">
|
||||
<link rel="stylesheet" href="/src/styles/components.css?v=1788237895492">
|
||||
<!-- Loads after components.css on purpose: press feedback, material weight,
|
||||
size-specific tracking and the reduced-motion / -transparency / -contrast
|
||||
answers all need to settle arguments with the sheets above on source order. -->
|
||||
<link rel="stylesheet" href="/src/styles/apple-motion.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/apple-motion.css?v=1788237895492">
|
||||
<!-- Last of the hand-written sheets: the mirroring rules must win over anything
|
||||
above them, and they only ever apply under [dir="rtl"]. -->
|
||||
<link rel="stylesheet" href="/src/styles/rtl.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/rtl.css?v=1788237895492">
|
||||
<!-- Non-critical CSS (FontAwesome ~102KB, Prism) loaded async — no longer blocks paint. -->
|
||||
<script defer src="/src/scripts/load-async-css.js?v=1788040116230"></script>
|
||||
<script defer src="/src/scripts/load-async-css.js?v=1788237895492"></script>
|
||||
<noscript>
|
||||
<link rel="stylesheet" href="/assets/fontawesome/css/all.min.css">
|
||||
<link rel="stylesheet" href="/libs/prism/prism.css">
|
||||
</noscript>
|
||||
<script defer src="/src/scripts/fa-check.js?v=1788040116230"></script>
|
||||
<script defer src="/src/scripts/fa-check.js?v=1788237895492"></script>
|
||||
<!-- Update Manager - система принудительного обновления -->
|
||||
<script defer src="/src/utils/updateManager.js?v=1788040116230"></script>
|
||||
<script type="module" src="/src/components/UpdateChecker.jsx?v=1788040116230"></script>
|
||||
<script type="module" src="/dist/qr-local.js?v=1788040116230"></script>
|
||||
<script type="module" src="/src/components/QRScanner.js?v=1788040116230"></script>
|
||||
<script defer src="/src/utils/updateManager.js?v=1788237895492"></script>
|
||||
<script type="module" src="/src/components/UpdateChecker.jsx?v=1788237895492"></script>
|
||||
<script type="module" src="/dist/qr-local.js?v=1788237895492"></script>
|
||||
<script type="module" src="/src/components/QRScanner.js?v=1788237895492"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/dist/app-boot.js?v=1788040116230"></script>
|
||||
<script type="module" src="/dist/app.js?v=1788040116230"></script>
|
||||
<script type="module" src="/dist/app-boot.js?v=1788237895492"></script>
|
||||
<script type="module" src="/dist/app.js?v=1788237895492"></script>
|
||||
|
||||
<script defer src="/src/scripts/pwa-register.js?v=1788040116230"></script>
|
||||
<script src="/src/pwa/install-prompt.js?v=1788040116230" type="module"></script>
|
||||
<script src="/src/pwa/pwa-manager.js?v=1788040116230" type="module"></script>
|
||||
<script defer src="/src/scripts/pwa-offline-test.js?v=1788040116230"></script>
|
||||
<link rel="stylesheet" href="/src/styles/pwa.css?v=1788040116230">
|
||||
<script defer src="/src/scripts/pwa-register.js?v=1788237895492"></script>
|
||||
<script src="/src/pwa/install-prompt.js?v=1788237895492" type="module"></script>
|
||||
<script src="/src/pwa/pwa-manager.js?v=1788237895492" type="module"></script>
|
||||
<script defer src="/src/scripts/pwa-offline-test.js?v=1788237895492"></script>
|
||||
<link rel="stylesheet" href="/src/styles/pwa.css?v=1788237895492">
|
||||
</body>
|
||||
</html>
|
||||
+24
-24
@@ -30,7 +30,7 @@
|
||||
|
||||
<!-- PWA Manifest -->
|
||||
<link rel="manifest" href="/hi/manifest.json">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788040116230">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788237895492">
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
@@ -96,7 +96,7 @@
|
||||
<link rel="apple-touch-startup-image" media="screen and (device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/logo/splash/splash_screens/8.3__iPad_Mini_portrait.png">
|
||||
|
||||
<!-- Apple Touch Icons -->
|
||||
<link rel="apple-touch-icon" href="/logo/icon-180x180.png?v=1788040116230">
|
||||
<link rel="apple-touch-icon" href="/logo/icon-180x180.png?v=1788237895492">
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="/logo/icon-57x57.png">
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="/logo/icon-60x60.png">
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="/logo/icon-72x72.png">
|
||||
@@ -105,7 +105,7 @@
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="/logo/icon-120x120.png">
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="/logo/icon-144x144.png">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="/logo/icon-152x152.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/logo/icon-180x180.png?v=1788040116230">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/logo/icon-180x180.png?v=1788237895492">
|
||||
|
||||
<!-- Microsoft Tiles -->
|
||||
<meta name="msapplication-TileColor" content="#ff6b35">
|
||||
@@ -222,7 +222,7 @@
|
||||
<!-- Render-blocking JS is deferred: classic deferred scripts and module scripts
|
||||
both execute in document order after parsing, so React still runs before the
|
||||
app modules below, but the parser / first paint is no longer blocked. -->
|
||||
<script defer src="/config/ice-servers.js?v=1788040116230"></script>
|
||||
<script defer src="/config/ice-servers.js?v=1788237895492"></script>
|
||||
<script defer src="/libs/react/react.production.min.js"></script>
|
||||
<script defer src="/libs/react-dom/react-dom.production.min.js"></script>
|
||||
<!-- Prism syntax highlighting (vendored, offline). Tokenizes code as TEXT only —
|
||||
@@ -230,8 +230,8 @@
|
||||
Its CSS is loaded async via load-async-css.js (not paint-critical). -->
|
||||
<script defer src="/libs/prism/prism.js"></script>
|
||||
<!-- Critical, paint-defining CSS stays render-blocking (avoids FOUC / layout shift). -->
|
||||
<link rel="stylesheet" href="/assets/tailwind.css?v=1788040116230">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788040116230">
|
||||
<link rel="stylesheet" href="/assets/tailwind.css?v=1788237895492">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788237895492">
|
||||
<!-- Preload only the fonts needed for first paint. fa-solid covers the bulk of UI
|
||||
icons; fa-regular/fa-brands are loaded on demand by their CSS (rarely on the
|
||||
first screen). Inter latin 400/700 cover body text and headings/buttons. -->
|
||||
@@ -239,38 +239,38 @@
|
||||
<link rel="preload" href="/assets/fonts/inter/files/inter-latin-400.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="/assets/fonts/inter/files/inter-latin-700.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="stylesheet" href="/assets/fonts/inter/inter.css">
|
||||
<link rel="stylesheet" href="/src/styles/main.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/animations.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/components.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/main.css?v=1788237895492">
|
||||
<link rel="stylesheet" href="/src/styles/animations.css?v=1788237895492">
|
||||
<link rel="stylesheet" href="/src/styles/components.css?v=1788237895492">
|
||||
<!-- Loads after components.css on purpose: press feedback, material weight,
|
||||
size-specific tracking and the reduced-motion / -transparency / -contrast
|
||||
answers all need to settle arguments with the sheets above on source order. -->
|
||||
<link rel="stylesheet" href="/src/styles/apple-motion.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/apple-motion.css?v=1788237895492">
|
||||
<!-- Last of the hand-written sheets: the mirroring rules must win over anything
|
||||
above them, and they only ever apply under [dir="rtl"]. -->
|
||||
<link rel="stylesheet" href="/src/styles/rtl.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/rtl.css?v=1788237895492">
|
||||
<!-- Non-critical CSS (FontAwesome ~102KB, Prism) loaded async — no longer blocks paint. -->
|
||||
<script defer src="/src/scripts/load-async-css.js?v=1788040116230"></script>
|
||||
<script defer src="/src/scripts/load-async-css.js?v=1788237895492"></script>
|
||||
<noscript>
|
||||
<link rel="stylesheet" href="/assets/fontawesome/css/all.min.css">
|
||||
<link rel="stylesheet" href="/libs/prism/prism.css">
|
||||
</noscript>
|
||||
<script defer src="/src/scripts/fa-check.js?v=1788040116230"></script>
|
||||
<script defer src="/src/scripts/fa-check.js?v=1788237895492"></script>
|
||||
<!-- Update Manager - система принудительного обновления -->
|
||||
<script defer src="/src/utils/updateManager.js?v=1788040116230"></script>
|
||||
<script type="module" src="/src/components/UpdateChecker.jsx?v=1788040116230"></script>
|
||||
<script type="module" src="/dist/qr-local.js?v=1788040116230"></script>
|
||||
<script type="module" src="/src/components/QRScanner.js?v=1788040116230"></script>
|
||||
<script defer src="/src/utils/updateManager.js?v=1788237895492"></script>
|
||||
<script type="module" src="/src/components/UpdateChecker.jsx?v=1788237895492"></script>
|
||||
<script type="module" src="/dist/qr-local.js?v=1788237895492"></script>
|
||||
<script type="module" src="/src/components/QRScanner.js?v=1788237895492"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/dist/app-boot.js?v=1788040116230"></script>
|
||||
<script type="module" src="/dist/app.js?v=1788040116230"></script>
|
||||
<script type="module" src="/dist/app-boot.js?v=1788237895492"></script>
|
||||
<script type="module" src="/dist/app.js?v=1788237895492"></script>
|
||||
|
||||
<script defer src="/src/scripts/pwa-register.js?v=1788040116230"></script>
|
||||
<script src="/src/pwa/install-prompt.js?v=1788040116230" type="module"></script>
|
||||
<script src="/src/pwa/pwa-manager.js?v=1788040116230" type="module"></script>
|
||||
<script defer src="/src/scripts/pwa-offline-test.js?v=1788040116230"></script>
|
||||
<link rel="stylesheet" href="/src/styles/pwa.css?v=1788040116230">
|
||||
<script defer src="/src/scripts/pwa-register.js?v=1788237895492"></script>
|
||||
<script src="/src/pwa/install-prompt.js?v=1788237895492" type="module"></script>
|
||||
<script src="/src/pwa/pwa-manager.js?v=1788237895492" type="module"></script>
|
||||
<script defer src="/src/scripts/pwa-offline-test.js?v=1788237895492"></script>
|
||||
<link rel="stylesheet" href="/src/styles/pwa.css?v=1788237895492">
|
||||
</body>
|
||||
</html>
|
||||
+24
-24
@@ -30,7 +30,7 @@
|
||||
|
||||
<!-- PWA Manifest -->
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788040116230">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788237895492">
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
@@ -96,7 +96,7 @@
|
||||
<link rel="apple-touch-startup-image" media="screen and (device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/logo/splash/splash_screens/8.3__iPad_Mini_portrait.png">
|
||||
|
||||
<!-- Apple Touch Icons -->
|
||||
<link rel="apple-touch-icon" href="/logo/icon-180x180.png?v=1788040116230">
|
||||
<link rel="apple-touch-icon" href="/logo/icon-180x180.png?v=1788237895492">
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="/logo/icon-57x57.png">
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="/logo/icon-60x60.png">
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="/logo/icon-72x72.png">
|
||||
@@ -105,7 +105,7 @@
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="/logo/icon-120x120.png">
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="/logo/icon-144x144.png">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="/logo/icon-152x152.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/logo/icon-180x180.png?v=1788040116230">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/logo/icon-180x180.png?v=1788237895492">
|
||||
|
||||
<!-- Microsoft Tiles -->
|
||||
<meta name="msapplication-TileColor" content="#ff6b35">
|
||||
@@ -222,7 +222,7 @@
|
||||
<!-- Render-blocking JS is deferred: classic deferred scripts and module scripts
|
||||
both execute in document order after parsing, so React still runs before the
|
||||
app modules below, but the parser / first paint is no longer blocked. -->
|
||||
<script defer src="/config/ice-servers.js?v=1788040116230"></script>
|
||||
<script defer src="/config/ice-servers.js?v=1788237895492"></script>
|
||||
<script defer src="/libs/react/react.production.min.js"></script>
|
||||
<script defer src="/libs/react-dom/react-dom.production.min.js"></script>
|
||||
<!-- Prism syntax highlighting (vendored, offline). Tokenizes code as TEXT only —
|
||||
@@ -230,8 +230,8 @@
|
||||
Its CSS is loaded async via load-async-css.js (not paint-critical). -->
|
||||
<script defer src="/libs/prism/prism.js"></script>
|
||||
<!-- Critical, paint-defining CSS stays render-blocking (avoids FOUC / layout shift). -->
|
||||
<link rel="stylesheet" href="/assets/tailwind.css?v=1788040116230">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788040116230">
|
||||
<link rel="stylesheet" href="/assets/tailwind.css?v=1788237895492">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788237895492">
|
||||
<!-- Preload only the fonts needed for first paint. fa-solid covers the bulk of UI
|
||||
icons; fa-regular/fa-brands are loaded on demand by their CSS (rarely on the
|
||||
first screen). Inter latin 400/700 cover body text and headings/buttons. -->
|
||||
@@ -239,38 +239,38 @@
|
||||
<link rel="preload" href="/assets/fonts/inter/files/inter-latin-400.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="/assets/fonts/inter/files/inter-latin-700.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="stylesheet" href="/assets/fonts/inter/inter.css">
|
||||
<link rel="stylesheet" href="/src/styles/main.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/animations.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/components.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/main.css?v=1788237895492">
|
||||
<link rel="stylesheet" href="/src/styles/animations.css?v=1788237895492">
|
||||
<link rel="stylesheet" href="/src/styles/components.css?v=1788237895492">
|
||||
<!-- Loads after components.css on purpose: press feedback, material weight,
|
||||
size-specific tracking and the reduced-motion / -transparency / -contrast
|
||||
answers all need to settle arguments with the sheets above on source order. -->
|
||||
<link rel="stylesheet" href="/src/styles/apple-motion.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/apple-motion.css?v=1788237895492">
|
||||
<!-- Last of the hand-written sheets: the mirroring rules must win over anything
|
||||
above them, and they only ever apply under [dir="rtl"]. -->
|
||||
<link rel="stylesheet" href="/src/styles/rtl.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/rtl.css?v=1788237895492">
|
||||
<!-- Non-critical CSS (FontAwesome ~102KB, Prism) loaded async — no longer blocks paint. -->
|
||||
<script defer src="/src/scripts/load-async-css.js?v=1788040116230"></script>
|
||||
<script defer src="/src/scripts/load-async-css.js?v=1788237895492"></script>
|
||||
<noscript>
|
||||
<link rel="stylesheet" href="/assets/fontawesome/css/all.min.css">
|
||||
<link rel="stylesheet" href="/libs/prism/prism.css">
|
||||
</noscript>
|
||||
<script defer src="/src/scripts/fa-check.js?v=1788040116230"></script>
|
||||
<script defer src="/src/scripts/fa-check.js?v=1788237895492"></script>
|
||||
<!-- Update Manager - система принудительного обновления -->
|
||||
<script defer src="/src/utils/updateManager.js?v=1788040116230"></script>
|
||||
<script type="module" src="/src/components/UpdateChecker.jsx?v=1788040116230"></script>
|
||||
<script type="module" src="/dist/qr-local.js?v=1788040116230"></script>
|
||||
<script type="module" src="/src/components/QRScanner.js?v=1788040116230"></script>
|
||||
<script defer src="/src/utils/updateManager.js?v=1788237895492"></script>
|
||||
<script type="module" src="/src/components/UpdateChecker.jsx?v=1788237895492"></script>
|
||||
<script type="module" src="/dist/qr-local.js?v=1788237895492"></script>
|
||||
<script type="module" src="/src/components/QRScanner.js?v=1788237895492"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/dist/app-boot.js?v=1788040116230"></script>
|
||||
<script type="module" src="/dist/app.js?v=1788040116230"></script>
|
||||
<script type="module" src="/dist/app-boot.js?v=1788237895492"></script>
|
||||
<script type="module" src="/dist/app.js?v=1788237895492"></script>
|
||||
|
||||
<script defer src="/src/scripts/pwa-register.js?v=1788040116230"></script>
|
||||
<script src="/src/pwa/install-prompt.js?v=1788040116230" type="module"></script>
|
||||
<script src="/src/pwa/pwa-manager.js?v=1788040116230" type="module"></script>
|
||||
<script defer src="/src/scripts/pwa-offline-test.js?v=1788040116230"></script>
|
||||
<link rel="stylesheet" href="/src/styles/pwa.css?v=1788040116230">
|
||||
<script defer src="/src/scripts/pwa-register.js?v=1788237895492"></script>
|
||||
<script src="/src/pwa/install-prompt.js?v=1788237895492" type="module"></script>
|
||||
<script src="/src/pwa/pwa-manager.js?v=1788237895492" type="module"></script>
|
||||
<script defer src="/src/scripts/pwa-offline-test.js?v=1788237895492"></script>
|
||||
<link rel="stylesheet" href="/src/styles/pwa.css?v=1788237895492">
|
||||
</body>
|
||||
</html>
|
||||
+24
-24
@@ -30,7 +30,7 @@
|
||||
|
||||
<!-- PWA Manifest -->
|
||||
<link rel="manifest" href="/ko/manifest.json">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788040116230">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788237895492">
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
@@ -96,7 +96,7 @@
|
||||
<link rel="apple-touch-startup-image" media="screen and (device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/logo/splash/splash_screens/8.3__iPad_Mini_portrait.png">
|
||||
|
||||
<!-- Apple Touch Icons -->
|
||||
<link rel="apple-touch-icon" href="/logo/icon-180x180.png?v=1788040116230">
|
||||
<link rel="apple-touch-icon" href="/logo/icon-180x180.png?v=1788237895492">
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="/logo/icon-57x57.png">
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="/logo/icon-60x60.png">
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="/logo/icon-72x72.png">
|
||||
@@ -105,7 +105,7 @@
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="/logo/icon-120x120.png">
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="/logo/icon-144x144.png">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="/logo/icon-152x152.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/logo/icon-180x180.png?v=1788040116230">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/logo/icon-180x180.png?v=1788237895492">
|
||||
|
||||
<!-- Microsoft Tiles -->
|
||||
<meta name="msapplication-TileColor" content="#ff6b35">
|
||||
@@ -222,7 +222,7 @@
|
||||
<!-- Render-blocking JS is deferred: classic deferred scripts and module scripts
|
||||
both execute in document order after parsing, so React still runs before the
|
||||
app modules below, but the parser / first paint is no longer blocked. -->
|
||||
<script defer src="/config/ice-servers.js?v=1788040116230"></script>
|
||||
<script defer src="/config/ice-servers.js?v=1788237895492"></script>
|
||||
<script defer src="/libs/react/react.production.min.js"></script>
|
||||
<script defer src="/libs/react-dom/react-dom.production.min.js"></script>
|
||||
<!-- Prism syntax highlighting (vendored, offline). Tokenizes code as TEXT only —
|
||||
@@ -230,8 +230,8 @@
|
||||
Its CSS is loaded async via load-async-css.js (not paint-critical). -->
|
||||
<script defer src="/libs/prism/prism.js"></script>
|
||||
<!-- Critical, paint-defining CSS stays render-blocking (avoids FOUC / layout shift). -->
|
||||
<link rel="stylesheet" href="/assets/tailwind.css?v=1788040116230">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788040116230">
|
||||
<link rel="stylesheet" href="/assets/tailwind.css?v=1788237895492">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788237895492">
|
||||
<!-- Preload only the fonts needed for first paint. fa-solid covers the bulk of UI
|
||||
icons; fa-regular/fa-brands are loaded on demand by their CSS (rarely on the
|
||||
first screen). Inter latin 400/700 cover body text and headings/buttons. -->
|
||||
@@ -239,38 +239,38 @@
|
||||
<link rel="preload" href="/assets/fonts/inter/files/inter-latin-400.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="/assets/fonts/inter/files/inter-latin-700.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="stylesheet" href="/assets/fonts/inter/inter.css">
|
||||
<link rel="stylesheet" href="/src/styles/main.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/animations.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/components.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/main.css?v=1788237895492">
|
||||
<link rel="stylesheet" href="/src/styles/animations.css?v=1788237895492">
|
||||
<link rel="stylesheet" href="/src/styles/components.css?v=1788237895492">
|
||||
<!-- Loads after components.css on purpose: press feedback, material weight,
|
||||
size-specific tracking and the reduced-motion / -transparency / -contrast
|
||||
answers all need to settle arguments with the sheets above on source order. -->
|
||||
<link rel="stylesheet" href="/src/styles/apple-motion.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/apple-motion.css?v=1788237895492">
|
||||
<!-- Last of the hand-written sheets: the mirroring rules must win over anything
|
||||
above them, and they only ever apply under [dir="rtl"]. -->
|
||||
<link rel="stylesheet" href="/src/styles/rtl.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/rtl.css?v=1788237895492">
|
||||
<!-- Non-critical CSS (FontAwesome ~102KB, Prism) loaded async — no longer blocks paint. -->
|
||||
<script defer src="/src/scripts/load-async-css.js?v=1788040116230"></script>
|
||||
<script defer src="/src/scripts/load-async-css.js?v=1788237895492"></script>
|
||||
<noscript>
|
||||
<link rel="stylesheet" href="/assets/fontawesome/css/all.min.css">
|
||||
<link rel="stylesheet" href="/libs/prism/prism.css">
|
||||
</noscript>
|
||||
<script defer src="/src/scripts/fa-check.js?v=1788040116230"></script>
|
||||
<script defer src="/src/scripts/fa-check.js?v=1788237895492"></script>
|
||||
<!-- Update Manager - система принудительного обновления -->
|
||||
<script defer src="/src/utils/updateManager.js?v=1788040116230"></script>
|
||||
<script type="module" src="/src/components/UpdateChecker.jsx?v=1788040116230"></script>
|
||||
<script type="module" src="/dist/qr-local.js?v=1788040116230"></script>
|
||||
<script type="module" src="/src/components/QRScanner.js?v=1788040116230"></script>
|
||||
<script defer src="/src/utils/updateManager.js?v=1788237895492"></script>
|
||||
<script type="module" src="/src/components/UpdateChecker.jsx?v=1788237895492"></script>
|
||||
<script type="module" src="/dist/qr-local.js?v=1788237895492"></script>
|
||||
<script type="module" src="/src/components/QRScanner.js?v=1788237895492"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/dist/app-boot.js?v=1788040116230"></script>
|
||||
<script type="module" src="/dist/app.js?v=1788040116230"></script>
|
||||
<script type="module" src="/dist/app-boot.js?v=1788237895492"></script>
|
||||
<script type="module" src="/dist/app.js?v=1788237895492"></script>
|
||||
|
||||
<script defer src="/src/scripts/pwa-register.js?v=1788040116230"></script>
|
||||
<script src="/src/pwa/install-prompt.js?v=1788040116230" type="module"></script>
|
||||
<script src="/src/pwa/pwa-manager.js?v=1788040116230" type="module"></script>
|
||||
<script defer src="/src/scripts/pwa-offline-test.js?v=1788040116230"></script>
|
||||
<link rel="stylesheet" href="/src/styles/pwa.css?v=1788040116230">
|
||||
<script defer src="/src/scripts/pwa-register.js?v=1788237895492"></script>
|
||||
<script src="/src/pwa/install-prompt.js?v=1788237895492" type="module"></script>
|
||||
<script src="/src/pwa/pwa-manager.js?v=1788237895492" type="module"></script>
|
||||
<script defer src="/src/scripts/pwa-offline-test.js?v=1788237895492"></script>
|
||||
<link rel="stylesheet" href="/src/styles/pwa.css?v=1788237895492">
|
||||
</body>
|
||||
</html>
|
||||
+26
-1
@@ -36,6 +36,9 @@
|
||||
},
|
||||
"ui": {
|
||||
"language.label": "اللغة",
|
||||
"language.suggest.text": "هذه الصفحة متوفرة أيضًا بالعربية.",
|
||||
"language.suggest.cta": "اقرأ بالعربية",
|
||||
"language.suggest.dismiss": "إغلاق",
|
||||
"community.title": "انضمّ إلى مستقبل الخصوصية",
|
||||
"community.description": "ينمو SecureBit بفضل مجتمعه. أفكاركم وملاحظاتكم هي ما يصوغ مستقبل التواصل الآمن - مبنيّ في العلن، مع تحقّق ASN.1 كامل من طرف إلى طرف.",
|
||||
"community.github": "مستودع GitHub",
|
||||
@@ -781,6 +784,28 @@
|
||||
"offline.disconnect": "قطع الاتصال",
|
||||
"offline.learnMore": "اعرف المزيد",
|
||||
"pwa.installApp": "ثبّت التطبيق",
|
||||
"chat.onWeb": "أنت على الويب"
|
||||
"chat.onWeb": "أنت على الويب",
|
||||
"groupCall.startVoice": "بدء مكالمة صوتية جماعية",
|
||||
"groupCall.startVideo": "بدء مكالمة فيديو جماعية",
|
||||
"groupCall.join": "انضمام",
|
||||
"groupCall.dismiss": "ليس الآن",
|
||||
"groupCall.leave": "مغادرة المكالمة",
|
||||
"groupCall.you": "أنت",
|
||||
"groupCall.connecting": "جارٍ الاتصال…",
|
||||
"groupCall.waitingLink": "في انتظار رابط مباشر…",
|
||||
"groupCall.legFailed": "تعذّر الاتصال",
|
||||
"groupCall.startedVoice": "بدأ {name} مكالمة صوتية",
|
||||
"groupCall.startedVideo": "بدأ {name} مكالمة فيديو",
|
||||
"groupCall.inCall": "{count} في المكالمة",
|
||||
"groupCall.err.permission_denied": "تعذّر بدء المكالمة - الوصول إلى الميكروفون والكاميرا محظور. اسمح به لهذا الموقع ثم أعد المحاولة.",
|
||||
"groupCall.err.device_not_found": "تعذّر بدء المكالمة - لم يُعثر على ميكروفون في هذا الجهاز.",
|
||||
"groupCall.err.device_busy": "تعذّر بدء المكالمة - الميكروفون مستخدَم من تطبيق آخر. أغلقه ثم أعد المحاولة.",
|
||||
"groupCall.err.media_failed": "تعذّر بدء المكالمة - لم يمكن فتح الميكروفون.",
|
||||
"groupCall.err.call_in_progress": "هناك مكالمة جارية بالفعل في هذه المجموعة. انضم إليها بدل بدء أخرى.",
|
||||
"groupCall.err.not_ready": "أكّد رمز المجموعة قبل الاتصال.",
|
||||
"groupCall.speaking": "{name} يتحدّث الآن",
|
||||
"groupCall.pin": "عرض {name} بحجم كبير",
|
||||
"groupCall.unpin": "العودة إلى الجميع",
|
||||
"groupCall.showEveryone": "الجميع"
|
||||
}
|
||||
}
|
||||
|
||||
+26
-1
@@ -36,6 +36,9 @@
|
||||
},
|
||||
"ui": {
|
||||
"language.label": "Sprache",
|
||||
"language.suggest.text": "Diese Seite gibt es auch auf Deutsch.",
|
||||
"language.suggest.cta": "Auf Deutsch lesen",
|
||||
"language.suggest.dismiss": "Schließen",
|
||||
"community.title": "Gestalten Sie die Zukunft der Privatsphäre mit",
|
||||
"community.description": "SecureBit wächst durch seine Community. Ihre Ideen und Ihr Feedback prägen die Zukunft sicherer Kommunikation - offen entwickelt, mit vollständiger ASN.1-Validierung von Ende zu Ende.",
|
||||
"community.github": "GitHub-Repository",
|
||||
@@ -781,6 +784,28 @@
|
||||
"offline.disconnect": "Trennen",
|
||||
"offline.learnMore": "Mehr erfahren",
|
||||
"pwa.installApp": "App installieren",
|
||||
"chat.onWeb": "Sie nutzen die Web-Version"
|
||||
"chat.onWeb": "Sie nutzen die Web-Version",
|
||||
"groupCall.startVoice": "Gruppen-Sprachanruf starten",
|
||||
"groupCall.startVideo": "Gruppen-Videoanruf starten",
|
||||
"groupCall.join": "Beitreten",
|
||||
"groupCall.dismiss": "Jetzt nicht",
|
||||
"groupCall.leave": "Anruf verlassen",
|
||||
"groupCall.you": "Du",
|
||||
"groupCall.connecting": "Verbinden…",
|
||||
"groupCall.waitingLink": "Warten auf eine direkte Verbindung…",
|
||||
"groupCall.legFailed": "Verbindung fehlgeschlagen",
|
||||
"groupCall.startedVoice": "{name} hat einen Sprachanruf gestartet",
|
||||
"groupCall.startedVideo": "{name} hat einen Videoanruf gestartet",
|
||||
"groupCall.inCall": "{count} im Anruf",
|
||||
"groupCall.err.permission_denied": "Der Anruf konnte nicht starten - der Zugriff auf Mikrofon und Kamera ist blockiert. Erlaube ihn für diese Seite und versuche es erneut.",
|
||||
"groupCall.err.device_not_found": "Der Anruf konnte nicht starten - auf diesem Gerät wurde kein Mikrofon gefunden.",
|
||||
"groupCall.err.device_busy": "Der Anruf konnte nicht starten - dein Mikrofon wird von einer anderen App verwendet. Schließe sie und versuche es erneut.",
|
||||
"groupCall.err.media_failed": "Der Anruf konnte nicht starten - das Mikrofon ließ sich nicht öffnen.",
|
||||
"groupCall.err.call_in_progress": "In dieser Gruppe läuft bereits ein Anruf. Tritt ihm bei, statt einen neuen zu starten.",
|
||||
"groupCall.err.not_ready": "Bestätige zuerst den Gruppencode, bevor du anrufst.",
|
||||
"groupCall.speaking": "{name} spricht",
|
||||
"groupCall.pin": "{name} groß anzeigen",
|
||||
"groupCall.unpin": "Zurück zu allen",
|
||||
"groupCall.showEveryone": "Alle"
|
||||
}
|
||||
}
|
||||
|
||||
+26
-1
@@ -31,6 +31,9 @@
|
||||
},
|
||||
"ui": {
|
||||
"language.label": "Language",
|
||||
"language.suggest.text": "This page is also available in English.",
|
||||
"language.suggest.cta": "Read in English",
|
||||
"language.suggest.dismiss": "Dismiss",
|
||||
"community.title": "Join the future of privacy",
|
||||
"community.description": "SecureBit grows thanks to its community. Your ideas and feedback shape the future of secure communication - built in the open, with complete ASN.1 validation end-to-end.",
|
||||
"community.github": "GitHub Repository",
|
||||
@@ -776,6 +779,28 @@
|
||||
"offline.disconnect": "Disconnect",
|
||||
"offline.learnMore": "Learn more",
|
||||
"pwa.installApp": "Install App",
|
||||
"chat.onWeb": "You're on Web"
|
||||
"chat.onWeb": "You're on Web",
|
||||
"groupCall.startVoice": "Start a group voice call",
|
||||
"groupCall.startVideo": "Start a group video call",
|
||||
"groupCall.join": "Join",
|
||||
"groupCall.dismiss": "Not now",
|
||||
"groupCall.leave": "Leave call",
|
||||
"groupCall.you": "You",
|
||||
"groupCall.connecting": "Connecting…",
|
||||
"groupCall.waitingLink": "Waiting for a direct link…",
|
||||
"groupCall.legFailed": "Could not connect",
|
||||
"groupCall.startedVoice": "{name} started a voice call",
|
||||
"groupCall.startedVideo": "{name} started a video call",
|
||||
"groupCall.inCall": "{count} in the call",
|
||||
"groupCall.err.permission_denied": "The call could not start - microphone and camera access is blocked. Allow it for this site, then try again.",
|
||||
"groupCall.err.device_not_found": "The call could not start - no microphone was found on this device.",
|
||||
"groupCall.err.device_busy": "The call could not start - your microphone is in use by another app. Close it and try again.",
|
||||
"groupCall.err.media_failed": "The call could not start - the microphone could not be opened.",
|
||||
"groupCall.err.call_in_progress": "A call is already running in this group. Join it instead of starting another.",
|
||||
"groupCall.err.not_ready": "Confirm the group code before calling.",
|
||||
"groupCall.speaking": "{name} is speaking",
|
||||
"groupCall.pin": "Show {name} large",
|
||||
"groupCall.unpin": "Back to everyone",
|
||||
"groupCall.showEveryone": "Everyone"
|
||||
}
|
||||
}
|
||||
|
||||
+26
-1
@@ -36,6 +36,9 @@
|
||||
},
|
||||
"ui": {
|
||||
"language.label": "Idioma",
|
||||
"language.suggest.text": "Esta página también está disponible en español.",
|
||||
"language.suggest.cta": "Leer en español",
|
||||
"language.suggest.dismiss": "Cerrar",
|
||||
"community.title": "Construye el futuro de la privacidad",
|
||||
"community.description": "SecureBit crece gracias a su comunidad. Tus ideas y comentarios dan forma al futuro de la comunicación segura, desarrollada de forma abierta y con validación ASN.1 completa de extremo a extremo.",
|
||||
"community.github": "Repositorio en GitHub",
|
||||
@@ -781,6 +784,28 @@
|
||||
"offline.disconnect": "Desconectar",
|
||||
"offline.learnMore": "Más información",
|
||||
"pwa.installApp": "Instalar la app",
|
||||
"chat.onWeb": "Estás en la versión web"
|
||||
"chat.onWeb": "Estás en la versión web",
|
||||
"groupCall.startVoice": "Iniciar llamada de voz grupal",
|
||||
"groupCall.startVideo": "Iniciar videollamada grupal",
|
||||
"groupCall.join": "Unirse",
|
||||
"groupCall.dismiss": "Ahora no",
|
||||
"groupCall.leave": "Salir de la llamada",
|
||||
"groupCall.you": "Tú",
|
||||
"groupCall.connecting": "Conectando…",
|
||||
"groupCall.waitingLink": "Esperando un enlace directo…",
|
||||
"groupCall.legFailed": "No se pudo conectar",
|
||||
"groupCall.startedVoice": "{name} inició una llamada de voz",
|
||||
"groupCall.startedVideo": "{name} inició una videollamada",
|
||||
"groupCall.inCall": "{count} en la llamada",
|
||||
"groupCall.err.permission_denied": "La llamada no pudo iniciarse - el acceso al micrófono y a la cámara está bloqueado. Permítelo para este sitio e inténtalo de nuevo.",
|
||||
"groupCall.err.device_not_found": "La llamada no pudo iniciarse - no se encontró ningún micrófono en este dispositivo.",
|
||||
"groupCall.err.device_busy": "La llamada no pudo iniciarse - otra aplicación está usando tu micrófono. Ciérrala e inténtalo de nuevo.",
|
||||
"groupCall.err.media_failed": "La llamada no pudo iniciarse - no se pudo abrir el micrófono.",
|
||||
"groupCall.err.call_in_progress": "Ya hay una llamada en este grupo. Únete en lugar de iniciar otra.",
|
||||
"groupCall.err.not_ready": "Confirma el código del grupo antes de llamar.",
|
||||
"groupCall.speaking": "{name} está hablando",
|
||||
"groupCall.pin": "Ver a {name} en grande",
|
||||
"groupCall.unpin": "Volver a todos",
|
||||
"groupCall.showEveryone": "Todos"
|
||||
}
|
||||
}
|
||||
|
||||
+26
-1
@@ -36,6 +36,9 @@
|
||||
},
|
||||
"ui": {
|
||||
"language.label": "زبان",
|
||||
"language.suggest.text": "این صفحه به فارسی هم در دسترس است.",
|
||||
"language.suggest.cta": "خواندن به فارسی",
|
||||
"language.suggest.dismiss": "بستن",
|
||||
"community.title": "به آیندهٔ حریم خصوصی بپیوندید",
|
||||
"community.description": "SecureBit بهلطف جامعهاش رشد میکند. ایدهها و بازخوردهای شما آیندهٔ ارتباط امن را شکل میدهد - ساختهشده در فضای باز، با اعتبارسنجی کامل ASN.1 از یک سر تا سر دیگر.",
|
||||
"community.github": "مخزن GitHub",
|
||||
@@ -781,6 +784,28 @@
|
||||
"offline.disconnect": "قطع اتصال",
|
||||
"offline.learnMore": "بیشتر بدانید",
|
||||
"pwa.installApp": "نصب برنامه",
|
||||
"chat.onWeb": "شما روی نسخهٔ وب هستید"
|
||||
"chat.onWeb": "شما روی نسخهٔ وب هستید",
|
||||
"groupCall.startVoice": "شروع تماس صوتی گروهی",
|
||||
"groupCall.startVideo": "شروع تماس تصویری گروهی",
|
||||
"groupCall.join": "پیوستن",
|
||||
"groupCall.dismiss": "الان نه",
|
||||
"groupCall.leave": "خروج از تماس",
|
||||
"groupCall.you": "شما",
|
||||
"groupCall.connecting": "در حال اتصال…",
|
||||
"groupCall.waitingLink": "در انتظار یک پیوند مستقیم…",
|
||||
"groupCall.legFailed": "اتصال برقرار نشد",
|
||||
"groupCall.startedVoice": "{name} یک تماس صوتی آغاز کرد",
|
||||
"groupCall.startedVideo": "{name} یک تماس تصویری آغاز کرد",
|
||||
"groupCall.inCall": "{count} نفر در تماس",
|
||||
"groupCall.err.permission_denied": "تماس آغاز نشد - دسترسی به میکروفون و دوربین مسدود است. آن را برای این سایت مجاز کنید و دوباره تلاش کنید.",
|
||||
"groupCall.err.device_not_found": "تماس آغاز نشد - میکروفونی روی این دستگاه پیدا نشد.",
|
||||
"groupCall.err.device_busy": "تماس آغاز نشد - میکروفون شما در حال استفاده توسط برنامه دیگری است. آن را ببندید و دوباره تلاش کنید.",
|
||||
"groupCall.err.media_failed": "تماس آغاز نشد - میکروفون باز نشد.",
|
||||
"groupCall.err.call_in_progress": "همین حالا تماسی در این گروه در جریان است. بهجای شروع تماس تازه، به آن بپیوندید.",
|
||||
"groupCall.err.not_ready": "پیش از تماس، کد گروه را تأیید کنید.",
|
||||
"groupCall.speaking": "{name} در حال صحبت است",
|
||||
"groupCall.pin": "نمایش بزرگ {name}",
|
||||
"groupCall.unpin": "بازگشت به همه",
|
||||
"groupCall.showEveryone": "همه"
|
||||
}
|
||||
}
|
||||
|
||||
+26
-1
@@ -31,6 +31,9 @@
|
||||
},
|
||||
"ui": {
|
||||
"language.label": "Langue",
|
||||
"language.suggest.text": "Cette page est aussi disponible en français.",
|
||||
"language.suggest.cta": "Lire en français",
|
||||
"language.suggest.dismiss": "Fermer",
|
||||
"community.title": "Construisez l'avenir de la vie privée",
|
||||
"community.description": "SecureBit grandit grâce à sa communauté. Vos idées et vos retours façonnent l'avenir des communications sécurisées, développées à ciel ouvert, avec une validation ASN.1 complète de bout en bout.",
|
||||
"community.github": "Dépôt GitHub",
|
||||
@@ -776,7 +779,29 @@
|
||||
"offline.disconnect": "Se déconnecter",
|
||||
"offline.learnMore": "En savoir plus",
|
||||
"pwa.installApp": "Installer l'application",
|
||||
"chat.onWeb": "Vous êtes sur la version web"
|
||||
"chat.onWeb": "Vous êtes sur la version web",
|
||||
"groupCall.startVoice": "Démarrer un appel vocal de groupe",
|
||||
"groupCall.startVideo": "Démarrer un appel vidéo de groupe",
|
||||
"groupCall.join": "Rejoindre",
|
||||
"groupCall.dismiss": "Pas maintenant",
|
||||
"groupCall.leave": "Quitter l'appel",
|
||||
"groupCall.you": "Vous",
|
||||
"groupCall.connecting": "Connexion…",
|
||||
"groupCall.waitingLink": "En attente d'un lien direct…",
|
||||
"groupCall.legFailed": "Connexion impossible",
|
||||
"groupCall.startedVoice": "{name} a démarré un appel vocal",
|
||||
"groupCall.startedVideo": "{name} a démarré un appel vidéo",
|
||||
"groupCall.inCall": "{count} dans l'appel",
|
||||
"groupCall.err.permission_denied": "L'appel n'a pas pu démarrer - l'accès au micro et à la caméra est bloqué. Autorisez-le pour ce site, puis réessayez.",
|
||||
"groupCall.err.device_not_found": "L'appel n'a pas pu démarrer - aucun micro n'a été trouvé sur cet appareil.",
|
||||
"groupCall.err.device_busy": "L'appel n'a pas pu démarrer - votre micro est utilisé par une autre application. Fermez-la et réessayez.",
|
||||
"groupCall.err.media_failed": "L'appel n'a pas pu démarrer - le micro n'a pas pu être ouvert.",
|
||||
"groupCall.err.call_in_progress": "Un appel est déjà en cours dans ce groupe. Rejoignez-le plutôt que d'en démarrer un autre.",
|
||||
"groupCall.err.not_ready": "Confirmez le code du groupe avant d'appeler.",
|
||||
"groupCall.speaking": "{name} parle",
|
||||
"groupCall.pin": "Afficher {name} en grand",
|
||||
"groupCall.unpin": "Revenir à tous",
|
||||
"groupCall.showEveryone": "Tout le monde"
|
||||
},
|
||||
"manifest": {
|
||||
"name": "SecureBit.chat - Messagerie privée et chiffrée",
|
||||
|
||||
+26
-1
@@ -36,6 +36,9 @@
|
||||
},
|
||||
"ui": {
|
||||
"language.label": "שפה",
|
||||
"language.suggest.text": "הדף הזה זמין גם בעברית.",
|
||||
"language.suggest.cta": "לקריאה בעברית",
|
||||
"language.suggest.dismiss": "סגירה",
|
||||
"community.title": "הצטרפו לעתיד של הפרטיות",
|
||||
"community.description": "SecureBit גדל בזכות הקהילה שלו. הרעיונות והמשוב שלכם מעצבים את עתיד התקשורת המאובטחת - נבנה בגלוי, עם אימות ASN.1 מלא מקצה לקצה.",
|
||||
"community.github": "מאגר GitHub",
|
||||
@@ -781,6 +784,28 @@
|
||||
"offline.disconnect": "ניתוק",
|
||||
"offline.learnMore": "מידע נוסף",
|
||||
"pwa.installApp": "התקנת האפליקציה",
|
||||
"chat.onWeb": "אתם בגרסת הווב"
|
||||
"chat.onWeb": "אתם בגרסת הווב",
|
||||
"groupCall.startVoice": "התחלת שיחה קולית קבוצתית",
|
||||
"groupCall.startVideo": "התחלת שיחת וידאו קבוצתית",
|
||||
"groupCall.join": "הצטרפות",
|
||||
"groupCall.dismiss": "לא עכשיו",
|
||||
"groupCall.leave": "יציאה מהשיחה",
|
||||
"groupCall.you": "את/ה",
|
||||
"groupCall.connecting": "מתחבר…",
|
||||
"groupCall.waitingLink": "ממתין לקישור ישיר…",
|
||||
"groupCall.legFailed": "ההתחברות נכשלה",
|
||||
"groupCall.startedVoice": "{name} התחיל שיחה קולית",
|
||||
"groupCall.startedVideo": "{name} התחיל שיחת וידאו",
|
||||
"groupCall.inCall": "{count} בשיחה",
|
||||
"groupCall.err.permission_denied": "השיחה לא התחילה - הגישה למיקרופון ולמצלמה חסומה. אפשרו אותה לאתר הזה ונסו שוב.",
|
||||
"groupCall.err.device_not_found": "השיחה לא התחילה - לא נמצא מיקרופון במכשיר הזה.",
|
||||
"groupCall.err.device_busy": "השיחה לא התחילה - המיקרופון בשימוש אפליקציה אחרת. סגרו אותה ונסו שוב.",
|
||||
"groupCall.err.media_failed": "השיחה לא התחילה - לא ניתן היה לפתוח את המיקרופון.",
|
||||
"groupCall.err.call_in_progress": "כבר מתקיימת שיחה בקבוצה הזו. הצטרפו אליה במקום להתחיל אחת חדשה.",
|
||||
"groupCall.err.not_ready": "אשרו את קוד הקבוצה לפני שיחה.",
|
||||
"groupCall.speaking": "{name} מדבר/ת",
|
||||
"groupCall.pin": "הצגת {name} בגדול",
|
||||
"groupCall.unpin": "חזרה לכולם",
|
||||
"groupCall.showEveryone": "כולם"
|
||||
}
|
||||
}
|
||||
|
||||
+26
-1
@@ -31,6 +31,9 @@
|
||||
},
|
||||
"ui": {
|
||||
"language.label": "भाषा",
|
||||
"language.suggest.text": "यह पृष्ठ हिन्दी में भी उपलब्ध है।",
|
||||
"language.suggest.cta": "हिन्दी में पढ़ें",
|
||||
"language.suggest.dismiss": "बंद करें",
|
||||
"community.title": "निजता के भविष्य में शामिल हों",
|
||||
"community.description": "SecureBit अपने समुदाय से बढ़ता है। आपके विचार और सुझाव सुरक्षित संवाद का भविष्य गढ़ते हैं - खुले में बना, और पूरे रास्ते पूरी ASN.1 जाँच के साथ।",
|
||||
"community.github": "GitHub रिपॉज़िटरी",
|
||||
@@ -776,7 +779,29 @@
|
||||
"offline.disconnect": "जुड़ाव तोड़ें",
|
||||
"offline.learnMore": "और जानें",
|
||||
"pwa.installApp": "ऐप इंस्टॉल करें",
|
||||
"chat.onWeb": "आप वेब संस्करण पर हैं"
|
||||
"chat.onWeb": "आप वेब संस्करण पर हैं",
|
||||
"groupCall.startVoice": "समूह वॉइस कॉल शुरू करें",
|
||||
"groupCall.startVideo": "समूह वीडियो कॉल शुरू करें",
|
||||
"groupCall.join": "शामिल हों",
|
||||
"groupCall.dismiss": "अभी नहीं",
|
||||
"groupCall.leave": "कॉल छोड़ें",
|
||||
"groupCall.you": "आप",
|
||||
"groupCall.connecting": "जुड़ रहा है…",
|
||||
"groupCall.waitingLink": "सीधे लिंक की प्रतीक्षा…",
|
||||
"groupCall.legFailed": "कनेक्ट नहीं हो सका",
|
||||
"groupCall.startedVoice": "{name} ने वॉइस कॉल शुरू की",
|
||||
"groupCall.startedVideo": "{name} ने वीडियो कॉल शुरू की",
|
||||
"groupCall.inCall": "कॉल में {count}",
|
||||
"groupCall.err.permission_denied": "कॉल शुरू नहीं हो सकी - माइक्रोफ़ोन और कैमरे की अनुमति अवरुद्ध है। इस साइट के लिए अनुमति दें और फिर कोशिश करें।",
|
||||
"groupCall.err.device_not_found": "कॉल शुरू नहीं हो सकी - इस डिवाइस पर कोई माइक्रोफ़ोन नहीं मिला।",
|
||||
"groupCall.err.device_busy": "कॉल शुरू नहीं हो सकी - आपका माइक्रोफ़ोन किसी अन्य ऐप के उपयोग में है। उसे बंद करके फिर कोशिश करें।",
|
||||
"groupCall.err.media_failed": "कॉल शुरू नहीं हो सकी - माइक्रोफ़ोन नहीं खुल सका।",
|
||||
"groupCall.err.call_in_progress": "इस समूह में पहले से एक कॉल चल रही है। नई शुरू करने के बजाय उसमें शामिल हों।",
|
||||
"groupCall.err.not_ready": "कॉल करने से पहले समूह कोड की पुष्टि करें।",
|
||||
"groupCall.speaking": "{name} बोल रहे हैं",
|
||||
"groupCall.pin": "{name} को बड़ा दिखाएँ",
|
||||
"groupCall.unpin": "सभी पर लौटें",
|
||||
"groupCall.showEveryone": "सभी"
|
||||
},
|
||||
"manifest": {
|
||||
"name": "SecureBit.chat - निजी, एन्क्रिप्टेड मैसेंजर",
|
||||
|
||||
+26
-1
@@ -31,6 +31,9 @@
|
||||
},
|
||||
"ui": {
|
||||
"language.label": "언어",
|
||||
"language.suggest.text": "이 페이지는 한국어로도 볼 수 있습니다.",
|
||||
"language.suggest.cta": "한국어로 보기",
|
||||
"language.suggest.dismiss": "닫기",
|
||||
"community.title": "프라이버시의 미래를 함께 만듭니다",
|
||||
"community.description": "SecureBit은 커뮤니티 덕분에 자랍니다. 여러분의 아이디어와 의견이 안전한 소통의 미래를 만듭니다 - 모든 과정이 공개되어 있고, 전 구간에서 ASN.1 검증을 수행합니다.",
|
||||
"community.github": "GitHub 저장소",
|
||||
@@ -776,7 +779,29 @@
|
||||
"offline.disconnect": "연결 끊기",
|
||||
"offline.learnMore": "자세히 보기",
|
||||
"pwa.installApp": "앱 설치",
|
||||
"chat.onWeb": "웹 버전을 쓰고 있습니다"
|
||||
"chat.onWeb": "웹 버전을 쓰고 있습니다",
|
||||
"groupCall.startVoice": "그룹 음성 통화 시작",
|
||||
"groupCall.startVideo": "그룹 영상 통화 시작",
|
||||
"groupCall.join": "참여",
|
||||
"groupCall.dismiss": "나중에",
|
||||
"groupCall.leave": "통화 나가기",
|
||||
"groupCall.you": "나",
|
||||
"groupCall.connecting": "연결 중…",
|
||||
"groupCall.waitingLink": "직접 연결을 기다리는 중…",
|
||||
"groupCall.legFailed": "연결하지 못했습니다",
|
||||
"groupCall.startedVoice": "{name} 님이 음성 통화를 시작했습니다",
|
||||
"groupCall.startedVideo": "{name} 님이 영상 통화를 시작했습니다",
|
||||
"groupCall.inCall": "통화 중 {count}명",
|
||||
"groupCall.err.permission_denied": "통화를 시작할 수 없습니다 - 마이크와 카메라 접근이 차단되어 있습니다. 이 사이트에 허용한 뒤 다시 시도하세요.",
|
||||
"groupCall.err.device_not_found": "통화를 시작할 수 없습니다 - 이 기기에서 마이크를 찾을 수 없습니다.",
|
||||
"groupCall.err.device_busy": "통화를 시작할 수 없습니다 - 마이크를 다른 앱이 사용 중입니다. 종료한 뒤 다시 시도하세요.",
|
||||
"groupCall.err.media_failed": "통화를 시작할 수 없습니다 - 마이크를 열지 못했습니다.",
|
||||
"groupCall.err.call_in_progress": "이 그룹에서 이미 통화가 진행 중입니다. 새로 시작하지 말고 참여하세요.",
|
||||
"groupCall.err.not_ready": "통화하기 전에 그룹 코드를 확인하세요.",
|
||||
"groupCall.speaking": "{name} 님이 말하는 중",
|
||||
"groupCall.pin": "{name} 크게 보기",
|
||||
"groupCall.unpin": "전체 보기로 돌아가기",
|
||||
"groupCall.showEveryone": "전체"
|
||||
},
|
||||
"manifest": {
|
||||
"name": "SecureBit.chat - 종단 간 암호화 비공개 메신저",
|
||||
|
||||
+26
-1
@@ -36,6 +36,9 @@
|
||||
},
|
||||
"ui": {
|
||||
"language.label": "Язык",
|
||||
"language.suggest.text": "Эта страница также доступна на русском.",
|
||||
"language.suggest.cta": "Читать по-русски",
|
||||
"language.suggest.dismiss": "Закрыть",
|
||||
"community.title": "Присоединяйтесь к будущему приватности",
|
||||
"community.description": "SecureBit растёт благодаря сообществу. Ваши идеи и отзывы формируют будущее защищённого общения - разработка ведётся открыто, с полной проверкой ASN.1 на всём пути.",
|
||||
"community.github": "Репозиторий на GitHub",
|
||||
@@ -781,6 +784,28 @@
|
||||
"offline.disconnect": "Отключиться",
|
||||
"offline.learnMore": "Подробнее",
|
||||
"pwa.installApp": "Установить приложение",
|
||||
"chat.onWeb": "Вы в веб-версии"
|
||||
"chat.onWeb": "Вы в веб-версии",
|
||||
"groupCall.startVoice": "Начать групповой голосовой звонок",
|
||||
"groupCall.startVideo": "Начать групповой видеозвонок",
|
||||
"groupCall.join": "Присоединиться",
|
||||
"groupCall.dismiss": "Не сейчас",
|
||||
"groupCall.leave": "Выйти из звонка",
|
||||
"groupCall.you": "Вы",
|
||||
"groupCall.connecting": "Соединение…",
|
||||
"groupCall.waitingLink": "Ожидание прямого соединения…",
|
||||
"groupCall.legFailed": "Не удалось соединиться",
|
||||
"groupCall.startedVoice": "{name} начал голосовой звонок",
|
||||
"groupCall.startedVideo": "{name} начал видеозвонок",
|
||||
"groupCall.inCall": "{count} в звонке",
|
||||
"groupCall.err.permission_denied": "Звонок не начался - доступ к микрофону и камере заблокирован. Разрешите его для этого сайта и попробуйте снова.",
|
||||
"groupCall.err.device_not_found": "Звонок не начался - на этом устройстве не найден микрофон.",
|
||||
"groupCall.err.device_busy": "Звонок не начался - микрофон занят другим приложением. Закройте его и попробуйте снова.",
|
||||
"groupCall.err.media_failed": "Звонок не начался - не удалось открыть микрофон.",
|
||||
"groupCall.err.call_in_progress": "В этой группе уже идёт звонок. Присоединитесь к нему, а не начинайте новый.",
|
||||
"groupCall.err.not_ready": "Сначала подтвердите код группы.",
|
||||
"groupCall.speaking": "{name} говорит",
|
||||
"groupCall.pin": "Показать {name} крупно",
|
||||
"groupCall.unpin": "Вернуться ко всем",
|
||||
"groupCall.showEveryone": "Все"
|
||||
}
|
||||
}
|
||||
|
||||
+26
-1
@@ -36,6 +36,9 @@
|
||||
},
|
||||
"ui": {
|
||||
"language.label": "Мова",
|
||||
"language.suggest.text": "Ця сторінка також доступна українською.",
|
||||
"language.suggest.cta": "Читати українською",
|
||||
"language.suggest.dismiss": "Закрити",
|
||||
"community.title": "Долучайтеся до майбутнього приватності",
|
||||
"community.description": "SecureBit росте завдяки своїй спільноті. Ваші ідеї та відгуки формують майбутнє захищеного спілкування - розробка ведеться відкрито, з повною валідацією ASN.1 на всьому шляху.",
|
||||
"community.github": "Репозиторій на GitHub",
|
||||
@@ -781,6 +784,28 @@
|
||||
"offline.disconnect": "Від'єднатися",
|
||||
"offline.learnMore": "Докладніше",
|
||||
"pwa.installApp": "Встановити застосунок",
|
||||
"chat.onWeb": "Ви у вебверсії"
|
||||
"chat.onWeb": "Ви у вебверсії",
|
||||
"groupCall.startVoice": "Почати груповий голосовий дзвінок",
|
||||
"groupCall.startVideo": "Почати груповий відеодзвінок",
|
||||
"groupCall.join": "Приєднатися",
|
||||
"groupCall.dismiss": "Не зараз",
|
||||
"groupCall.leave": "Вийти з дзвінка",
|
||||
"groupCall.you": "Ви",
|
||||
"groupCall.connecting": "З'єднання…",
|
||||
"groupCall.waitingLink": "Очікування прямого з'єднання…",
|
||||
"groupCall.legFailed": "Не вдалося з'єднатися",
|
||||
"groupCall.startedVoice": "{name} почав голосовий дзвінок",
|
||||
"groupCall.startedVideo": "{name} почав відеодзвінок",
|
||||
"groupCall.inCall": "{count} у дзвінку",
|
||||
"groupCall.err.permission_denied": "Дзвінок не почався - доступ до мікрофона й камери заблоковано. Дозвольте його для цього сайту й спробуйте ще раз.",
|
||||
"groupCall.err.device_not_found": "Дзвінок не почався - на цьому пристрої не знайдено мікрофона.",
|
||||
"groupCall.err.device_busy": "Дзвінок не почався - мікрофон зайнятий іншим застосунком. Закрийте його й спробуйте ще раз.",
|
||||
"groupCall.err.media_failed": "Дзвінок не почався - не вдалося відкрити мікрофон.",
|
||||
"groupCall.err.call_in_progress": "У цій групі вже триває дзвінок. Приєднайтеся до нього, а не починайте новий.",
|
||||
"groupCall.err.not_ready": "Спершу підтвердьте код групи.",
|
||||
"groupCall.speaking": "{name} говорить",
|
||||
"groupCall.pin": "Показати {name} великим",
|
||||
"groupCall.unpin": "Повернутися до всіх",
|
||||
"groupCall.showEveryone": "Усі"
|
||||
}
|
||||
}
|
||||
|
||||
+26
-1
@@ -36,6 +36,9 @@
|
||||
},
|
||||
"ui": {
|
||||
"language.label": "زبان",
|
||||
"language.suggest.text": "یہ صفحہ اردو میں بھی دستیاب ہے۔",
|
||||
"language.suggest.cta": "اردو میں پڑھیں",
|
||||
"language.suggest.dismiss": "بند کریں",
|
||||
"community.title": "نجیت کے مستقبل میں شامل ہوں",
|
||||
"community.description": "SecureBit اپنی برادری کی بدولت بڑھتا ہے۔ آپ کے خیالات اور آراء محفوظ رابطے کا مستقبل تشکیل دیتے ہیں - کھلے عام بنایا گیا، سرے سے سرے تک مکمل ASN.1 توثیق کے ساتھ۔",
|
||||
"community.github": "GitHub مخزن",
|
||||
@@ -781,6 +784,28 @@
|
||||
"offline.disconnect": "منقطع کریں",
|
||||
"offline.learnMore": "مزید جانیں",
|
||||
"pwa.installApp": "ایپ نصب کریں",
|
||||
"chat.onWeb": "آپ ویب پر ہیں"
|
||||
"chat.onWeb": "آپ ویب پر ہیں",
|
||||
"groupCall.startVoice": "گروپ وائس کال شروع کریں",
|
||||
"groupCall.startVideo": "گروپ ویڈیو کال شروع کریں",
|
||||
"groupCall.join": "شامل ہوں",
|
||||
"groupCall.dismiss": "ابھی نہیں",
|
||||
"groupCall.leave": "کال چھوڑیں",
|
||||
"groupCall.you": "آپ",
|
||||
"groupCall.connecting": "منسلک ہو رہا ہے…",
|
||||
"groupCall.waitingLink": "براہِ راست رابطے کا انتظار…",
|
||||
"groupCall.legFailed": "منسلک نہیں ہو سکا",
|
||||
"groupCall.startedVoice": "{name} نے وائس کال شروع کی",
|
||||
"groupCall.startedVideo": "{name} نے ویڈیو کال شروع کی",
|
||||
"groupCall.inCall": "کال میں {count}",
|
||||
"groupCall.err.permission_denied": "کال شروع نہیں ہو سکی - مائیکروفون اور کیمرے تک رسائی بند ہے۔ اس سائٹ کے لیے اجازت دیں اور دوبارہ کوشش کریں۔",
|
||||
"groupCall.err.device_not_found": "کال شروع نہیں ہو سکی - اس ڈیوائس پر کوئی مائیکروفون نہیں ملا۔",
|
||||
"groupCall.err.device_busy": "کال شروع نہیں ہو سکی - آپ کا مائیکروفون کسی اور ایپ کے زیرِ استعمال ہے۔ اسے بند کر کے دوبارہ کوشش کریں۔",
|
||||
"groupCall.err.media_failed": "کال شروع نہیں ہو سکی - مائیکروفون نہیں کھل سکا۔",
|
||||
"groupCall.err.call_in_progress": "اس گروپ میں پہلے ہی ایک کال جاری ہے۔ نئی شروع کرنے کے بجائے اس میں شامل ہوں۔",
|
||||
"groupCall.err.not_ready": "کال سے پہلے گروپ کوڈ کی تصدیق کریں۔",
|
||||
"groupCall.speaking": "{name} بول رہے ہیں",
|
||||
"groupCall.pin": "{name} کو بڑا دکھائیں",
|
||||
"groupCall.unpin": "سب پر واپس جائیں",
|
||||
"groupCall.showEveryone": "سب"
|
||||
}
|
||||
}
|
||||
|
||||
+26
-1
@@ -31,6 +31,9 @@
|
||||
},
|
||||
"ui": {
|
||||
"language.label": "语言",
|
||||
"language.suggest.text": "本页面也有简体中文版本。",
|
||||
"language.suggest.cta": "阅读简体中文",
|
||||
"language.suggest.dismiss": "关闭",
|
||||
"community.title": "一起构筑隐私的未来",
|
||||
"community.description": "SecureBit 因社区而成长。你的想法与反馈塑造着安全通讯的未来 —— 开放开发,全程完整的 ASN.1 校验。",
|
||||
"community.github": "GitHub 仓库",
|
||||
@@ -776,7 +779,29 @@
|
||||
"offline.disconnect": "断开连接",
|
||||
"offline.learnMore": "了解更多",
|
||||
"pwa.installApp": "安装应用",
|
||||
"chat.onWeb": "你正在使用网页版"
|
||||
"chat.onWeb": "你正在使用网页版",
|
||||
"groupCall.startVoice": "发起群组语音通话",
|
||||
"groupCall.startVideo": "发起群组视频通话",
|
||||
"groupCall.join": "加入",
|
||||
"groupCall.dismiss": "暂不加入",
|
||||
"groupCall.leave": "离开通话",
|
||||
"groupCall.you": "你",
|
||||
"groupCall.connecting": "连接中…",
|
||||
"groupCall.waitingLink": "正在等待直连…",
|
||||
"groupCall.legFailed": "无法连接",
|
||||
"groupCall.startedVoice": "{name} 发起了语音通话",
|
||||
"groupCall.startedVideo": "{name} 发起了视频通话",
|
||||
"groupCall.inCall": "{count} 人在通话中",
|
||||
"groupCall.err.permission_denied": "通话无法开始 - 麦克风和摄像头权限被拒绝。请为本站点允许后重试。",
|
||||
"groupCall.err.device_not_found": "通话无法开始 - 此设备上未找到麦克风。",
|
||||
"groupCall.err.device_busy": "通话无法开始 - 麦克风正被其他应用占用。请关闭后重试。",
|
||||
"groupCall.err.media_failed": "通话无法开始 - 无法打开麦克风。",
|
||||
"groupCall.err.call_in_progress": "该群组已有通话正在进行。请加入,而不是另开一个。",
|
||||
"groupCall.err.not_ready": "请先确认群组安全码再通话。",
|
||||
"groupCall.speaking": "{name} 正在讲话",
|
||||
"groupCall.pin": "放大显示 {name}",
|
||||
"groupCall.unpin": "返回所有人",
|
||||
"groupCall.showEveryone": "所有人"
|
||||
},
|
||||
"manifest": {
|
||||
"name": "SecureBit.chat - 私密的端到端加密通讯工具",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"version": "1788040116230",
|
||||
"buildVersion": "1788040116230",
|
||||
"appVersion": "6.4.0",
|
||||
"buildTime": "2026-08-29T21:48:36.295Z",
|
||||
"buildId": "1788040116230-98d42ac",
|
||||
"gitHash": "98d42ac",
|
||||
"version": "1788237895492",
|
||||
"buildVersion": "1788237895492",
|
||||
"appVersion": "6.6.6",
|
||||
"buildTime": "2026-09-01T04:44:55.550Z",
|
||||
"buildId": "1788237895492-5e32f54",
|
||||
"gitHash": "5e32f54",
|
||||
"generated": true,
|
||||
"generatedAt": "2026-08-29T21:48:36.297Z"
|
||||
"generatedAt": "2026-09-01T04:44:55.553Z"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "securebit-chat",
|
||||
"version": "6.4.0",
|
||||
"version": "6.6.6",
|
||||
"description": "Secure P2P Communication Application with End-to-End Encryption",
|
||||
"main": "index.html",
|
||||
"scripts": {
|
||||
@@ -12,7 +12,7 @@
|
||||
"dev": "npm run build && python -m http.server 8000",
|
||||
"watch": "npx tailwindcss -i src/styles/tw-input.css -o assets/tailwind.css --watch",
|
||||
"serve": "npx http-server -p 8000",
|
||||
"test": "node tests/sas-verification.test.mjs && node tests/verification-gate.test.mjs && node tests/inbound-frame-authentication.test.mjs && node tests/control-frame-authorization.test.mjs && node tests/security-level-shape.test.mjs && node tests/desktop-download-links.test.mjs && node tests/file-transfer-consent.test.mjs && node tests/incoming-message-sanitization.test.mjs && node tests/outgoing-message-integrity.test.mjs && node tests/secure-chat-features.test.mjs && node tests/notification-meta-forwarding.test.mjs && node tests/notification-ephemeral-privacy.test.mjs && node tests/key-derivation-compat.test.mjs && node tests/key-exchange-e2e.test.mjs && node tests/file-type-allowlist.test.mjs && node tests/voice-auto-accept.test.mjs && node tests/legacy-offer-purge.test.mjs && node tests/webrtc-privacy-mode.test.mjs && node tests/indexeddb-metadata-encryption.test.mjs && node tests/disconnect-cleanup.test.mjs && node tests/timer-lifecycle.test.mjs && node tests/file-transfer-cleanup.test.mjs && node tests/file-transfer-ui-cleanup.test.mjs && node tests/file-transfer-callback-propagation.test.mjs && node tests/debug-window-hooks.test.mjs && node tests/inbound-message-rate-limit.test.mjs && node tests/file-transfer-chunk-rate-limit.test.mjs && node tests/ice-servers-validation.test.mjs && node tests/sessions-reducer.test.mjs && node tests/webrtc-sdp.test.mjs && node tests/webrtc-video.test.mjs && node tests/webrtc-adaptation.test.mjs && node tests/session-recovery.test.mjs && node tests/qr-zip-bomb.test.mjs && node tests/ice-gathering-patience.test.mjs && node tests/version-consistency.test.mjs && node tests/i18n-build.test.mjs && node tests/i18n-runtime.test.mjs && node tests/language-switcher.test.mjs && node tests/rtl-layout.test.mjs && node tests/asset-paths-locale-safe.test.mjs && node tests/double-ratchet.test.mjs && node tests/ratchet-integration.test.mjs && node tests/descriptor-sbq2.test.mjs && node tests/sbq2-key-exchange.test.mjs && node tests/qr-scan-single-frame.test.mjs && node tests/mobile-chat-layout.test.mjs && node tests/group-crypto.test.mjs && node tests/groups-reducer.test.mjs && node tests/group-session-e2e.test.mjs && node tests/group-app-integration.test.mjs && node tests/group-sender.test.mjs && node tests/group-mesh.test.mjs && node tests/apple-motion.test.mjs"
|
||||
"test": "node tests/sas-verification.test.mjs && node tests/verification-gate.test.mjs && node tests/inbound-frame-authentication.test.mjs && node tests/control-frame-authorization.test.mjs && node tests/security-level-shape.test.mjs && node tests/desktop-download-links.test.mjs && node tests/file-transfer-consent.test.mjs && node tests/incoming-message-sanitization.test.mjs && node tests/outgoing-message-integrity.test.mjs && node tests/secure-chat-features.test.mjs && node tests/notification-meta-forwarding.test.mjs && node tests/notification-ephemeral-privacy.test.mjs && node tests/key-derivation-compat.test.mjs && node tests/key-exchange-e2e.test.mjs && node tests/file-type-allowlist.test.mjs && node tests/voice-auto-accept.test.mjs && node tests/legacy-offer-purge.test.mjs && node tests/webrtc-privacy-mode.test.mjs && node tests/indexeddb-metadata-encryption.test.mjs && node tests/disconnect-cleanup.test.mjs && node tests/timer-lifecycle.test.mjs && node tests/file-transfer-cleanup.test.mjs && node tests/file-transfer-ui-cleanup.test.mjs && node tests/file-transfer-callback-propagation.test.mjs && node tests/debug-window-hooks.test.mjs && node tests/inbound-message-rate-limit.test.mjs && node tests/file-transfer-chunk-rate-limit.test.mjs && node tests/ice-servers-validation.test.mjs && node tests/sessions-reducer.test.mjs && node tests/webrtc-sdp.test.mjs && node tests/webrtc-video.test.mjs && node tests/webrtc-adaptation.test.mjs && node tests/session-recovery.test.mjs && node tests/qr-zip-bomb.test.mjs && node tests/ice-gathering-patience.test.mjs && node tests/version-consistency.test.mjs && node tests/i18n-build.test.mjs && node tests/i18n-runtime.test.mjs && node tests/language-switcher.test.mjs && node tests/language-suggestion.test.mjs && node tests/rtl-layout.test.mjs && node tests/asset-paths-locale-safe.test.mjs && node tests/double-ratchet.test.mjs && node tests/ratchet-integration.test.mjs && node tests/descriptor-sbq2.test.mjs && node tests/sbq2-key-exchange.test.mjs && node tests/qr-scan-single-frame.test.mjs && node tests/mobile-chat-layout.test.mjs && node tests/group-crypto.test.mjs && node tests/groups-reducer.test.mjs && node tests/group-session-e2e.test.mjs && node tests/group-app-integration.test.mjs && node tests/group-sender.test.mjs && node tests/group-mesh.test.mjs && node tests/group-call.test.mjs && node tests/group-call-autoanswer.test.mjs && node tests/apple-motion.test.mjs"
|
||||
},
|
||||
"keywords": [
|
||||
"p2p",
|
||||
|
||||
+24
-24
@@ -30,7 +30,7 @@
|
||||
|
||||
<!-- PWA Manifest -->
|
||||
<link rel="manifest" href="/ru/manifest.json">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788040116230">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788237895492">
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
@@ -96,7 +96,7 @@
|
||||
<link rel="apple-touch-startup-image" media="screen and (device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/logo/splash/splash_screens/8.3__iPad_Mini_portrait.png">
|
||||
|
||||
<!-- Apple Touch Icons -->
|
||||
<link rel="apple-touch-icon" href="/logo/icon-180x180.png?v=1788040116230">
|
||||
<link rel="apple-touch-icon" href="/logo/icon-180x180.png?v=1788237895492">
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="/logo/icon-57x57.png">
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="/logo/icon-60x60.png">
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="/logo/icon-72x72.png">
|
||||
@@ -105,7 +105,7 @@
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="/logo/icon-120x120.png">
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="/logo/icon-144x144.png">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="/logo/icon-152x152.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/logo/icon-180x180.png?v=1788040116230">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/logo/icon-180x180.png?v=1788237895492">
|
||||
|
||||
<!-- Microsoft Tiles -->
|
||||
<meta name="msapplication-TileColor" content="#ff6b35">
|
||||
@@ -222,7 +222,7 @@
|
||||
<!-- Render-blocking JS is deferred: classic deferred scripts and module scripts
|
||||
both execute in document order after parsing, so React still runs before the
|
||||
app modules below, but the parser / first paint is no longer blocked. -->
|
||||
<script defer src="/config/ice-servers.js?v=1788040116230"></script>
|
||||
<script defer src="/config/ice-servers.js?v=1788237895492"></script>
|
||||
<script defer src="/libs/react/react.production.min.js"></script>
|
||||
<script defer src="/libs/react-dom/react-dom.production.min.js"></script>
|
||||
<!-- Prism syntax highlighting (vendored, offline). Tokenizes code as TEXT only —
|
||||
@@ -230,8 +230,8 @@
|
||||
Its CSS is loaded async via load-async-css.js (not paint-critical). -->
|
||||
<script defer src="/libs/prism/prism.js"></script>
|
||||
<!-- Critical, paint-defining CSS stays render-blocking (avoids FOUC / layout shift). -->
|
||||
<link rel="stylesheet" href="/assets/tailwind.css?v=1788040116230">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788040116230">
|
||||
<link rel="stylesheet" href="/assets/tailwind.css?v=1788237895492">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788237895492">
|
||||
<!-- Preload only the fonts needed for first paint. fa-solid covers the bulk of UI
|
||||
icons; fa-regular/fa-brands are loaded on demand by their CSS (rarely on the
|
||||
first screen). Inter latin 400/700 cover body text and headings/buttons. -->
|
||||
@@ -239,38 +239,38 @@
|
||||
<link rel="preload" href="/assets/fonts/inter/files/inter-latin-400.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="/assets/fonts/inter/files/inter-latin-700.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="stylesheet" href="/assets/fonts/inter/inter.css">
|
||||
<link rel="stylesheet" href="/src/styles/main.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/animations.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/components.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/main.css?v=1788237895492">
|
||||
<link rel="stylesheet" href="/src/styles/animations.css?v=1788237895492">
|
||||
<link rel="stylesheet" href="/src/styles/components.css?v=1788237895492">
|
||||
<!-- Loads after components.css on purpose: press feedback, material weight,
|
||||
size-specific tracking and the reduced-motion / -transparency / -contrast
|
||||
answers all need to settle arguments with the sheets above on source order. -->
|
||||
<link rel="stylesheet" href="/src/styles/apple-motion.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/apple-motion.css?v=1788237895492">
|
||||
<!-- Last of the hand-written sheets: the mirroring rules must win over anything
|
||||
above them, and they only ever apply under [dir="rtl"]. -->
|
||||
<link rel="stylesheet" href="/src/styles/rtl.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/rtl.css?v=1788237895492">
|
||||
<!-- Non-critical CSS (FontAwesome ~102KB, Prism) loaded async — no longer blocks paint. -->
|
||||
<script defer src="/src/scripts/load-async-css.js?v=1788040116230"></script>
|
||||
<script defer src="/src/scripts/load-async-css.js?v=1788237895492"></script>
|
||||
<noscript>
|
||||
<link rel="stylesheet" href="/assets/fontawesome/css/all.min.css">
|
||||
<link rel="stylesheet" href="/libs/prism/prism.css">
|
||||
</noscript>
|
||||
<script defer src="/src/scripts/fa-check.js?v=1788040116230"></script>
|
||||
<script defer src="/src/scripts/fa-check.js?v=1788237895492"></script>
|
||||
<!-- Update Manager - система принудительного обновления -->
|
||||
<script defer src="/src/utils/updateManager.js?v=1788040116230"></script>
|
||||
<script type="module" src="/src/components/UpdateChecker.jsx?v=1788040116230"></script>
|
||||
<script type="module" src="/dist/qr-local.js?v=1788040116230"></script>
|
||||
<script type="module" src="/src/components/QRScanner.js?v=1788040116230"></script>
|
||||
<script defer src="/src/utils/updateManager.js?v=1788237895492"></script>
|
||||
<script type="module" src="/src/components/UpdateChecker.jsx?v=1788237895492"></script>
|
||||
<script type="module" src="/dist/qr-local.js?v=1788237895492"></script>
|
||||
<script type="module" src="/src/components/QRScanner.js?v=1788237895492"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/dist/app-boot.js?v=1788040116230"></script>
|
||||
<script type="module" src="/dist/app.js?v=1788040116230"></script>
|
||||
<script type="module" src="/dist/app-boot.js?v=1788237895492"></script>
|
||||
<script type="module" src="/dist/app.js?v=1788237895492"></script>
|
||||
|
||||
<script defer src="/src/scripts/pwa-register.js?v=1788040116230"></script>
|
||||
<script src="/src/pwa/install-prompt.js?v=1788040116230" type="module"></script>
|
||||
<script src="/src/pwa/pwa-manager.js?v=1788040116230" type="module"></script>
|
||||
<script defer src="/src/scripts/pwa-offline-test.js?v=1788040116230"></script>
|
||||
<link rel="stylesheet" href="/src/styles/pwa.css?v=1788040116230">
|
||||
<script defer src="/src/scripts/pwa-register.js?v=1788237895492"></script>
|
||||
<script src="/src/pwa/install-prompt.js?v=1788237895492" type="module"></script>
|
||||
<script src="/src/pwa/pwa-manager.js?v=1788237895492" type="module"></script>
|
||||
<script defer src="/src/scripts/pwa-offline-test.js?v=1788237895492"></script>
|
||||
<link rel="stylesheet" href="/src/styles/pwa.css?v=1788237895492">
|
||||
</body>
|
||||
</html>
|
||||
+323
-15
@@ -26,6 +26,7 @@ import { GROUP_LIMITS } from './group/groupCrypto.js';
|
||||
import { createGroupSender } from './group/groupSender.js';
|
||||
import { spring, snapTarget, rubberband, velocityTracker, prefersReducedMotion, SPRING } from './ui/motion.js';
|
||||
import { t, direction, LTR_TEXT } from './i18n/index.js';
|
||||
import { LanguageSuggestion } from './components/ui/LanguageSuggestion.jsx';
|
||||
|
||||
// +1 in a left-to-right locale, -1 in a right-to-left one. Only the handful of
|
||||
// horizontal transforms that CSS logical properties cannot express need it —
|
||||
@@ -37,6 +38,8 @@ const DIR = direction();
|
||||
// still lands in the same place — it just stops travelling to get there.
|
||||
const scrollBehavior = () => (prefersReducedMotion() ? 'auto' : 'smooth');
|
||||
import { GroupChatView, GroupSasModal, CreateGroupModal, GroupInviteModal, GroupErrorModal, AddMembersModal } from './components/ui/GroupChat.jsx';
|
||||
import { GroupCallUI } from './components/ui/GroupCallUI.jsx';
|
||||
import { GroupCallMedia, mediaErrorCode } from './group/groupCallMedia.js';
|
||||
|
||||
// ── Secure chat extras: code blocks, clipboard hygiene ──────────────
|
||||
// Copy text to the clipboard and (optionally) wipe it after a delay so
|
||||
@@ -372,6 +375,16 @@ import { GroupChatView, GroupSasModal, CreateGroupModal, GroupInviteModal, Group
|
||||
]);
|
||||
};
|
||||
|
||||
/**
|
||||
* How many digits a safety code has.
|
||||
*
|
||||
* Seven, everywhere: the pairwise code is `n % 10_000_000` padded to
|
||||
* seven (see the manager's SAS derivation) and the group code is derived
|
||||
* to the same length. It is stated once here so the two inputs that ask
|
||||
* for it cannot drift apart from each other or from the real code.
|
||||
*/
|
||||
const SAS_CODE_LENGTH = 7;
|
||||
|
||||
// Verification Component
|
||||
const VerificationStep = ({ verificationCode, onConfirm, onReject, localConfirmed, remoteConfirmed, bothConfirmed }) => {
|
||||
const [sasInput, setSasInput] = React.useState('');
|
||||
@@ -451,18 +464,28 @@ import { GroupChatView, GroupSasModal, CreateGroupModal, GroupInviteModal, Group
|
||||
type: 'text',
|
||||
dir: 'ltr',
|
||||
value: sasInput,
|
||||
// The safety code is seven DIGITS and has been for as
|
||||
// long as it has existed (see the manager's SAS
|
||||
// derivation). Asking for text opened a full QWERTY
|
||||
// keyboard on a phone, where the digits are a shift
|
||||
// away — a keyboard for characters that can never be
|
||||
// part of the answer. Non-digits are stripped rather
|
||||
// than rejected so a pasted "123-4567" still works.
|
||||
onChange: (event) => {
|
||||
setSasInput(event.target.value.toUpperCase());
|
||||
setSasInput(event.target.value.replace(/\D/g, '').slice(0, SAS_CODE_LENGTH));
|
||||
if (error) setError('');
|
||||
},
|
||||
autoFocus: true,
|
||||
autoComplete: 'off',
|
||||
autoComplete: 'one-time-code',
|
||||
spellCheck: false,
|
||||
inputMode: 'text',
|
||||
type: 'text',
|
||||
inputMode: 'numeric',
|
||||
pattern: '[0-9]*',
|
||||
maxLength: SAS_CODE_LENGTH,
|
||||
disabled: localConfirmed,
|
||||
placeholder: verificationCode ? t('verify.placeholder') : t('verify.waiting'),
|
||||
className: "w-full rounded-lg border border-purple-500/30 bg-black/20 px-4 py-3 text-center text-xl tracking-[0.3em] text-primary uppercase focus:border-purple-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60",
|
||||
style: { fontFamily: 'monospace', textTransform: 'uppercase' }
|
||||
className: "w-full rounded-lg border border-purple-500/30 bg-black/20 px-4 py-3 text-center text-xl tracking-[0.3em] text-primary focus:border-purple-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60",
|
||||
style: { fontFamily: 'monospace' }
|
||||
}),
|
||||
error && React.createElement('p', {
|
||||
key: 'sas-error',
|
||||
@@ -1338,7 +1361,9 @@ import { GroupChatView, GroupSasModal, CreateGroupModal, GroupInviteModal, Group
|
||||
setTimeout(() => setCopied(false), 1600);
|
||||
};
|
||||
|
||||
// SAS verification (alphanumeric, variable length — matches real codes)
|
||||
// SAS verification. The length is read off the code we were given
|
||||
// rather than assumed, so the button enables exactly when the input
|
||||
// is as long as the real answer.
|
||||
const normExpected = (verificationCode || '').replace(/[-\s]/g, '').length;
|
||||
const normInput = sasInput.replace(/[-\s]/g, '').length;
|
||||
const canConfirm = !localVerificationConfirmed && normExpected > 0 && normInput === normExpected;
|
||||
@@ -1549,7 +1574,7 @@ import { GroupChatView, GroupSasModal, CreateGroupModal, GroupInviteModal, Group
|
||||
])
|
||||
: h('div', { key: 'form' }, [
|
||||
h('div', { key: 'lbl', style: { fontSize: '12.5px', fontWeight: 600, color: '#9a9aa2', marginBottom: '8px' } }, t('verify.enterLabel')),
|
||||
h('input', { key: 'in', dir: 'ltr', value: sasInput, onChange: (e) => { setSasInput(e.target.value.toUpperCase()); if (sasError) setSasError(''); }, disabled: localVerificationConfirmed, autoFocus: true, autoComplete: 'off', spellCheck: false, placeholder: verificationCode ? t('verify.placeholder') : t('verify.waiting'), style: { width: '100%', textAlign: 'center', letterSpacing: '6px', borderRadius: '12px', border: `1px solid ${sasInput.length ? (canConfirm || localVerificationConfirmed ? 'rgba(62,207,142,0.5)' : 'rgba(255,255,255,0.14)') : 'rgba(255,255,255,0.08)'}`, background: '#141416', color: '#f4f4f6', fontFamily: MONO, fontSize: '20px', fontWeight: 700, padding: '14px', outline: 'none', textTransform: 'uppercase', marginBottom: sasError ? '8px' : '16px' } }),
|
||||
h('input', { key: 'in', dir: 'ltr', value: sasInput, onChange: (e) => { setSasInput(e.target.value.replace(/\D/g, '').slice(0, SAS_CODE_LENGTH)); if (sasError) setSasError(''); }, disabled: localVerificationConfirmed, autoFocus: true, autoComplete: 'one-time-code', spellCheck: false, type: 'text', inputMode: 'numeric', pattern: '[0-9]*', maxLength: SAS_CODE_LENGTH, placeholder: verificationCode ? t('verify.placeholder') : t('verify.waiting'), style: { width: '100%', textAlign: 'center', letterSpacing: '6px', borderRadius: '12px', border: `1px solid ${sasInput.length ? (canConfirm || localVerificationConfirmed ? 'rgba(62,207,142,0.5)' : 'rgba(255,255,255,0.14)') : 'rgba(255,255,255,0.08)'}`, background: '#141416', color: '#f4f4f6', fontFamily: MONO, fontSize: '20px', fontWeight: 700, padding: '14px', outline: 'none', marginBottom: sasError ? '8px' : '16px' } }),
|
||||
sasError && h('p', { key: 'err', style: { color: '#e5727a', fontSize: '12.5px', margin: '0 0 16px' } }, sasError),
|
||||
h('div', { key: 'status', style: { display: 'flex', flexDirection: 'column', gap: '8px', marginBottom: '16px' } }, [
|
||||
h('div', { key: 'you', style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '11px 14px', borderRadius: '11px', border: '1px solid rgba(255,255,255,0.06)', background: '#141416' } }, [
|
||||
@@ -2095,7 +2120,11 @@ import { GroupChatView, GroupSasModal, CreateGroupModal, GroupInviteModal, Group
|
||||
'@media (max-width:480px){.sb-chat-header{padding-inline-end:12px !important;}}'
|
||||
} });
|
||||
const header = React.createElement('header', {
|
||||
key: 'hdr', className: 'sb-chat-header', style: { flex: 'none', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '24px', padding: '0 20px', height: '64px', borderBottom: '1px solid rgba(255,255,255,0.06)', background: 'rgba(18,18,20,0.72)', backdropFilter: 'blur(14px)', WebkitBackdropFilter: 'blur(14px)' }
|
||||
// --sb-safe-top is the iOS status-bar strip the installed web view draws
|
||||
// under (0 in a browser tab). It is added to BOTH the padding and the
|
||||
// height so the 64px of header content is untouched and only the
|
||||
// translucent bar grows upward to meet the top edge of the screen.
|
||||
key: 'hdr', className: 'sb-chat-header', style: { flex: 'none', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '24px', padding: 'var(--sb-safe-top, 0px) 20px var(--sb-bar-extra, 0px)', minHeight: 'calc(var(--sb-bar-h, 64px) + var(--sb-safe-top, 0px) + var(--sb-bar-extra, 0px))', boxSizing: 'border-box', borderBottom: '1px solid rgba(255,255,255,0.06)', background: 'rgba(18,18,20,0.72)', backdropFilter: 'blur(14px)', WebkitBackdropFilter: 'blur(14px)' }
|
||||
}, [
|
||||
headerResponsiveCss,
|
||||
// The SecureBit brand/logo lives in the left rail; this header identifies the
|
||||
@@ -2881,7 +2910,7 @@ import { GroupChatView, GroupSasModal, CreateGroupModal, GroupInviteModal, Group
|
||||
]);
|
||||
|
||||
const expandedInner = [
|
||||
h('div', { key: 'head', style: { flex: 'none', display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingBlock: 0, paddingInlineStart: '16px', paddingInlineEnd: '12px', height: '64px', borderBottom: '1px solid rgba(255,255,255,0.06)' } }, [
|
||||
h('div', { key: 'head', style: { flex: 'none', display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingBlock: 0, paddingInlineStart: '16px', paddingInlineEnd: '12px', height: 'var(--sb-bar-h, 64px)', borderBottom: '1px solid rgba(255,255,255,0.06)' } }, [
|
||||
h('div', { key: 'brand', style: { display: 'flex', alignItems: 'center', gap: '10px' } }, [brandMark(30), h('span', { key: 't', style: { fontSize: '15px', fontWeight: 800, letterSpacing: '-0.3px', color: '#f4f4f6' } }, 'SecureBit')]),
|
||||
collapseBtn(SB_SVG.chevL, t('chat.collapse'))
|
||||
]),
|
||||
@@ -2956,7 +2985,7 @@ import { GroupChatView, GroupSasModal, CreateGroupModal, GroupInviteModal, Group
|
||||
];
|
||||
|
||||
const railWidth = collapsed ? '72px' : '292px';
|
||||
const railStyle = { flex: 'none', width: railWidth, display: 'flex', flexDirection: 'column', alignItems: collapsed ? 'center' : 'stretch', background: '#0c0c0e', borderInlineEnd: '1px solid rgba(255,255,255,0.06)' };
|
||||
const railStyle = { flex: 'none', width: railWidth, display: 'flex', flexDirection: 'column', alignItems: collapsed ? 'center' : 'stretch', paddingTop: 'var(--sb-safe-top, 0px)', background: '#0c0c0e', borderInlineEnd: '1px solid rgba(255,255,255,0.06)' };
|
||||
const inner = collapsed ? collapsedInner : expandedInner;
|
||||
|
||||
return h(React.Fragment, null, [
|
||||
@@ -3038,11 +3067,11 @@ import { GroupChatView, GroupSasModal, CreateGroupModal, GroupInviteModal, Group
|
||||
onPointerUp: drawerUp,
|
||||
onPointerCancel: drawerUp,
|
||||
style: { position: 'fixed', inset: 0, zIndex: 60, background: 'rgba(6,6,8,0.6)', backdropFilter: 'blur(0px)', WebkitBackdropFilter: 'blur(0px)', opacity: 0, display: drawerMounted ? 'block' : 'none', touchAction: 'pan-y', willChange: 'opacity' }
|
||||
}, h('aside', { className: 'sb-mobile-drawer', ref: panelRef, onClick: (e) => e.stopPropagation(), style: { position: 'absolute', insetInlineStart: 0, top: 0, bottom: 0, width: 'min(292px, 86vw)', display: 'flex', flexDirection: 'column', background: '#0c0c0e', borderInlineEnd: '1px solid rgba(255,255,255,0.06)', boxShadow: '0 0 60px rgba(0,0,0,0.6)', touchAction: 'pan-y', willChange: 'transform' } }, [
|
||||
}, h('aside', { className: 'sb-mobile-drawer', ref: panelRef, onClick: (e) => e.stopPropagation(), style: { position: 'absolute', insetInlineStart: 0, top: 0, bottom: 0, width: 'min(292px, 86vw)', display: 'flex', flexDirection: 'column', paddingTop: 'var(--sb-safe-top, 0px)', background: '#0c0c0e', borderInlineEnd: '1px solid rgba(255,255,255,0.06)', boxShadow: '0 0 60px rgba(0,0,0,0.6)', touchAction: 'pan-y', willChange: 'transform' } }, [
|
||||
// Explicit close button — the drawer's own header only has a
|
||||
// "collapse" chevron (a desktop-rail action), so on mobile there was
|
||||
// no obvious way to dismiss it. This X closes the drawer reliably.
|
||||
h('button', { key: 'x', onClick: onCloseDrawer, title: t('chat.closeMenu'), 'aria-label': t('chat.closeMenu'), style: { position: 'absolute', top: '15px', insetInlineEnd: '13px', zIndex: 2, width: '34px', height: '34px', borderRadius: '9px', display: 'grid', placeItems: 'center', border: '1px solid rgba(255,255,255,0.1)', background: 'rgba(255,255,255,0.05)', color: '#cfcfd4', cursor: 'pointer' } }, h('i', { className: 'fas fa-xmark', style: { fontSize: '16px' } })),
|
||||
h('button', { key: 'x', onClick: onCloseDrawer, title: t('chat.closeMenu'), 'aria-label': t('chat.closeMenu'), style: { position: 'absolute', top: 'calc(15px + var(--sb-safe-top, 0px))', insetInlineEnd: '13px', zIndex: 2, width: '34px', height: '34px', borderRadius: '9px', display: 'grid', placeItems: 'center', border: '1px solid rgba(255,255,255,0.1)', background: 'rgba(255,255,255,0.05)', color: '#cfcfd4', cursor: 'pointer' } }, h('i', { className: 'fas fa-xmark', style: { fontSize: '16px' } })),
|
||||
expandedInner
|
||||
]))
|
||||
]);
|
||||
@@ -3099,6 +3128,24 @@ import { GroupChatView, GroupSasModal, CreateGroupModal, GroupInviteModal, Group
|
||||
const [showAddMembers, setShowAddMembers] = React.useState(false);
|
||||
const groupScrollRef = React.useRef(null);
|
||||
|
||||
// ---- Groups: calls ----
|
||||
//
|
||||
// Two pieces of state, deliberately separate. `groupCallState` is what
|
||||
// the GROUP says — who opened a call, who is in it — and it arrives as
|
||||
// signed frames that reach members we have no direct link to. `groupCallMedia`
|
||||
// is what THIS DEVICE has actually managed to connect, leg by leg, and it
|
||||
// can never be better than the mesh underneath it. Rendering them from one
|
||||
// object would mean either hiding a member the group can see or claiming a
|
||||
// connection this device does not have.
|
||||
const [groupCallState, setGroupCallState] = React.useState({}); // gid -> snapshot|null
|
||||
const [groupCallMediaState, setGroupCallMediaState] = React.useState({});
|
||||
/** gid -> GroupCallMedia. Held in a ref: it owns a live capture, not state. */
|
||||
const groupCallMediaRef = React.useRef(new Map());
|
||||
/** gid -> the call id this device declined, so a dismissed ring stays gone. */
|
||||
const [dismissedCalls, setDismissedCalls] = React.useState({});
|
||||
/** gid:callId already badged, so a badge is raised once per call, not per join. */
|
||||
const announcedCallsRef = React.useRef(new Set());
|
||||
|
||||
/**
|
||||
* Put group frames on the wire, paced and serialised per session.
|
||||
*
|
||||
@@ -3155,6 +3202,10 @@ import { GroupChatView, GroupSasModal, CreateGroupModal, GroupInviteModal, Group
|
||||
break;
|
||||
case 'members':
|
||||
groupsDispatch({ type: GA.SET_MEMBERS, id: gid, members: payload.members, epoch: payload.epoch });
|
||||
// A member whose direct link only just came up can now carry
|
||||
// media. Nothing about the call roster changed, so this is the
|
||||
// only event that tells us to build the leg.
|
||||
syncGroupCallMediaRef.current(gid, null);
|
||||
break;
|
||||
case 'roster':
|
||||
groupsDispatch({ type: GA.RENAME, id: gid, name: payload.name });
|
||||
@@ -3174,6 +3225,24 @@ import { GroupChatView, GroupSasModal, CreateGroupModal, GroupInviteModal, Group
|
||||
case 'confirmed':
|
||||
groupsDispatch({ type: GA.CONFIRM_SAS, id: gid });
|
||||
break;
|
||||
case 'call':
|
||||
setGroupCallState((current) => ({ ...current, [gid]: payload.call }));
|
||||
// The media layer follows the roster: a member who just joined
|
||||
// needs a leg, one who left needs theirs torn down, and a member
|
||||
// whose direct link only just came up needs one built now.
|
||||
syncGroupCallMediaRef.current(gid, payload.call);
|
||||
if (payload.call && !payload.call.joined && gid !== activeGroupIdRef.current) {
|
||||
// A call the user cannot see is still a call: badge the group
|
||||
// rather than let it ring in a window nobody has open. Once
|
||||
// per call — every later join and leave emits this event too,
|
||||
// and badging on each would count the room, not the call.
|
||||
const token = `${gid}:${payload.call.callId}`;
|
||||
if (!announcedCallsRef.current.has(token)) {
|
||||
announcedCallsRef.current.add(token);
|
||||
groupsDispatch({ type: GA.INCREMENT_UNREAD, id: gid });
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'message':
|
||||
groupsDispatch({
|
||||
type: GA.ADD_MESSAGE, id: gid,
|
||||
@@ -4406,7 +4475,24 @@ import { GroupChatView, GroupSasModal, CreateGroupModal, GroupInviteModal, Group
|
||||
dispatch({ type: SA.CLEAR_UNREAD, id });
|
||||
setSidebarDrawerOpen(false);
|
||||
}, []);
|
||||
/**
|
||||
* Start a new 1:1 chat and put it on screen.
|
||||
*
|
||||
* Clearing the active group is the part that matters. One conversation
|
||||
* owns the column at a time, and a group owns it unconditionally — so
|
||||
* from inside a group this button used to create a session that was
|
||||
* never rendered: no invitation to copy, no way to verify it, no sign
|
||||
* anything had happened at all.
|
||||
*
|
||||
* That made adding somebody to a group impossible in the one case where
|
||||
* you would want to. A group is built out of chats you have already
|
||||
* verified, so adding a member means first opening a chat with them —
|
||||
* and the button that opens one did nothing while a group was in front
|
||||
* of you. The admin was told there was nobody left to add, and the only
|
||||
* route to changing that was closed.
|
||||
*/
|
||||
const handleNewChat = React.useCallback(() => {
|
||||
groupsDispatch({ type: GA.SET_ACTIVE_GROUP, id: null });
|
||||
createSessionRef.current({ role: 'offer' });
|
||||
setSidebarDrawerOpen(false);
|
||||
}, []);
|
||||
@@ -4693,6 +4779,25 @@ import { GroupChatView, GroupSasModal, CreateGroupModal, GroupInviteModal, Group
|
||||
/** Tear a group down locally and tell whoever we can still reach. */
|
||||
const destroyGroup = React.useCallback((gid, { announce = true } = {}) => {
|
||||
const runtime = groupRuntimesRef.current.get(gid);
|
||||
// A group that is going away takes its call with it — and the capture
|
||||
// with that. Released before anything else, because a microphone left
|
||||
// running after the window it belonged to is gone is the one failure
|
||||
// here nobody would see happening.
|
||||
const media = groupCallMediaRef.current.get(gid);
|
||||
if (media) {
|
||||
groupCallMediaRef.current.delete(gid);
|
||||
media.leave().catch(() => {});
|
||||
}
|
||||
setGroupCallState((current) => {
|
||||
const next = { ...current };
|
||||
delete next[gid];
|
||||
return next;
|
||||
});
|
||||
setGroupCallMediaState((current) => {
|
||||
const next = { ...current };
|
||||
delete next[gid];
|
||||
return next;
|
||||
});
|
||||
// Drop the runtime from the registry first, so a new group with the
|
||||
// same people cannot collide with one that is still tearing down.
|
||||
groupRuntimesRef.current.delete(gid);
|
||||
@@ -4803,6 +4908,173 @@ import { GroupChatView, GroupSasModal, CreateGroupModal, GroupInviteModal, Group
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ---- Groups: call actions ----
|
||||
//
|
||||
// The split throughout: GroupSession decides WHO is in the call and tells
|
||||
// everyone in signed frames; GroupCallMedia connects the legs it can. A
|
||||
// failure in the second never rewrites the first — a member this device
|
||||
// cannot reach is still in the call, and is shown as such.
|
||||
|
||||
/** Reconcile one group's media legs against its call roster. */
|
||||
const syncGroupCallMedia = React.useCallback((gid, callOrNull) => {
|
||||
const media = groupCallMediaRef.current.get(gid);
|
||||
if (!media) return;
|
||||
const runtime = groupRuntimesRef.current.get(gid);
|
||||
const call = callOrNull !== null ? callOrNull : (runtime?.getCallSnapshot?.() || null);
|
||||
|
||||
// The call is over, or we are no longer in it: release the capture.
|
||||
// This is the ONLY place the microphone is let go on a state change,
|
||||
// so a call that ends while the tab is in the background still stops
|
||||
// recording.
|
||||
if (!call || !call.joined || call.callId !== media.callId) {
|
||||
groupCallMediaRef.current.delete(gid);
|
||||
media.leave().catch(() => {});
|
||||
setGroupCallMediaState((current) => {
|
||||
const next = { ...current };
|
||||
delete next[gid];
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
media.setPeers(call.participants);
|
||||
}, []);
|
||||
const syncGroupCallMediaRef = React.useRef(syncGroupCallMedia);
|
||||
syncGroupCallMediaRef.current = syncGroupCallMedia;
|
||||
|
||||
/**
|
||||
* Build the media controller for a group and capture the microphone.
|
||||
*
|
||||
* Capture only — no legs. Connecting them is a separate step because
|
||||
* the group has to be told we are in the call FIRST: attaching a leg
|
||||
* can place an offer straight away, and an offer that overtakes the
|
||||
* announcement lands on a peer who does not yet know this session is
|
||||
* a call leg, so they ring instead of answering. See setPeers.
|
||||
*/
|
||||
const openGroupCallMedia = React.useCallback(async (gid, call) => {
|
||||
const existing = groupCallMediaRef.current.get(gid);
|
||||
if (existing && existing.callId === call.callId) {
|
||||
existing.setPeers(call.participants);
|
||||
return existing;
|
||||
}
|
||||
if (existing) {
|
||||
groupCallMediaRef.current.delete(gid);
|
||||
await existing.leave().catch(() => {});
|
||||
}
|
||||
const media = new GroupCallMedia({
|
||||
getManager: (sessionId) => managersRef.current.get(sessionId)
|
||||
|| meshLinksRef.current.get(sessionId)?.manager
|
||||
|| null,
|
||||
onChange: (snapshot) => {
|
||||
setGroupCallMediaState((current) => ({ ...current, [gid]: snapshot }));
|
||||
},
|
||||
});
|
||||
groupCallMediaRef.current.set(gid, media);
|
||||
await media.join({
|
||||
callId: call.callId,
|
||||
selfFp: groupsState.groups[gid]?.selfFp || groupRuntimesRef.current.get(gid)?.selfFp || '',
|
||||
withVideo: call.withVideo,
|
||||
peers: [], // legs are connected after the announcement
|
||||
});
|
||||
return media;
|
||||
}, [groupsState]);
|
||||
|
||||
/**
|
||||
* Open a call: capture first, announce second.
|
||||
*
|
||||
* The order is the whole point, and it is why startCall takes a `prepare`
|
||||
* hook rather than the app calling the two in sequence. Ringing everybody
|
||||
* else's device and only then discovering this device has no microphone
|
||||
* would make one person's permission dialog into everyone's interruption.
|
||||
*/
|
||||
const handleStartGroupCall = React.useCallback(async (withVideo) => {
|
||||
const gid = activeGroupIdRef.current;
|
||||
const runtime = gid && groupRuntimesRef.current.get(gid);
|
||||
if (!runtime) return;
|
||||
try {
|
||||
await runtime.startCall({
|
||||
withVideo,
|
||||
prepare: (call) => openGroupCallMedia(gid, call),
|
||||
});
|
||||
} catch (error) {
|
||||
// The runtime has already rolled the call back; drop whatever the
|
||||
// capture managed to build so no leg outlives it.
|
||||
const media = groupCallMediaRef.current.get(gid);
|
||||
if (media) { groupCallMediaRef.current.delete(gid); media.leave().catch(() => {}); }
|
||||
const code = error?.code || 'media_failed';
|
||||
groupsDispatch({
|
||||
type: GA.ADD_MESSAGE, id: gid,
|
||||
message: buildGroupMessage(
|
||||
code === 'call_in_progress' ? t('groupCall.err.call_in_progress')
|
||||
: code === 'not_ready' ? t('groupCall.err.not_ready')
|
||||
: t(`groupCall.err.${mediaErrorCode(error)}`),
|
||||
'system'
|
||||
)
|
||||
});
|
||||
}
|
||||
}, [openGroupCallMedia]);
|
||||
|
||||
const handleJoinGroupCall = React.useCallback(async () => {
|
||||
const gid = activeGroupIdRef.current;
|
||||
const runtime = gid && groupRuntimesRef.current.get(gid);
|
||||
const call = runtime?.getCallSnapshot?.();
|
||||
if (!runtime || !call) return;
|
||||
try {
|
||||
// Capture, announce, then connect — in that order. Capturing
|
||||
// first means a device with no microphone never tells the group
|
||||
// it joined; announcing before connecting means our offer cannot
|
||||
// reach a peer who has not yet heard that we are in the call.
|
||||
const media = await openGroupCallMedia(gid, { ...call, joined: true });
|
||||
await runtime.joinCall();
|
||||
media.setPeers(runtime.getCallSnapshot()?.participants || []);
|
||||
} catch (error) {
|
||||
const media = groupCallMediaRef.current.get(gid);
|
||||
if (media) { groupCallMediaRef.current.delete(gid); media.leave().catch(() => {}); }
|
||||
groupsDispatch({
|
||||
type: GA.ADD_MESSAGE, id: gid,
|
||||
message: buildGroupMessage(t(`groupCall.err.${mediaErrorCode(error)}`), 'system')
|
||||
});
|
||||
}
|
||||
}, [openGroupCallMedia]);
|
||||
|
||||
/**
|
||||
* Dismissing is local and silent.
|
||||
*
|
||||
* There is no "declined" to broadcast: in a group without a server nobody
|
||||
* is entitled to be told who chose not to pick up, and a decline frame
|
||||
* would leak exactly that. The call carries on for whoever is in it, and
|
||||
* this device simply stops showing the prompt.
|
||||
*/
|
||||
const handleDismissGroupCall = React.useCallback(() => {
|
||||
const gid = activeGroupIdRef.current;
|
||||
const call = gid && groupRuntimesRef.current.get(gid)?.getCallSnapshot?.();
|
||||
if (!call) return;
|
||||
setDismissedCalls((current) => ({ ...current, [gid]: call.callId }));
|
||||
}, []);
|
||||
|
||||
const handleLeaveGroupCall = React.useCallback(async () => {
|
||||
const gid = activeGroupIdRef.current;
|
||||
if (!gid) return;
|
||||
const media = groupCallMediaRef.current.get(gid);
|
||||
if (media) {
|
||||
groupCallMediaRef.current.delete(gid);
|
||||
setGroupCallMediaState((current) => {
|
||||
const next = { ...current };
|
||||
delete next[gid];
|
||||
return next;
|
||||
});
|
||||
await media.leave().catch(() => {});
|
||||
}
|
||||
try { await groupRuntimesRef.current.get(gid)?.leaveCall(); } catch (_) {}
|
||||
}, []);
|
||||
|
||||
const activeGroupMedia = React.useCallback(() => (
|
||||
activeGroupIdRef.current ? groupCallMediaRef.current.get(activeGroupIdRef.current) : null
|
||||
), []);
|
||||
|
||||
const handleGroupCallMic = React.useCallback(() => { activeGroupMedia()?.toggleMic(); }, [activeGroupMedia]);
|
||||
const handleGroupCallCamera = React.useCallback(() => { activeGroupMedia()?.toggleCamera(); }, [activeGroupMedia]);
|
||||
const handleGroupCallFlip = React.useCallback(() => { activeGroupMedia()?.flipCamera(); }, [activeGroupMedia]);
|
||||
|
||||
// Opening a group clears its badge; new traffic scrolls the transcript.
|
||||
React.useEffect(() => {
|
||||
if (!activeGroupId) return;
|
||||
@@ -4816,6 +5088,12 @@ import { GroupChatView, GroupSasModal, CreateGroupModal, GroupInviteModal, Group
|
||||
// Every group runtime is torn down with the app so no identity key or
|
||||
// ceremony nonce outlives the tab.
|
||||
React.useEffect(() => () => {
|
||||
// Captures first: a group call holds a live microphone, and it must not
|
||||
// outlive the app under any teardown order.
|
||||
for (const media of groupCallMediaRef.current.values()) {
|
||||
media.leave().catch(() => {});
|
||||
}
|
||||
groupCallMediaRef.current.clear();
|
||||
for (const runtime of groupRuntimesRef.current.values()) {
|
||||
try { runtime.destroy(); } catch (_) {}
|
||||
}
|
||||
@@ -6645,7 +6923,7 @@ import { GroupChatView, GroupSasModal, CreateGroupModal, GroupInviteModal, Group
|
||||
React.createElement('button', {
|
||||
key: 'burger', className: 'sb-burger',
|
||||
onClick: () => setSidebarDrawerOpen(true),
|
||||
style: { display: 'none', position: 'fixed', top: '13px', insetInlineStart: '13px', zIndex: 55, width: '38px', height: '38px', borderRadius: '10px', placeItems: 'center', border: '1px solid rgba(255,255,255,0.1)', background: 'rgba(18,18,20,0.9)', color: '#cfcfd4', cursor: 'pointer' },
|
||||
style: { display: 'none', position: 'fixed', top: 'calc(13px + var(--sb-safe-top, 0px))', insetInlineStart: '13px', zIndex: 55, width: '38px', height: '38px', borderRadius: '10px', placeItems: 'center', border: '1px solid rgba(255,255,255,0.1)', background: 'rgba(18,18,20,0.9)', color: '#cfcfd4', cursor: 'pointer' },
|
||||
dangerouslySetInnerHTML: { __html: SB_SVG.burger }
|
||||
}),
|
||||
React.createElement('div', {
|
||||
@@ -6745,7 +7023,7 @@ import { GroupChatView, GroupSasModal, CreateGroupModal, GroupInviteModal, Group
|
||||
key: 'sb-burger',
|
||||
className: 'sb-burger',
|
||||
onClick: () => setSidebarDrawerOpen(true),
|
||||
style: { display: 'none', position: 'fixed', top: '13px', insetInlineStart: '13px', zIndex: 55, width: '38px', height: '38px', borderRadius: '10px', placeItems: 'center', border: '1px solid rgba(255,255,255,0.1)', background: 'rgba(18,18,20,0.9)', color: '#cfcfd4', cursor: 'pointer' },
|
||||
style: { display: 'none', position: 'fixed', top: 'calc(13px + var(--sb-safe-top, 0px))', insetInlineStart: '13px', zIndex: 55, width: '38px', height: '38px', borderRadius: '10px', placeItems: 'center', border: '1px solid rgba(255,255,255,0.1)', background: 'rgba(18,18,20,0.9)', color: '#cfcfd4', cursor: 'pointer' },
|
||||
dangerouslySetInnerHTML: { __html: SB_SVG.burger }
|
||||
}),
|
||||
React.createElement('div', {
|
||||
@@ -6769,6 +7047,11 @@ import { GroupChatView, GroupSasModal, CreateGroupModal, GroupInviteModal, Group
|
||||
// sessionManager removed - all features enabled by default
|
||||
webrtcManager: webrtcManagerRef.current
|
||||
}),
|
||||
|
||||
// Offered, never forced: the visitor's language decides what this
|
||||
// bar says, and only a click on it changes the URL. Landing only,
|
||||
// for the same reason as the switcher — following it navigates.
|
||||
(!isConnectedAndVerified && !showSidebar) && React.createElement(LanguageSuggestion, { key: 'lang-suggest' }),
|
||||
|
||||
// A group takes the whole column when it is the active conversation.
|
||||
// It renders its own header and composer, so none of the 1:1 chrome
|
||||
@@ -6785,7 +7068,32 @@ import { GroupChatView, GroupSasModal, CreateGroupModal, GroupInviteModal, Group
|
||||
onRemoveMember: handleRemoveGroupMember,
|
||||
onAddMembers: () => setShowAddMembers(true),
|
||||
isAdmin: activeGroup.isAdmin,
|
||||
scrollRef: groupScrollRef
|
||||
scrollRef: groupScrollRef,
|
||||
onStartCall: handleStartGroupCall,
|
||||
callActive: !!groupCallState[activeGroup.id],
|
||||
// The overlay renders nothing when there is no call, and a
|
||||
// dismissed ring is treated as no call for this device only —
|
||||
// the call itself carries on for whoever is in it.
|
||||
callOverlay: (() => {
|
||||
const call = groupCallState[activeGroup.id] || null;
|
||||
if (!call) return null;
|
||||
if (!call.joined && dismissedCalls[activeGroup.id] === call.callId) return null;
|
||||
const media = groupCallMediaState[activeGroup.id] || null;
|
||||
return React.createElement(GroupCallUI, {
|
||||
key: 'group-call',
|
||||
call,
|
||||
media,
|
||||
groupName: activeGroup.name,
|
||||
localStream: groupCallMediaRef.current.get(activeGroup.id)?.getLocalStream() || null,
|
||||
getRemoteStream: (fp) => groupCallMediaRef.current.get(activeGroup.id)?.getRemoteStream(fp) || null,
|
||||
onJoin: handleJoinGroupCall,
|
||||
onDismiss: handleDismissGroupCall,
|
||||
onLeave: handleLeaveGroupCall,
|
||||
onToggleMic: handleGroupCallMic,
|
||||
onToggleCamera: handleGroupCallCamera,
|
||||
onFlipCamera: handleGroupCallFlip
|
||||
});
|
||||
})()
|
||||
})),
|
||||
|
||||
!activeGroup && React.createElement('main', {
|
||||
|
||||
@@ -87,6 +87,11 @@ const CallUIComponent = ({ webrtcManager, peerTitle }) => {
|
||||
React.useEffect(() => { if (phase === 'idle') setMinimized(false); }, [phase]);
|
||||
|
||||
if (!active || phase === 'idle' || phase === 'ended') return null;
|
||||
// One leg of a group call runs on this same session. The group's own surface
|
||||
// renders it as a tile alongside the others; a second full-screen 1:1 overlay
|
||||
// on top of that would be the same audio shown twice, with two hang-up
|
||||
// buttons that mean different things.
|
||||
if (call.groupCallId) return null;
|
||||
|
||||
const fmt = (s) => `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
|
||||
const ringing = phase === 'outgoing' || phase === 'connecting';
|
||||
|
||||
@@ -0,0 +1,575 @@
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { LEG_STATE } from '../../group/groupCallMedia.js';
|
||||
import { gridLayout, spotlightLayout, TILE_GAP } from './callLayout.js';
|
||||
// Group call surfaces: the ringing prompt, the tile grid, the control bar and
|
||||
// the minimized dock.
|
||||
//
|
||||
// It is deliberately the same visual language as the 1:1 call (CallUI.jsx) —
|
||||
// same icons, same control discs, same encrypted badge — because it is the same
|
||||
// thing happening more than once, and a second design would suggest otherwise.
|
||||
// What it adds is the part a group call has and a 1:1 call does not: a tile per
|
||||
// member, each carrying that member's own connection state, because in a mesh
|
||||
// call the connection is per pair and "the call is fine" is not a thing anybody
|
||||
// can say on behalf of everyone else.
|
||||
//
|
||||
// Purely presentational. All media and all signalling live in GroupCallMedia
|
||||
// and GroupSession; this reads a snapshot and calls back.
|
||||
|
||||
const h = (...args) => React.createElement(...args);
|
||||
const MONO = "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace";
|
||||
|
||||
const ICON = {
|
||||
lock: '<path d="M7 11V7a5 5 0 0 1 10 0v4"/><rect x="4.5" y="11" width="15" height="9" rx="2.2"/>',
|
||||
minimize: '<path d="M9 4v4a1 1 0 0 1-1 1H4M15 4v4a1 1 0 0 0 1 1h4M9 20v-4a1 1 0 0 0-1-1H4M15 20v-4a1 1 0 0 1 1-1h4"/>',
|
||||
expand: '<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/>',
|
||||
users: '<path d="M16 19v-1.5a3.5 3.5 0 0 0-3.5-3.5h-5A3.5 3.5 0 0 0 4 17.5V19"/><circle cx="10" cy="8" r="3.2"/><path d="M20 19v-1.5a3.5 3.5 0 0 0-2.6-3.4"/><path d="M15.5 5.3a3.2 3.2 0 0 1 0 5.4"/>',
|
||||
micOn: '<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0"/><path d="M12 18v3"/>',
|
||||
micOff: '<path d="M9 9v-1a3 3 0 0 1 5.1-2.1M15 11v3a3 3 0 0 1-4.6 2.5"/><path d="M5 11a7 7 0 0 0 10.3 6.2M19 11a7 7 0 0 1-.4 2.3"/><path d="M12 18v3"/><path d="M3 3l18 18"/>',
|
||||
camOn: '<path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2.5"/>',
|
||||
camOff: '<path d="M16 16H3a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h2l2-2M11 6h2l7-3v14M2 2l20 20"/>',
|
||||
grid: '<rect x="3" y="3" width="7.5" height="7.5" rx="1.6"/><rect x="13.5" y="3" width="7.5" height="7.5" rx="1.6"/><rect x="3" y="13.5" width="7.5" height="7.5" rx="1.6"/><rect x="13.5" y="13.5" width="7.5" height="7.5" rx="1.6"/>',
|
||||
flip: '<path d="M3 7h3l2-2h8l2 2h3v12H3z"/><path d="M9.5 13a2.5 2.5 0 0 1 5 0M14.5 13l-1.3-1.3M14.5 13l1.3-1.3"/>',
|
||||
phone: '<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.8 19.8 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6A19.8 19.8 0 0 1 2.12 4.18 2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.34 1.85.57 2.81.7A2 2 0 0 1 22 16.92z"/>',
|
||||
phoneHangup: '<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.8 19.8 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6A19.8 19.8 0 0 1 2.12 4.18 2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.34 1.85.57 2.81.7A2 2 0 0 1 22 16.92z" transform="rotate(135 12 12)"/>',
|
||||
};
|
||||
|
||||
const svg = (inner, size, sw) => h('span', {
|
||||
style: { display: 'grid', placeItems: 'center', width: size + 'px', height: size + 'px' },
|
||||
dangerouslySetInnerHTML: { __html: `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${sw}" stroke-linecap="round" stroke-linejoin="round">${inner}</svg>` },
|
||||
});
|
||||
|
||||
const ctrlBase = {
|
||||
width: '56px', height: '56px', borderRadius: '50%', display: 'grid', placeItems: 'center',
|
||||
border: '1px solid rgba(255,255,255,0.1)', background: 'rgba(255,255,255,0.05)',
|
||||
color: '#cfcfd4', cursor: 'pointer', transition: 'all .15s',
|
||||
};
|
||||
const dangerCtrl = { ...ctrlBase, background: '#e5484d', color: '#fff', border: '1px solid transparent' };
|
||||
const endBtn = {
|
||||
width: '56px', height: '56px', borderRadius: '50%', display: 'grid', placeItems: 'center',
|
||||
border: 'none', background: '#e5484d', color: '#fff', cursor: 'pointer',
|
||||
boxShadow: '0 8px 24px rgba(229,72,77,0.35)', transition: 'transform .15s',
|
||||
};
|
||||
const minimizeBtn = {
|
||||
width: '36px', height: '36px', borderRadius: '9px', display: 'grid', placeItems: 'center',
|
||||
border: '1px solid rgba(255,255,255,0.15)', background: 'rgba(0,0,0,0.35)',
|
||||
color: '#fff', cursor: 'pointer', transition: 'all .15s',
|
||||
};
|
||||
|
||||
const QUALITY = {
|
||||
excellent: { bars: 4, color: '#3ecf8e' },
|
||||
good: { bars: 3, color: '#3ecf8e' },
|
||||
fair: { bars: 2, color: '#e3c84e' },
|
||||
poor: { bars: 1, color: '#e5727a' },
|
||||
};
|
||||
|
||||
function qualityBars(quality) {
|
||||
const q = QUALITY[quality];
|
||||
if (!q) return null;
|
||||
return h('span', { key: 'q', style: { display: 'inline-flex', alignItems: 'flex-end', gap: '2px', height: '12px' } },
|
||||
[0, 1, 2, 3].map((i) => h('span', {
|
||||
key: i,
|
||||
style: {
|
||||
width: '2.5px', height: (4 + i * 2.6) + 'px', borderRadius: '1px',
|
||||
background: i < q.bars ? q.color : 'rgba(255,255,255,0.18)',
|
||||
},
|
||||
})));
|
||||
}
|
||||
|
||||
function initials(name) {
|
||||
const words = String(name || '').trim().split(/\s+/).filter(Boolean);
|
||||
return ((words[0]?.[0] || '') + (words[1]?.[0] || words[0]?.[1] || '')).toUpperCase() || '#';
|
||||
}
|
||||
|
||||
const fmt = (s) => `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
|
||||
|
||||
/**
|
||||
* The size of an element, kept current as it changes.
|
||||
*
|
||||
* The layout above needs real numbers, and the only honest source of those is
|
||||
* the element itself: the window's size says nothing about a panel inside a
|
||||
* sidebar layout, and a call that is resized — a window dragged wider, a phone
|
||||
* turned — has to re-solve rather than keep the size it was born with.
|
||||
*/
|
||||
function useMeasuredSize() {
|
||||
const [size, setSize] = React.useState({ w: 0, h: 0 });
|
||||
// A CALLBACK ref, not an object one, and this is the whole point of the hook.
|
||||
//
|
||||
// With `useRef` the effect's only dependency is the ref object, which never
|
||||
// changes — so the effect runs exactly once, on mount. The element it wants
|
||||
// to measure is inside a branch that mount does not always render: whoever
|
||||
// JOINS a call sees the "somebody is calling" prompt first, and there is no
|
||||
// stage in it. Their effect ran against `null`, never ran again, and the
|
||||
// size stayed 0×0 for the rest of the call while the person who STARTED the
|
||||
// call — mounted straight into the grid — measured fine. One user saw a
|
||||
// normal call and the other saw tiles collapsed to the width of their own
|
||||
// labels, from the same code.
|
||||
//
|
||||
// A callback ref is state: React calls it when the node attaches and again
|
||||
// when it detaches, so the effect re-runs the moment there is something to
|
||||
// measure. It also covers the same trip through the minimized dock, which
|
||||
// unmounts the stage and brings it back.
|
||||
const [node, setNode] = React.useState(null);
|
||||
React.useLayoutEffect(() => {
|
||||
if (!node) return undefined;
|
||||
const apply = () => setSize((prev) => (
|
||||
prev.w === node.clientWidth && prev.h === node.clientHeight
|
||||
? prev // same numbers, same object: no re-render
|
||||
: { w: node.clientWidth, h: node.clientHeight }
|
||||
));
|
||||
apply(); // before paint, so the first frame is already laid out
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
window.addEventListener('resize', apply);
|
||||
return () => window.removeEventListener('resize', apply);
|
||||
}
|
||||
const observer = new ResizeObserver(apply);
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, [node]);
|
||||
return [setNode, size];
|
||||
}
|
||||
|
||||
/**
|
||||
* One member's tile.
|
||||
*
|
||||
* A tile always says what THIS leg is doing, never what the call is doing. In a
|
||||
* mesh call one member can be connected while another is still being dialled,
|
||||
* and a single shared status line would have to lie about one of them.
|
||||
*/
|
||||
function Tile({ peer, stream, self, localStream, cameraEnabled, speaking, tileW, tileH, onSelect, pinned, compact }) {
|
||||
const videoRef = React.useRef(null);
|
||||
const source = self ? localStream : stream;
|
||||
const showVideo = self ? cameraEnabled : peer.hasVideo;
|
||||
|
||||
// Video only. Every tile's <video> is MUTED and the call's audio is played by
|
||||
// GroupCallMedia from elements that are not in this tree — a tile unmounts
|
||||
// whenever the user opens another chat, and a call must not go silent
|
||||
// because somebody looked at a different window.
|
||||
React.useEffect(() => {
|
||||
const el = videoRef.current;
|
||||
if (!el || !source) return;
|
||||
if (el.srcObject !== source) { el.muted = true; el.srcObject = source; }
|
||||
const played = el.play && el.play();
|
||||
if (played && played.catch) played.catch(() => {});
|
||||
});
|
||||
|
||||
// Nothing to say about our own tile: its name already reads "You", and the
|
||||
// status slot repeating it put the same word at both ends of the label row.
|
||||
const statusWord = self
|
||||
? null
|
||||
: peer.state === LEG_STATE.ACTIVE ? null
|
||||
: peer.state === LEG_STATE.UNREACHABLE ? t('groupCall.waitingLink')
|
||||
: peer.state === LEG_STATE.FAILED ? t('groupCall.legFailed')
|
||||
: t('groupCall.connecting');
|
||||
|
||||
const avatarPx = Math.max(44, Math.min(148, Math.round((tileH || 180) * 0.42)));
|
||||
|
||||
// The speaking ring is drawn as a border on the tile itself rather than as an
|
||||
// overlay, so it cannot be covered by the video and does not change layout
|
||||
// when it appears — a tile that resized every time somebody spoke would make
|
||||
// the whole grid twitch.
|
||||
return h('div', {
|
||||
// A tile is a control as well as a picture: clicking it spotlights that
|
||||
// person. Given a role and a key handler rather than wrapped in a button,
|
||||
// so a <video> is not nested inside interactive content.
|
||||
role: onSelect ? 'button' : undefined,
|
||||
tabIndex: onSelect ? 0 : undefined,
|
||||
onClick: onSelect,
|
||||
onKeyDown: onSelect ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(); } } : undefined,
|
||||
title: pinned ? t('groupCall.unpin', { name: peer.name })
|
||||
: onSelect ? t('groupCall.pin', { name: peer.name })
|
||||
: (speaking ? t('groupCall.speaking', { name: peer.name }) : peer.name),
|
||||
style: {
|
||||
cursor: onSelect ? 'pointer' : 'default',
|
||||
position: 'relative', borderRadius: '14px', overflow: 'hidden', background: '#141417',
|
||||
border: speaking ? '2px solid #3ecf8e' : '2px solid rgba(255,255,255,0.08)',
|
||||
boxShadow: speaking ? '0 0 0 3px rgba(62,207,142,0.16)' : 'none',
|
||||
transition: 'border-color .12s ease, box-shadow .12s ease',
|
||||
// Sized by the grid, which is the only thing that knows how much
|
||||
// room there is. aspectRatio stays as the fallback for the first
|
||||
// paint, before the container has been measured.
|
||||
// Measured: an exact size. Unmeasured: a shape that is still a tile.
|
||||
// The floor matters more than it looks — without a minimum a flex item
|
||||
// can shrink to its own text, and one bad containing block turned the
|
||||
// whole call into a cluster of label-sized rectangles. Measurement now
|
||||
// makes the layout GOOD; it is no longer what makes it work at all.
|
||||
flex: tileW ? '0 0 auto' : '1 1 260px',
|
||||
minWidth: tileW ? undefined : '200px',
|
||||
width: tileW ? tileW + 'px' : 'auto',
|
||||
height: tileH ? tileH + 'px' : 'auto',
|
||||
aspectRatio: tileH ? undefined : '16 / 9',
|
||||
display: 'grid', placeItems: 'center',
|
||||
},
|
||||
}, [
|
||||
showVideo
|
||||
? h('video', {
|
||||
key: 'v', ref: videoRef, autoPlay: true, muted: true, playsInline: true,
|
||||
style: {
|
||||
position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover',
|
||||
transform: self ? 'scaleX(-1)' : 'none', background: '#0f0f11',
|
||||
},
|
||||
})
|
||||
: h('div', {
|
||||
key: 'av',
|
||||
style: {
|
||||
// Proportional to the tile: a fixed disc looks lost in a
|
||||
// large tile and crowds a small one.
|
||||
width: avatarPx + 'px', height: avatarPx + 'px',
|
||||
borderRadius: '50%', display: 'grid', placeItems: 'center',
|
||||
background: 'radial-gradient(circle at 35% 30%, #2a2a30, #161618)',
|
||||
border: speaking ? '1.5px solid rgba(62,207,142,0.65)' : '1px solid rgba(255,255,255,0.1)',
|
||||
color: speaking ? '#3ecf8e' : '#cfcfd4',
|
||||
fontFamily: MONO, fontSize: Math.round(avatarPx * 0.3) + 'px', fontWeight: 700,
|
||||
transition: 'color .12s ease, border-color .12s ease',
|
||||
},
|
||||
}, initials(peer.name)),
|
||||
h('div', {
|
||||
key: 'label',
|
||||
style: {
|
||||
position: 'absolute', insetInline: 0, bottom: 0, display: 'flex', alignItems: 'center',
|
||||
gap: compact ? '4px' : '7px', padding: compact ? '4px 6px' : '8px 10px',
|
||||
background: 'linear-gradient(0deg, rgba(0,0,0,0.7), transparent)',
|
||||
},
|
||||
}, [
|
||||
h('span', {
|
||||
key: 'n',
|
||||
style: {
|
||||
flex: 1, minWidth: 0, fontSize: compact ? '10.5px' : '12.5px', fontWeight: 700, color: '#f4f4f6',
|
||||
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||||
},
|
||||
}, peer.name),
|
||||
speaking && h('span', {
|
||||
key: 'sp', style: { flex: 'none', color: '#3ecf8e', display: 'grid', placeItems: 'center' },
|
||||
}, svg(ICON.micOn, compact ? 11 : 13, 2)),
|
||||
(statusWord && !compact) && h('span', {
|
||||
key: 's',
|
||||
style: {
|
||||
flex: 'none', fontFamily: MONO, fontSize: '10.5px',
|
||||
color: peer.state === LEG_STATE.FAILED ? '#e5727a' : '#9a9aa2',
|
||||
},
|
||||
}, statusWord),
|
||||
(!self && !compact) && qualityBars(peer.quality),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole group-call surface.
|
||||
*
|
||||
* Three states, in the order they happen: a prompt when somebody else opened a
|
||||
* call we have not joined, the grid while we are in it, and a dock when the user
|
||||
* wants the transcript back without hanging up.
|
||||
*/
|
||||
export function GroupCallUI({
|
||||
call, media, groupName, localStream, getRemoteStream,
|
||||
onJoin, onDismiss, onLeave, onToggleMic, onToggleCamera, onFlipCamera,
|
||||
}) {
|
||||
// EVERY hook runs before EVERY early return below, without exception.
|
||||
// Measuring the stage was added underneath one of them, so the first render
|
||||
// that reached the grid ran more hooks than the render before it and React
|
||||
// refused it outright (#310, "rendered more hooks than during the previous
|
||||
// render") — which took the whole call down at the moment it started. The
|
||||
// early returns are what make this file easy to get wrong; keeping the hooks
|
||||
// in one block at the top is what makes it hard.
|
||||
const [minimized, setMinimized] = React.useState(false);
|
||||
const [seconds, setSeconds] = React.useState(0);
|
||||
const [pinned, setPinned] = React.useState(null);
|
||||
const [stageRef, stage] = useMeasuredSize();
|
||||
const joined = !!call?.joined;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!joined) { setSeconds(0); return undefined; }
|
||||
const started = Date.now();
|
||||
const iv = setInterval(() => setSeconds(Math.floor((Date.now() - started) / 1000)), 1000);
|
||||
return () => clearInterval(iv);
|
||||
}, [joined, call?.callId]);
|
||||
|
||||
React.useEffect(() => { if (!call) setMinimized(false); }, [!call]);
|
||||
|
||||
// A pin is dropped when the call ends, and when the person it points at
|
||||
// leaves — otherwise the stage would spotlight somebody who is not in the
|
||||
// call any more and the rest of the grid would look mysteriously short.
|
||||
const present = React.useMemo(
|
||||
() => new Set(['self', ...(media?.peers || []).map((p) => p.fp)]),
|
||||
[media],
|
||||
);
|
||||
React.useEffect(() => {
|
||||
if (pinned && !present.has(pinned)) setPinned(null);
|
||||
}, [pinned, present]);
|
||||
|
||||
if (!call) return null;
|
||||
|
||||
const encBadge = h('span', {
|
||||
key: 'enc',
|
||||
style: { display: 'inline-flex', alignItems: 'center', gap: '5px', fontSize: '11.5px', fontWeight: 600, color: '#3ecf8e' },
|
||||
}, [svg(ICON.lock, 12, 2), t('call.encryptedShort')]);
|
||||
|
||||
// ── somebody is calling the group and we have not joined ─────────────────
|
||||
if (!joined) {
|
||||
return h('div', {
|
||||
style: {
|
||||
position: 'absolute', inset: 0, zIndex: 40, display: 'flex', flexDirection: 'column',
|
||||
background: 'radial-gradient(680px 460px at 50% 36%, rgba(240,137,42,0.08), transparent 70%), #0d0d0f',
|
||||
animation: 'sbExpand .2s ease',
|
||||
},
|
||||
}, [
|
||||
h('div', { key: 'top', style: { flex: 'none', padding: '16px 18px' } },
|
||||
h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: '7px', fontSize: '12px', fontWeight: 600, color: '#3ecf8e' } },
|
||||
[svg(ICON.lock, 13, 2), t('call.encrypted')])),
|
||||
h('div', { key: 'mid', style: { flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '0 24px', textAlign: 'center' } }, [
|
||||
h('div', {
|
||||
key: 'av',
|
||||
style: {
|
||||
position: 'relative', width: '112px', height: '112px', marginBottom: '26px',
|
||||
display: 'grid', placeItems: 'center',
|
||||
},
|
||||
}, [
|
||||
h('span', { key: 'p1', style: { position: 'absolute', inset: 0, borderRadius: '50%', border: '1.5px solid rgba(240,137,42,0.5)', animation: 'sbCallPulse 2s ease-out infinite' } }),
|
||||
h('span', { key: 'p2', style: { position: 'absolute', inset: 0, borderRadius: '50%', border: '1.5px solid rgba(240,137,42,0.4)', animation: 'sbCallPulse 2s ease-out infinite', animationDelay: '1s' } }),
|
||||
h('div', {
|
||||
key: 'c',
|
||||
style: {
|
||||
width: '96px', height: '96px', borderRadius: '50%', display: 'grid', placeItems: 'center',
|
||||
background: 'radial-gradient(circle at 35% 30%, #2a2a30, #161618)',
|
||||
border: '1px solid rgba(255,255,255,0.1)', color: '#8a8a92',
|
||||
},
|
||||
}, svg(ICON.users, 42, 1.6)),
|
||||
]),
|
||||
h('div', { key: 'n', style: { fontSize: '22px', fontWeight: 800, letterSpacing: '-0.4px', color: '#f4f4f6' } }, groupName),
|
||||
h('div', { key: 's', style: { fontFamily: MONO, fontSize: '13.5px', color: '#9a9aa2', marginTop: '8px' } },
|
||||
call.withVideo
|
||||
? t('groupCall.startedVideo', { name: call.startedByName })
|
||||
: t('groupCall.startedVoice', { name: call.startedByName })),
|
||||
h('div', { key: 'p', style: { fontSize: '12.5px', color: '#6b6b73', marginTop: '6px' } },
|
||||
t('groupCall.inCall', { count: call.participants.length })),
|
||||
]),
|
||||
h('div', { key: 'ctrls', style: { flex: 'none', display: 'flex', justifyContent: 'center', gap: '48px', padding: '24px 24px 40px' } }, [
|
||||
h('div', { key: 'dec', style: { display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' } }, [
|
||||
h('button', { key: 'b', onClick: onDismiss, title: t('groupCall.dismiss'), style: { ...endBtn, width: '62px', height: '62px' } }, svg(ICON.phoneHangup, 24, 1.9)),
|
||||
h('span', { key: 'l', style: { fontFamily: MONO, fontSize: '10.5px', color: '#8a8a92' } }, t('groupCall.dismiss')),
|
||||
]),
|
||||
h('div', { key: 'acc', style: { display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' } }, [
|
||||
h('button', {
|
||||
key: 'b', onClick: onJoin, title: t('groupCall.join'),
|
||||
style: {
|
||||
width: '62px', height: '62px', borderRadius: '50%', display: 'grid', placeItems: 'center',
|
||||
border: 'none', background: '#3ecf8e', color: '#06231a', cursor: 'pointer',
|
||||
boxShadow: '0 8px 24px rgba(62,207,142,0.35)',
|
||||
},
|
||||
}, svg(ICON.phone, 24, 1.9)),
|
||||
h('span', { key: 'l', style: { fontFamily: MONO, fontSize: '10.5px', color: '#8a8a92' } }, t('groupCall.join')),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
const peers = media?.peers || [];
|
||||
const selfTile = {
|
||||
fp: 'self', name: t('groupCall.you'), state: LEG_STATE.ACTIVE,
|
||||
hasVideo: media?.cameraEnabled, quality: null, speaking: media?.selfSpeaking === true,
|
||||
};
|
||||
|
||||
// ── minimized dock ───────────────────────────────────────────────────────
|
||||
if (minimized) {
|
||||
// Collapsed, there are no tiles to carry the indicator, so the one line
|
||||
// the dock has says who is talking instead of how long the call has run.
|
||||
const speakers = peers.filter((p) => p.speaking).map((p) => p.name);
|
||||
const talking = speakers.length === 1 ? speakers[0]
|
||||
: speakers.length > 1 ? speakers.slice(0, 2).join(', ')
|
||||
: (media?.selfSpeaking ? t('groupCall.you') : null);
|
||||
return h('div', {
|
||||
style: {
|
||||
position: 'absolute', bottom: '18px', insetInlineEnd: '18px', zIndex: 40, width: '244px',
|
||||
borderRadius: '14px', overflow: 'hidden', background: '#161618',
|
||||
border: '1px solid rgba(255,255,255,0.1)', boxShadow: '0 18px 44px rgba(0,0,0,0.55)',
|
||||
animation: 'sbExpand .18s ease',
|
||||
},
|
||||
}, [
|
||||
h('div', { key: 'bar', style: { display: 'flex', alignItems: 'center', gap: '11px', padding: '11px 12px' } }, [
|
||||
h('span', {
|
||||
key: 'ic',
|
||||
style: {
|
||||
flex: 'none', width: '34px', height: '34px', borderRadius: '9px', display: 'grid',
|
||||
placeItems: 'center', background: 'rgba(62,207,142,0.1)',
|
||||
border: '1px solid rgba(62,207,142,0.25)', color: '#3ecf8e',
|
||||
},
|
||||
}, svg(ICON.users, 16, 1.9)),
|
||||
h('div', { key: 'tx', style: { flex: 1, minWidth: 0 } }, [
|
||||
h('div', { key: 'n', style: { fontSize: '13px', fontWeight: 700, color: '#f4f4f6', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' } }, groupName),
|
||||
h('div', {
|
||||
key: 's',
|
||||
style: {
|
||||
fontFamily: MONO, fontSize: '11px', color: talking ? '#3ecf8e' : '#9a9aa2',
|
||||
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||||
},
|
||||
}, talking
|
||||
? t('groupCall.speaking', { name: talking })
|
||||
: `${t('groupCall.inCall', { count: call.participants.length })} · ${fmt(seconds)}`),
|
||||
]),
|
||||
h('button', { key: 'exp', onClick: () => setMinimized(false), title: t('call.expand'), style: { flex: 'none', width: '32px', height: '32px', borderRadius: '8px', display: 'grid', placeItems: 'center', border: 'none', background: 'rgba(255,255,255,0.05)', color: '#cfcfd4', cursor: 'pointer' } }, svg(ICON.expand, 15, 2)),
|
||||
h('button', { key: 'end', onClick: onLeave, title: t('groupCall.leave'), style: { flex: 'none', width: '32px', height: '32px', borderRadius: '8px', display: 'grid', placeItems: 'center', border: 'none', background: '#e5484d', color: '#fff', cursor: 'pointer' } }, svg(ICON.phoneHangup, 15, 2)),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── the call itself ──────────────────────────────────────────────────────
|
||||
const tiles = [selfTile, ...peers];
|
||||
const tileFor = (id) => tiles.find((tile) => tile.fp === id) || null;
|
||||
const spotlight = pinned ? tileFor(pinned) : null;
|
||||
const others = spotlight ? tiles.filter((tile) => tile.fp !== spotlight.fp) : [];
|
||||
const spot = spotlight ? spotlightLayout(others.length, stage.w, stage.h) : null;
|
||||
// A stage too short to split honestly falls back to the gallery rather than
|
||||
// rendering a main tile with no height.
|
||||
const spotlit = spotlight && spot && spot.main.w > 0;
|
||||
const layout = gridLayout(tiles.length, stage.w, stage.h);
|
||||
|
||||
const renderTile = (tile, extra = {}) => h(Tile, {
|
||||
key: tile.fp,
|
||||
peer: tile,
|
||||
self: tile.fp === 'self',
|
||||
speaking: tile.speaking === true,
|
||||
localStream,
|
||||
cameraEnabled: media?.cameraEnabled,
|
||||
stream: tile.fp === 'self' ? null : getRemoteStream(tile.fp),
|
||||
...extra,
|
||||
});
|
||||
|
||||
return h('div', {
|
||||
style: {
|
||||
position: 'absolute', inset: 0, zIndex: 40, display: 'flex', flexDirection: 'column',
|
||||
background: '#0a0a0c', animation: 'sbExpand .2s ease',
|
||||
},
|
||||
}, [
|
||||
h('div', {
|
||||
key: 'top',
|
||||
style: {
|
||||
flex: 'none', display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between',
|
||||
gap: '14px', padding: '16px 18px 10px',
|
||||
},
|
||||
}, [
|
||||
h('div', { key: 'l', style: { minWidth: 0 } }, [
|
||||
h('div', { key: 'n', style: { fontSize: '17px', fontWeight: 800, letterSpacing: '-0.3px', color: '#fff', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, groupName),
|
||||
h('div', { key: 's', style: { display: 'inline-flex', alignItems: 'center', gap: '9px', marginTop: '4px' } }, [
|
||||
h('span', { key: 'd', style: { fontFamily: MONO, fontSize: '12.5px', color: '#e8e8eb' } }, fmt(seconds)),
|
||||
encBadge,
|
||||
h('span', { key: 'c', style: { fontSize: '11.5px', color: '#8a8a92' } },
|
||||
t('groupCall.inCall', { count: call.participants.length })),
|
||||
]),
|
||||
]),
|
||||
h('div', { key: 'r', style: { flex: 'none', display: 'flex', alignItems: 'center', gap: '8px' } }, [
|
||||
spotlit && h('button', {
|
||||
key: 'grid', onClick: () => setPinned(null), title: t('groupCall.showEveryone'),
|
||||
style: {
|
||||
height: '36px', padding: '0 12px', borderRadius: '9px', display: 'inline-flex',
|
||||
alignItems: 'center', gap: '7px', border: '1px solid rgba(255,255,255,0.15)',
|
||||
background: 'rgba(0,0,0,0.35)', color: '#fff', cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: '12.5px', fontWeight: 600,
|
||||
},
|
||||
}, [svg(ICON.grid, 14, 2), t('groupCall.showEveryone')]),
|
||||
h('button', { key: 'min', onClick: () => setMinimized(true), title: t('call.minimize'), style: minimizeBtn }, svg(ICON.minimize, 16, 2)),
|
||||
]),
|
||||
]),
|
||||
|
||||
media?.error && h('div', {
|
||||
key: 'err',
|
||||
style: {
|
||||
flex: 'none', margin: '0 18px 10px', padding: '9px 12px', borderRadius: '10px',
|
||||
background: 'rgba(229,114,122,0.1)', border: '1px solid rgba(229,114,122,0.28)',
|
||||
color: '#e5727a', fontSize: '12.5px', lineHeight: 1.5,
|
||||
},
|
||||
}, t(`groupCall.err.${media.error}`)),
|
||||
|
||||
// The stage is what gets measured, so it carries no padding of its own —
|
||||
// padding would be counted as usable room and every tile would come out
|
||||
// slightly too large for the space that is really there.
|
||||
h('div', {
|
||||
key: 'stage',
|
||||
ref: stageRef,
|
||||
className: 'msc-scroll',
|
||||
style: {
|
||||
flex: 1, minHeight: 0, minWidth: 0, overflow: 'auto',
|
||||
// Breathing room as MARGIN, not padding: clientWidth counts
|
||||
// padding as usable space and every tile would come out that
|
||||
// much too wide for the room actually available.
|
||||
margin: '0 14px 6px',
|
||||
// Flex, not grid. A grid with `justify-content: center` sizes its
|
||||
// column to the content, so the row's `width: 100%` had nothing
|
||||
// definite to resolve against and collapsed to the widest label —
|
||||
// which is exactly what the broken screenshot showed. A flex
|
||||
// container keeps a definite content box either way, so a row
|
||||
// asking for the full width gets the full width.
|
||||
// `safe center` centres it without the clipping plain centring
|
||||
// causes once the content overflows: the first row stays
|
||||
// reachable and the stage scrolls as normal.
|
||||
display: 'flex', alignItems: 'safe center', justifyContent: 'safe center',
|
||||
},
|
||||
}, spotlit
|
||||
// ── spotlight: one person large, the rest in a strip underneath ──
|
||||
? h('div', {
|
||||
style: {
|
||||
display: 'flex', flexDirection: 'column', gap: TILE_GAP + 'px',
|
||||
width: '100%', height: stage.h + 'px', minHeight: 0,
|
||||
},
|
||||
}, [
|
||||
h('div', {
|
||||
key: 'main',
|
||||
style: { flex: 'none', display: 'flex', justifyContent: 'center', minHeight: 0 },
|
||||
}, renderTile(spotlight, {
|
||||
tileW: spot.main.w, tileH: spot.main.h,
|
||||
pinned: true, onSelect: () => setPinned(null),
|
||||
})),
|
||||
others.length > 0 && h('div', {
|
||||
key: 'strip',
|
||||
className: 'msc-scroll',
|
||||
style: {
|
||||
flex: 'none', height: spot.stripH + 'px', display: 'flex',
|
||||
gap: TILE_GAP + 'px', justifyContent: others.length > 4 ? 'flex-start' : 'center',
|
||||
// The strip scrolls sideways rather than shrinking: past a
|
||||
// handful of people, thumbnails that keep dividing stop
|
||||
// being recognisable, which is the whole point of them.
|
||||
overflowX: 'auto', overflowY: 'hidden',
|
||||
},
|
||||
}, others.map((tile) => renderTile(tile, {
|
||||
tileW: spot.thumbW, tileH: spot.stripH, compact: true,
|
||||
onSelect: () => setPinned(tile.fp),
|
||||
}))),
|
||||
])
|
||||
// ── gallery: everyone the same size ─────────────────────────────
|
||||
: h('div', {
|
||||
// Wrapping flex rather than a grid, for one reason: a call of three
|
||||
// lays out two over one, and in a grid that lone tile is stuck in the
|
||||
// first column with a hole beside it. Fixing the row width to exactly
|
||||
// `cols` tiles makes the wrap land where the layout said it should,
|
||||
// and a short last row centres itself.
|
||||
style: {
|
||||
display: 'flex', flexWrap: 'wrap', gap: TILE_GAP + 'px',
|
||||
justifyContent: 'center', alignContent: 'center',
|
||||
width: layout.tileW
|
||||
? (layout.cols * layout.tileW + (layout.cols - 1) * TILE_GAP) + 'px'
|
||||
: '100%',
|
||||
},
|
||||
}, tiles.map((tile) => renderTile(tile, {
|
||||
tileW: layout.tileW, tileH: layout.tileH,
|
||||
onSelect: () => setPinned(tile.fp),
|
||||
})))),
|
||||
|
||||
h('div', {
|
||||
key: 'ctrls',
|
||||
style: {
|
||||
flex: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '16px',
|
||||
padding: '16px 20px calc(22px + var(--sb-safe-bottom, env(safe-area-inset-bottom, 0px)))',
|
||||
background: 'linear-gradient(0deg, rgba(0,0,0,0.6), transparent)',
|
||||
},
|
||||
}, [
|
||||
h('button', { key: 'mic', onClick: onToggleMic, title: t('call.mute'), style: media?.micEnabled ? ctrlBase : dangerCtrl },
|
||||
svg(media?.micEnabled ? ICON.micOn : ICON.micOff, 21, 1.9)),
|
||||
h('button', { key: 'cam', onClick: onToggleCamera, title: t('call.camera'), style: media?.cameraEnabled ? ctrlBase : dangerCtrl },
|
||||
svg(media?.cameraEnabled ? ICON.camOn : ICON.camOff, 21, 1.8)),
|
||||
media?.cameraEnabled && h('button', { key: 'flip', onClick: onFlipCamera, title: t('call.flipCamera'), style: ctrlBase }, svg(ICON.flip, 21, 1.8)),
|
||||
h('button', { key: 'end', onClick: onLeave, title: t('groupCall.leave'), style: endBtn }, svg(ICON.phoneHangup, 22, 1.9)),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.GroupCallUI = GroupCallUI;
|
||||
}
|
||||
@@ -37,6 +37,9 @@ const ICON = {
|
||||
x: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>',
|
||||
plus: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>',
|
||||
relay: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h4l3-7 4 14 3-7h2"/></svg>',
|
||||
phone: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.8 19.8 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6A19.8 19.8 0 0 1 2.12 4.18 2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.34 1.85.57 2.81.7A2 2 0 0 1 22 16.92z"/></svg>',
|
||||
exit: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 20H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h4"/><path d="m15 16 4-4-4-4"/><path d="M19 12H10"/></svg>',
|
||||
video: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2.5"/></svg>',
|
||||
};
|
||||
|
||||
const svg = (markup, extra = {}) => h('span', {
|
||||
@@ -584,6 +587,7 @@ function Bubble({ msg }) {
|
||||
|
||||
export function GroupChatView({
|
||||
group, input, setInput, onSend, onLeave, onRemoveMember, onAddMembers, isAdmin, scrollRef,
|
||||
onStartCall, callActive, callOverlay,
|
||||
}) {
|
||||
const ready = group.phase === GROUP_PHASE.READY && group.sasConfirmed;
|
||||
// Only members we are RELAYING to. A member who is offline is not relayed —
|
||||
@@ -599,14 +603,40 @@ export function GroupChatView({
|
||||
};
|
||||
|
||||
return h('div', {
|
||||
style: { display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0, background: C.bg },
|
||||
style: {
|
||||
position: 'relative', display: 'flex', flexDirection: 'column',
|
||||
height: '100%', minHeight: 0, background: C.bg,
|
||||
},
|
||||
}, [
|
||||
// Responsive rules the inline styles cannot express. The group header had
|
||||
// none, so on a phone the drawer hamburger — which is position:fixed at the
|
||||
// top-left corner — sat directly on top of the group's avatar and name. The
|
||||
// 1:1 header has always reserved that space; this is the same reservation,
|
||||
// plus the safe-area strip an installed web view draws under.
|
||||
h('style', {
|
||||
key: 'head-css',
|
||||
dangerouslySetInnerHTML: {
|
||||
__html: '@media (max-width:1023px){'
|
||||
+ '.sb-group-header{padding-inline-start:58px !important;gap:9px !important;}'
|
||||
+ '}'
|
||||
+ '@media (max-width:600px){'
|
||||
// Icon-only actions: three labelled buttons and a group name do not
|
||||
// fit a narrow screen, and the name is the part that must not be cut.
|
||||
+ '.sb-group-header .sb-gh-label{display:none !important;}'
|
||||
+ '.sb-group-header .sb-gh-btn{padding:8px 10px !important;}'
|
||||
+ '}',
|
||||
},
|
||||
}),
|
||||
|
||||
// header
|
||||
h('div', {
|
||||
key: 'head',
|
||||
className: 'sb-group-header',
|
||||
style: {
|
||||
flex: 'none', display: 'flex', alignItems: 'center', gap: '12px', padding: '0 16px',
|
||||
height: '64px', borderBottom: `1px solid ${C.line}`,
|
||||
flex: 'none', display: 'flex', alignItems: 'center', gap: '12px',
|
||||
padding: 'var(--sb-safe-top, 0px) 16px 0',
|
||||
minHeight: 'calc(64px + var(--sb-safe-top, 0px))',
|
||||
borderBottom: `1px solid ${C.line}`,
|
||||
},
|
||||
}, [
|
||||
h('span', {
|
||||
@@ -634,14 +664,40 @@ export function GroupChatView({
|
||||
: '',
|
||||
]),
|
||||
]),
|
||||
// Calls are offered only once the group is usable: a call before the
|
||||
// safety code is confirmed would be media on links nobody has
|
||||
// authenticated as belonging to this group.
|
||||
(ready && onStartCall) && h('button', {
|
||||
key: 'callvoice', className: 'sb-gh-btn', onClick: () => onStartCall(false), disabled: callActive,
|
||||
title: t('groupCall.startVoice'), 'aria-label': t('groupCall.startVoice'),
|
||||
style: {
|
||||
...btn(false), flex: 'none', padding: '8px 10px', opacity: callActive ? 0.4 : 1,
|
||||
cursor: callActive ? 'default' : 'pointer',
|
||||
},
|
||||
}, svg(ICON.phone, { key: 'i' })),
|
||||
(ready && onStartCall) && h('button', {
|
||||
key: 'callvideo', className: 'sb-gh-btn', onClick: () => onStartCall(true), disabled: callActive,
|
||||
title: t('groupCall.startVideo'), 'aria-label': t('groupCall.startVideo'),
|
||||
style: {
|
||||
...btn(false), flex: 'none', padding: '8px 10px', opacity: callActive ? 0.4 : 1,
|
||||
cursor: callActive ? 'default' : 'pointer',
|
||||
},
|
||||
}, svg(ICON.video, { key: 'i' })),
|
||||
(isAdmin && onAddMembers && group.members.length < GROUP_LIMITS.MAX_MEMBERS) && h('button', {
|
||||
key: 'add', onClick: onAddMembers, title: t('group.inviteMore'),
|
||||
style: { ...btn(false), padding: '8px 12px', fontSize: '12.5px' },
|
||||
}, [svg(ICON.plus, { key: 'i' }), t('group.add')]),
|
||||
key: 'add', className: 'sb-gh-btn', onClick: onAddMembers, title: t('group.inviteMore'),
|
||||
style: { ...btn(false), flex: 'none', padding: '8px 12px', fontSize: '12.5px' },
|
||||
}, [
|
||||
svg(ICON.plus, { key: 'i' }),
|
||||
h('span', { key: 'l', className: 'sb-gh-label' }, t('group.add')),
|
||||
]),
|
||||
h('button', {
|
||||
key: 'leave', onClick: onLeave, title: t('group.leaveThis'),
|
||||
style: { ...btn(false), padding: '8px 12px', fontSize: '12.5px', color: C.bad, borderColor: 'rgba(229,114,122,0.3)' },
|
||||
}, t('group.leave')),
|
||||
key: 'leave', className: 'sb-gh-btn', onClick: onLeave, title: t('group.leaveThis'),
|
||||
'aria-label': t('group.leaveThis'),
|
||||
style: { ...btn(false), flex: 'none', padding: '8px 12px', fontSize: '12.5px', color: C.bad, borderColor: 'rgba(229,114,122,0.3)' },
|
||||
}, [
|
||||
svg(ICON.exit, { key: 'i' }),
|
||||
h('span', { key: 'l', className: 'sb-gh-label' }, t('group.leave')),
|
||||
]),
|
||||
]),
|
||||
|
||||
h(MemberStrip, { key: 'strip', group, onRemove: onRemoveMember, isAdmin }),
|
||||
@@ -702,5 +758,9 @@ export function GroupChatView({
|
||||
},
|
||||
}, svg(ICON.send)),
|
||||
]),
|
||||
|
||||
// The group call covers the transcript when it is running and renders
|
||||
// nothing when it is not — the same shape as the 1:1 call overlay.
|
||||
callOverlay,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -546,12 +546,18 @@ const EnhancedMinimalHeader = ({
|
||||
// animated together so the bar *materialises* on scroll rather than fading in.
|
||||
const GLASS = 'blur(20px) saturate(180%)';
|
||||
const MATERIALISE = 'background .25s ease, backdrop-filter .25s ease, -webkit-backdrop-filter .25s ease, border-color .25s ease';
|
||||
const overlay = { position: 'fixed', top: 0, left: 0, right: 0 };
|
||||
// Installed on iOS the web view starts at the physical top of the screen, so a
|
||||
// bar pinned to top:0 sits under the clock and the notch. --sb-safe-top is that
|
||||
// strip (src/styles/pwa.css) and 0 in a browser tab, so it can be added
|
||||
// unconditionally: the bar grows by the inset and its contents clear the status
|
||||
// bar, while the translucent background still reaches the top edge.
|
||||
const safeTop = { paddingTop: 'var(--sb-safe-top, 0px)', paddingBottom: 'var(--sb-bar-extra, 0px)' };
|
||||
const overlay = { position: 'fixed', top: 0, left: 0, right: 0, ...safeTop };
|
||||
const headerStyle = onLanding
|
||||
? (scrolled
|
||||
? { ...overlay, background: 'rgba(15,15,17,0.72)', backdropFilter: GLASS, WebkitBackdropFilter: GLASS, borderBottom: '1px solid rgba(255,255,255,0.06)', transition: MATERIALISE }
|
||||
: { ...overlay, background: 'transparent', backdropFilter: 'blur(0px) saturate(100%)', WebkitBackdropFilter: 'blur(0px) saturate(100%)', borderBottom: '1px solid transparent', transition: MATERIALISE })
|
||||
: { background: 'rgba(18,18,20,0.72)', backdropFilter: GLASS, WebkitBackdropFilter: GLASS, borderBottom: '1px solid rgba(255,255,255,0.06)' };
|
||||
: { ...safeTop, background: 'rgba(18,18,20,0.72)', backdropFilter: GLASS, WebkitBackdropFilter: GLASS, borderBottom: '1px solid rgba(255,255,255,0.06)' };
|
||||
|
||||
return React.createElement('header', {
|
||||
className: onLanding ? 'header-minimal z-50' : 'header-minimal sticky top-0 z-50',
|
||||
@@ -565,7 +571,7 @@ const EnhancedMinimalHeader = ({
|
||||
React.createElement('div', {
|
||||
key: 'content',
|
||||
className: 'flex items-center justify-between',
|
||||
style: { height: '64px', gap: '16px' }
|
||||
style: { height: 'var(--sb-bar-h, 64px)', gap: '16px' }
|
||||
}, [
|
||||
// Left: logo + wordmark
|
||||
React.createElement('div', { key: 'left', style: { display: 'flex', alignItems: 'center', gap: '12px', minWidth: 0 } }, [
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// An offer to switch language — never a redirect.
|
||||
//
|
||||
// The tempting version of this feature is to read navigator.language on load and send
|
||||
// the visitor to the matching locale. It breaks the site for search engines in a way
|
||||
// that is invisible from a browser: Googlebot crawls with a single language (en) from a
|
||||
// single place, so a language redirect answers every one of the thirteen locale URLs
|
||||
// with the English page. The twelve translations then have no indexable document of
|
||||
// their own, the hreflang cluster they are declared in contradicts what the crawler
|
||||
// actually receives, and the work of translating the site earns nothing. It is also
|
||||
// wrong for people: a link shared into a German group chat has to open in German for
|
||||
// everyone who follows it, including the person whose laptop is set to English.
|
||||
//
|
||||
// So the URL stays authoritative and this bar carries the whole feature. It appears only
|
||||
// when the page being read is not the language the visitor probably wants, it says so in
|
||||
// that language, and it offers a normal link. Dismissing it is remembered for good.
|
||||
//
|
||||
// Landing only, like the switcher: following the link is a navigation, and a navigation
|
||||
// during a session drops the peer connection.
|
||||
|
||||
import {
|
||||
LOCALE_META,
|
||||
dismissLocaleSuggestion,
|
||||
localeHref,
|
||||
localeSuggestionDismissed,
|
||||
rememberLocale,
|
||||
storedLocale,
|
||||
suggestedLocale,
|
||||
t,
|
||||
} from '../../i18n/index.js';
|
||||
import { prefersReducedMotion } from '../../ui/motion.js';
|
||||
|
||||
const LanguageSuggestion = () => {
|
||||
// Resolved once, at mount: the answer cannot change without a navigation, and
|
||||
// re-deriving it on every render would mean a storage read per render.
|
||||
const [target] = React.useState(() => {
|
||||
if (typeof window === 'undefined' || !window.location) return null;
|
||||
if (localeSuggestionDismissed()) return null;
|
||||
return suggestedLocale({
|
||||
pathname: window.location.pathname || '/',
|
||||
languages: window.navigator?.languages || [],
|
||||
stored: storedLocale(),
|
||||
});
|
||||
});
|
||||
const [gone, setGone] = React.useState(false);
|
||||
// Mounted invisible, then raised on the next frame, so the bar arrives rather than
|
||||
// being part of the first paint — the page's own content should land first.
|
||||
const [shown, setShown] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!target) return undefined;
|
||||
const id = setTimeout(() => setShown(true), 600);
|
||||
return () => clearTimeout(id);
|
||||
}, [target]);
|
||||
|
||||
if (!target || gone) return null;
|
||||
|
||||
const meta = LOCALE_META[target] || {};
|
||||
const dir = meta.dir === 'rtl' ? 'rtl' : 'ltr';
|
||||
const pathname = typeof window !== 'undefined' ? window.location.pathname : '/';
|
||||
const href = localeHref(target, pathname);
|
||||
const still = prefersReducedMotion();
|
||||
|
||||
const close = () => {
|
||||
dismissLocaleSuggestion();
|
||||
setGone(true);
|
||||
};
|
||||
|
||||
// The copy is in the language being offered — the point of the bar is to be readable
|
||||
// by someone who cannot read the page it sits on. `dir` and `lang` come with it, so
|
||||
// Arabic in this bar is laid out as Arabic even on the English page.
|
||||
const line = React.createElement('div', {
|
||||
key: 'line',
|
||||
lang: meta.htmlLang || target,
|
||||
dir,
|
||||
style: { fontSize: '13px', lineHeight: 1.45, color: '#cfcfd4', textAlign: 'start' },
|
||||
}, t('language.suggest.text', null, target));
|
||||
|
||||
const link = React.createElement('a', {
|
||||
key: 'cta',
|
||||
href,
|
||||
hrefLang: meta.htmlLang || target,
|
||||
lang: meta.htmlLang || target,
|
||||
dir,
|
||||
// Following the link is an explicit choice, so it is remembered — the bar has
|
||||
// then done its job and will not be offered again.
|
||||
onClick: () => { rememberLocale(target); dismissLocaleSuggestion(); },
|
||||
style: {
|
||||
display: 'inline-flex', alignItems: 'center', gap: '6px',
|
||||
padding: '7px 12px', borderRadius: '9px',
|
||||
border: '1px solid rgba(240,137,42,0.30)', background: 'rgba(240,137,42,0.12)',
|
||||
color: '#f0892a', fontSize: '12.5px', fontWeight: 600,
|
||||
textDecoration: 'none', whiteSpace: 'nowrap',
|
||||
},
|
||||
}, t('language.suggest.cta', null, target));
|
||||
|
||||
const dismiss = React.createElement('button', {
|
||||
key: 'dismiss',
|
||||
type: 'button',
|
||||
onClick: close,
|
||||
// Labelled in the offered language too: this button is for the same reader.
|
||||
'aria-label': t('language.suggest.dismiss', null, target),
|
||||
style: {
|
||||
padding: '7px 10px', borderRadius: '9px',
|
||||
border: '1px solid rgba(255,255,255,0.07)', background: 'rgba(255,255,255,0.02)',
|
||||
color: '#8a8a92', font: 'inherit', fontSize: '12.5px', fontWeight: 500,
|
||||
cursor: 'pointer', whiteSpace: 'nowrap',
|
||||
},
|
||||
}, t('language.suggest.dismiss', null, target));
|
||||
|
||||
return React.createElement('div', {
|
||||
// A region rather than a dialog: it interrupts nothing and takes no focus.
|
||||
role: 'region',
|
||||
'aria-label': t('language.label'),
|
||||
style: {
|
||||
// Bottom inline-start, because the install prompt owns the opposite corner.
|
||||
position: 'fixed', bottom: '24px', insetInlineStart: '24px', zIndex: 50,
|
||||
maxWidth: 'min(340px, calc(100vw - 48px))',
|
||||
display: 'flex', flexDirection: 'column', gap: '11px',
|
||||
padding: '14px 16px', borderRadius: '14px',
|
||||
border: '1px solid rgba(255,255,255,0.08)', background: '#161618',
|
||||
boxShadow: '0 16px 40px rgba(0,0,0,0.5)',
|
||||
opacity: shown ? 1 : 0,
|
||||
transform: shown || still ? 'none' : 'translateY(10px)',
|
||||
transition: still ? 'opacity .2s linear' : 'opacity .3s ease, transform .3s cubic-bezier(.2,.7,.3,1)',
|
||||
// Invisible and untouchable until raised, so it cannot swallow a tap during
|
||||
// the fade.
|
||||
pointerEvents: shown ? 'auto' : 'none',
|
||||
},
|
||||
}, [
|
||||
line,
|
||||
React.createElement('div', {
|
||||
key: 'actions',
|
||||
style: { display: 'flex', alignItems: 'center', gap: '8px' },
|
||||
}, [link, dismiss]),
|
||||
]);
|
||||
};
|
||||
|
||||
window.LanguageSuggestion = LanguageSuggestion;
|
||||
|
||||
export { LanguageSuggestion };
|
||||
@@ -0,0 +1,107 @@
|
||||
// The geometry of a gallery view.
|
||||
//
|
||||
// Separated from the component because it is arithmetic, not rendering: it has
|
||||
// no React in it, it is the part that was actually wrong when tiles came out
|
||||
// tiny on a desktop, and it is the part worth pinning down in a test that does
|
||||
// not need a browser to run.
|
||||
|
||||
/** Gap between tiles, and the shape each one holds. */
|
||||
export const TILE_GAP = 10;
|
||||
export const TILE_ASPECT = 16 / 9;
|
||||
/** Below this a tile stops shrinking and the grid scrolls instead. */
|
||||
export const MIN_TILE_W = 150;
|
||||
|
||||
/**
|
||||
* How big each tile should be, given the space there actually is.
|
||||
*
|
||||
* The first version of this picked a column count from the number of people and
|
||||
* let CSS divide the width. That is wrong in both directions and it showed:
|
||||
* capped, three people on a wide monitor got three small tiles adrift in empty
|
||||
* space; uncapped, they got three letterbox strips. Neither is what a call
|
||||
* looks like, because neither is looking at the window.
|
||||
*
|
||||
* So do what a gallery view does: try every column count, work out how large a
|
||||
* tile could be at that count — bounded by the width a column gets AND by the
|
||||
* height its row gets, since a tile has a fixed shape and the smaller of the two
|
||||
* wins — and keep whichever count makes the tiles biggest. Two people on a wide
|
||||
* screen come out side by side and enormous; six come out three by two; the same
|
||||
* six on a phone come out two by three, because there the height is what is
|
||||
* plentiful. Nothing is special-cased per device.
|
||||
*
|
||||
* Pure and exported so the arithmetic can be tested without a browser.
|
||||
*
|
||||
* @returns {{cols: number, rows: number, tileW: number, tileH: number}}
|
||||
*/
|
||||
export function gridLayout(count, width, height, {
|
||||
gap = TILE_GAP, aspect = TILE_ASPECT, minWidth = MIN_TILE_W,
|
||||
} = {}) {
|
||||
if (count <= 0) return { cols: 0, rows: 0, tileW: 0, tileH: 0 };
|
||||
if (!(width > 0) || !(height > 0)) {
|
||||
// Not measured yet — first paint, or a container with no layout. Fall
|
||||
// back to a shape that is reasonable rather than to zero-sized tiles.
|
||||
const cols = Math.min(count, count <= 2 ? count : Math.ceil(Math.sqrt(count)));
|
||||
return { cols, rows: Math.ceil(count / cols), tileW: 0, tileH: 0 };
|
||||
}
|
||||
|
||||
let best = { cols: 1, rows: count, tileW: 0, tileH: 0 };
|
||||
for (let cols = 1; cols <= count; cols++) {
|
||||
const rows = Math.ceil(count / cols);
|
||||
const perColumn = (width - gap * (cols - 1)) / cols;
|
||||
const perRow = (height - gap * (rows - 1)) / rows;
|
||||
if (perColumn <= 0 || perRow <= 0) continue;
|
||||
// A tile keeps its aspect ratio, so it is as large as the tighter of the
|
||||
// two constraints allows — never stretched to fill one of them.
|
||||
const tileW = Math.min(perColumn, perRow * aspect);
|
||||
if (tileW > best.tileW) best = { cols, rows, tileW, tileH: tileW / aspect };
|
||||
}
|
||||
|
||||
if (best.tileW < minWidth) {
|
||||
// Too many people for the space. Stop shrinking and let the grid scroll:
|
||||
// tiles small enough to be unreadable are worse than a scrollbar.
|
||||
best.tileW = minWidth;
|
||||
best.tileH = minWidth / aspect;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* The spotlight arrangement: one large tile, the rest in a strip beneath it.
|
||||
*
|
||||
* A gallery is the right default — in a call of three or four everyone is worth
|
||||
* the same amount of screen. It stops being right the moment someone is
|
||||
* presenting, or the moment there are enough people that equal shares means
|
||||
* nobody is legible. So a tile can be pinned, and the stage splits: the pinned
|
||||
* person takes what is left after a strip of thumbnails, and the strip scrolls
|
||||
* sideways rather than shrinking further.
|
||||
*
|
||||
* The strip's height is a fraction of the stage, floored and capped, because a
|
||||
* proportion alone gives useless thumbnails on a phone and absurd ones on a
|
||||
* 4K monitor.
|
||||
*
|
||||
* @returns {{stageH: number, main: {w: number, h: number}, stripH: number, thumbW: number}}
|
||||
*/
|
||||
export function spotlightLayout(othersCount, width, height, {
|
||||
gap = TILE_GAP, aspect = TILE_ASPECT, minThumb = 92, maxThumb = 168,
|
||||
} = {}) {
|
||||
if (!(width > 0) || !(height > 0)) {
|
||||
return { main: { w: 0, h: 0 }, stripH: 0, thumbW: 0 };
|
||||
}
|
||||
if (othersCount <= 0) {
|
||||
const w = Math.min(width, height * aspect);
|
||||
return { main: { w, h: w / aspect }, stripH: 0, thumbW: 0 };
|
||||
}
|
||||
|
||||
const thumbW = Math.max(minThumb, Math.min(maxThumb, Math.round(height * 0.2 * aspect)));
|
||||
const stripH = thumbW / aspect;
|
||||
const mainH = height - stripH - gap;
|
||||
const mainW = Math.min(width, mainH * aspect);
|
||||
// Refuse to split a stage that cannot carry the split. The test is not
|
||||
// "does it fit" but "is it worth it": a main tile barely larger than the
|
||||
// thumbnails below it spotlights nobody, it just takes a gallery and makes
|
||||
// one cell slightly bigger. Below that the caller falls back to the gallery,
|
||||
// and is told so by a main tile of zero rather than a useless one.
|
||||
if (mainH <= 0 || mainW < thumbW * 2) {
|
||||
return { main: { w: 0, h: 0 }, stripH: 0, thumbW: 0 };
|
||||
}
|
||||
return { main: { w: mainW, h: mainW / aspect }, stripH, thumbW };
|
||||
}
|
||||
@@ -75,6 +75,11 @@ import {
|
||||
verifyMeshDescriptor,
|
||||
signLinkProbe,
|
||||
verifyLinkProbe,
|
||||
CALL_ACTIONS,
|
||||
newCallId,
|
||||
assertCallId,
|
||||
signGroupCall,
|
||||
verifyGroupCall,
|
||||
randomBytes,
|
||||
canonicalFingerprints,
|
||||
assertGroupId,
|
||||
@@ -106,6 +111,9 @@ export const GROUP_FRAMES = Object.freeze({
|
||||
MESH_ABORT: 'g_mabort',
|
||||
// "The pairwise chat this arrived on is me, member <fp>."
|
||||
PROBE: 'g_probe',
|
||||
// Call control: who opened a call, who is in it, who has left. Media never
|
||||
// travels here — see the call section below.
|
||||
CALL: 'g_call',
|
||||
});
|
||||
|
||||
/** The outer wrapper every group frame travels inside. See encodeEnvelope. */
|
||||
@@ -325,6 +333,23 @@ export class GroupSession {
|
||||
this._probed = new Set();
|
||||
/** The coalescing timer for _meshMaintain, or null when none is armed. */
|
||||
this._meshPass = null;
|
||||
|
||||
/**
|
||||
* The call this group is currently holding, or null.
|
||||
*
|
||||
* { callId, startedBy, withVideo, startedAt, participants: Set<fp>, joined }
|
||||
*
|
||||
* `joined` is about US specifically: a call can be running with three
|
||||
* people in it while we have not picked up, and the difference decides
|
||||
* whether this device is capturing a microphone. It is never inferred
|
||||
* from the participant set, because a member could otherwise put us in a
|
||||
* call by naming us in one.
|
||||
*/
|
||||
this.call = null;
|
||||
/** Our own counter over call frames. Monotonic; never reset within an epoch. */
|
||||
this.callSeq = 0;
|
||||
/** fp -> highest call sequence seen, so a captured frame cannot be replayed. */
|
||||
this._callSeen = new Map();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -372,6 +397,9 @@ export class GroupSession {
|
||||
this._meshFailures.clear();
|
||||
this._probed.clear();
|
||||
|
||||
this.call = null;
|
||||
this._callSeen.clear();
|
||||
|
||||
this.members.clear();
|
||||
this.sessionToFp.clear();
|
||||
|
||||
@@ -419,9 +447,28 @@ export class GroupSession {
|
||||
}
|
||||
|
||||
_emitMembers() {
|
||||
this._pruneCall();
|
||||
this._emit('members', { members: this._memberSnapshot(), epoch: this.epoch });
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop anyone from the current call who is no longer a member.
|
||||
*
|
||||
* Membership can change under a call — the admin removes somebody, a roster
|
||||
* for a new epoch arrives — and a participant list that outlives the roster
|
||||
* would show a person in the call who is not in the group, which is exactly
|
||||
* the kind of stale claim a group must not make about who can hear it.
|
||||
*/
|
||||
_pruneCall() {
|
||||
if (!this.call) return;
|
||||
let changed = false;
|
||||
for (const fp of [...this.call.participants]) {
|
||||
if (!this.members.has(fp)) { this.call.participants.delete(fp); changed = true; }
|
||||
}
|
||||
if (this.call.participants.size === 0) { this.call = null; changed = true; }
|
||||
if (changed) this._emit('call', { call: this.getCallSnapshot() });
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// routing
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -1616,6 +1663,264 @@ export class GroupSession {
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// calls
|
||||
// -----------------------------------------------------------------------
|
||||
//
|
||||
// WHAT TRAVELS HERE AND WHAT DOES NOT
|
||||
// -----------------------------------
|
||||
// Only the roster of a call: somebody opened one, somebody joined it,
|
||||
// somebody left. No SDP, no ICE, no audio, no video. Media is carried by the
|
||||
// pairwise sessions themselves — each member places an ordinary encrypted
|
||||
// call to each other member over the link they already share, so a group
|
||||
// call is N-1 of the 1:1 calls this app already makes, on transports that
|
||||
// were already SAS-verified. There is no mixer, no conference server and no
|
||||
// point at which two people's audio meets anywhere but on a device.
|
||||
//
|
||||
// That is why call control is separate from the media path. Control has to
|
||||
// reach every member, including one who is currently reachable only through
|
||||
// a relay; media can only flow where a direct link exists. Splitting them
|
||||
// means a member with no direct link still SEES the call and can be dialled
|
||||
// into it as the mesh completes, instead of silently missing it.
|
||||
//
|
||||
// WHY THESE FRAMES ARE SIGNED
|
||||
// ---------------------------
|
||||
// A relaying member carries call control for pairs that cannot reach each
|
||||
// other. Unsigned, that member could add somebody to a call they never
|
||||
// joined, or drop somebody who is in one, and nobody could tell it had
|
||||
// happened. Signed with the group identity key, a relay can still refuse to
|
||||
// carry a frame — the same availability cost relaying always has — but it
|
||||
// cannot write one.
|
||||
|
||||
/** What the app renders. Null when there is no call. */
|
||||
getCallSnapshot() {
|
||||
if (!this.call) return null;
|
||||
const starter = this.members.get(this.call.startedBy);
|
||||
return {
|
||||
callId: this.call.callId,
|
||||
startedBy: this.call.startedBy,
|
||||
startedByName: this.call.startedBy === this.selfFp
|
||||
? 'You'
|
||||
: (starter?.name || 'A member'),
|
||||
withVideo: this.call.withVideo,
|
||||
startedAt: this.call.startedAt,
|
||||
joined: this.call.joined,
|
||||
participants: [...this.call.participants].map((fp) => {
|
||||
const member = this.members.get(fp);
|
||||
return {
|
||||
fp,
|
||||
name: fp === this.selfFp ? 'You' : (member?.name || 'A member'),
|
||||
self: fp === this.selfFp,
|
||||
sessionId: member?.sessionId || null,
|
||||
state: member?.state || MEMBER_STATE.LOST,
|
||||
};
|
||||
}).sort((a, b) => (a.fp < b.fp ? -1 : a.fp > b.fp ? 1 : 0)),
|
||||
};
|
||||
}
|
||||
|
||||
_emitCall() {
|
||||
this._emit('call', { call: this.getCallSnapshot() });
|
||||
}
|
||||
|
||||
_requireReady() {
|
||||
if (this.phase !== GROUP_PHASE.READY || !this.sasConfirmed) {
|
||||
throw new GroupSessionError('the group code has not been confirmed', 'not_ready');
|
||||
}
|
||||
}
|
||||
|
||||
/** Sign and fan out one call-control frame. */
|
||||
async _sendCallFrame(action, callId, withVideo) {
|
||||
const seq = ++this.callSeq;
|
||||
const sig = await signGroupCall(this.subtle, this.identity.keyPair.privateKey, {
|
||||
groupId: this.groupId, epoch: this.epoch, callId, action,
|
||||
fp: this.selfFp, seq, withVideo,
|
||||
});
|
||||
return this._broadcast({
|
||||
type: GROUP_FRAMES.CALL,
|
||||
gid: this.groupId,
|
||||
epoch: this.epoch,
|
||||
callId,
|
||||
action,
|
||||
fp: this.selfFp,
|
||||
seq,
|
||||
v: withVideo === true,
|
||||
ts: Date.now(),
|
||||
sig: toB64(sig),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a call and put ourselves in it.
|
||||
*
|
||||
* Refused while one is already running: joining the call that exists is what
|
||||
* the user means, and a second concurrent call would split the group into two
|
||||
* rooms that cannot hear each other.
|
||||
*/
|
||||
async startCall({ withVideo = false, prepare = null } = {}) {
|
||||
this._requireReady();
|
||||
if (this.call) throw new GroupSessionError('a call is already running in this group', 'call_in_progress');
|
||||
|
||||
const callId = newCallId();
|
||||
this.call = {
|
||||
callId,
|
||||
startedBy: this.selfFp,
|
||||
withVideo: withVideo === true,
|
||||
startedAt: Date.now(),
|
||||
participants: new Set([this.selfFp]),
|
||||
joined: true,
|
||||
};
|
||||
this._emitCall();
|
||||
// Media needs a direct link, so a call is the moment it is most worth
|
||||
// having one. The mesh would get there on its own; this stops the first
|
||||
// seconds of the call being spent waiting for a maintenance pass.
|
||||
this._scheduleMeshMaintain();
|
||||
try {
|
||||
// `prepare` is where the caller opens its microphone, and it runs
|
||||
// BEFORE the group is told anything. Announcing first would ring
|
||||
// everybody else's device for a call this one turns out not to be
|
||||
// able to make — a denied permission, no microphone, another
|
||||
// application holding it. Failing here costs nobody but the person
|
||||
// who pressed the button.
|
||||
if (typeof prepare === 'function') await prepare(this.getCallSnapshot());
|
||||
const { unreachable } = await this._sendCallFrame(CALL_ACTIONS.START, callId, this.call.withVideo);
|
||||
return { callId, unreachable };
|
||||
} catch (error) {
|
||||
this.call = null;
|
||||
this._emitCall();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Join the call that is already running. */
|
||||
async joinCall() {
|
||||
this._requireReady();
|
||||
if (!this.call) throw new GroupSessionError('there is no call to join', 'no_call');
|
||||
if (this.call.joined) return { callId: this.call.callId, unreachable: [] };
|
||||
|
||||
this.call.joined = true;
|
||||
this.call.participants.add(this.selfFp);
|
||||
this._emitCall();
|
||||
this._scheduleMeshMaintain();
|
||||
const { unreachable } = await this._sendCallFrame(CALL_ACTIONS.JOIN, this.call.callId, this.call.withVideo);
|
||||
return { callId: this.call.callId, unreachable };
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave the call.
|
||||
*
|
||||
* Leaving is always local first: the frame is best effort, because a member
|
||||
* who cannot be reached must not be able to keep us in a call by being
|
||||
* unreachable.
|
||||
*/
|
||||
async leaveCall() {
|
||||
if (!this.call) return;
|
||||
const callId = this.call.callId;
|
||||
const withVideo = this.call.withVideo;
|
||||
this.call.joined = false;
|
||||
this.call.participants.delete(this.selfFp);
|
||||
if (this.call.participants.size === 0) this.call = null;
|
||||
this._emitCall();
|
||||
try {
|
||||
await this._sendCallFrame(CALL_ACTIONS.LEAVE, callId, withVideo);
|
||||
} catch (_) { /* leaving is best effort */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* A member is gone (left the call, left the group, or was removed).
|
||||
*
|
||||
* A call with NOBODY in it is over. A call with only us in it is not: that
|
||||
* is exactly the state every call is in for the seconds between opening it
|
||||
* and the first person joining, and ending it there would hang up on
|
||||
* somebody who is on their way in. Leaving is the user's decision, and
|
||||
* leaveCall is the only thing that makes it.
|
||||
*/
|
||||
_dropFromCall(fp) {
|
||||
if (!this.call || !this.call.participants.has(fp)) return;
|
||||
this.call.participants.delete(fp);
|
||||
if (this.call.participants.size === 0) this.call = null;
|
||||
this._emitCall();
|
||||
}
|
||||
|
||||
async _onCall(frame) {
|
||||
const epoch = assertEpoch(frame.epoch);
|
||||
const seq = assertEpoch(frame.seq);
|
||||
const senderFp = assertFingerprint(String(frame.fp || ''));
|
||||
const callId = assertCallId(String(frame.callId || ''));
|
||||
const action = String(frame.action || '');
|
||||
const withVideo = frame.v === true;
|
||||
|
||||
if (senderFp === this.selfFp) return;
|
||||
const member = this.members.get(senderFp);
|
||||
if (!member || !member.publicKey) throw new GroupSessionError('call frame from a non-member', 'not_a_member');
|
||||
if (epoch !== this.epoch) throw new GroupSessionError('call frame from another epoch', 'stale_epoch');
|
||||
|
||||
// Replay window first, so a captured frame is dropped before its action
|
||||
// is considered at all. Equal counts as a replay: a sender never reuses
|
||||
// a sequence number, and fan-out duplicates of the same frame are
|
||||
// exactly what this absorbs.
|
||||
const seen = this._callSeen.get(senderFp);
|
||||
if (seen !== undefined && seq <= seen) return;
|
||||
|
||||
const ok = await verifyGroupCall(this.subtle, member.publicKey, {
|
||||
groupId: this.groupId, epoch, callId, action, fp: senderFp, seq, withVideo,
|
||||
}, fromB64(String(frame.sig || ''), { max: GROUP_LIMITS.MAX_SIG_BYTES }));
|
||||
if (!ok) throw new GroupSessionError('call frame signature did not verify', 'bad_signature');
|
||||
this._callSeen.set(senderFp, seq);
|
||||
|
||||
// Nothing about a call may be acted on before the group itself is
|
||||
// usable: a call that arrives mid-ceremony would be a ringing phone for
|
||||
// a group nobody has authenticated yet.
|
||||
if (this.phase !== GROUP_PHASE.READY || !this.sasConfirmed) return;
|
||||
|
||||
switch (action) {
|
||||
case CALL_ACTIONS.START: {
|
||||
if (this.call && this.call.callId !== callId) {
|
||||
// Two calls opened at once. The lower id wins for everyone,
|
||||
// because every member compares the same two values and gets
|
||||
// the same answer — so the group converges on one room
|
||||
// instead of splitting into two that cannot hear each other.
|
||||
if (callId >= this.call.callId) return;
|
||||
// We are being moved off a call we may be in. Say so, so the
|
||||
// media layer tears the old one down before building the new.
|
||||
this.call = null;
|
||||
}
|
||||
if (!this.call) {
|
||||
this.call = {
|
||||
callId,
|
||||
startedBy: senderFp,
|
||||
withVideo,
|
||||
startedAt: Date.now(),
|
||||
participants: new Set([senderFp]),
|
||||
joined: false,
|
||||
};
|
||||
} else {
|
||||
this.call.participants.add(senderFp);
|
||||
}
|
||||
this._emitCall();
|
||||
this._scheduleMeshMaintain();
|
||||
return;
|
||||
}
|
||||
case CALL_ACTIONS.JOIN: {
|
||||
if (!this.call || this.call.callId !== callId) return;
|
||||
if (this.call.participants.has(senderFp)) return;
|
||||
this.call.participants.add(senderFp);
|
||||
// A member joining with video turns the call into one that has
|
||||
// video in it; nobody's own camera is turned on by this.
|
||||
if (withVideo) this.call.withVideo = true;
|
||||
this._emitCall();
|
||||
this._scheduleMeshMaintain();
|
||||
return;
|
||||
}
|
||||
case CALL_ACTIONS.LEAVE: {
|
||||
if (!this.call || this.call.callId !== callId) return;
|
||||
this._dropFromCall(senderFp);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// inbound dispatch
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -1658,6 +1963,8 @@ export class GroupSession {
|
||||
return this._onMeshAnswer(frame);
|
||||
case GROUP_FRAMES.MESH_ABORT:
|
||||
return this._onMeshAbort(frame);
|
||||
case GROUP_FRAMES.CALL:
|
||||
return this._onCall(frame);
|
||||
case GROUP_FRAMES.PROBE:
|
||||
// A probe is a claim about the link it arrived on, so it is only
|
||||
// meaningful on a direct one. Relayed, it says nothing.
|
||||
@@ -1695,6 +2002,7 @@ export class GroupSession {
|
||||
const member = this.members.get(fp);
|
||||
if (!member || fp === this.selfFp) return;
|
||||
this._emit('left', { fp, name: member.name });
|
||||
this._dropFromCall(fp);
|
||||
|
||||
// The admin leaving ends the group for everyone else. Nobody else can
|
||||
// sign a roster, so there is no next epoch and no safety code to compare
|
||||
|
||||
@@ -0,0 +1,739 @@
|
||||
// GroupCallMedia — the media half of a group call.
|
||||
//
|
||||
// WHAT THIS IS
|
||||
// ------------
|
||||
// A group call in SecureBit is not a conference. There is no mixer, no SFU and
|
||||
// no server: it is N-1 ordinary encrypted 1:1 calls, one to each other member,
|
||||
// each riding the pairwise session that member already has with us — a session
|
||||
// whose DTLS transport a human authenticated by comparing a safety code. Nobody
|
||||
// but the two endpoints of a leg ever holds the keys that leg's audio is under,
|
||||
// which is the same guarantee the 1:1 call has, kept N-1 times over rather than
|
||||
// traded away for a server that would make the call cheaper.
|
||||
//
|
||||
// This class owns exactly three things:
|
||||
//
|
||||
// 1. ONE capture for the whole call. Not one per leg. Seven legs opening
|
||||
// getUserMedia on the same microphone is seven captures of one microphone,
|
||||
// seven camera indicators, and on several devices simply a failure. The
|
||||
// capture is made here and handed to each manager, which attaches its
|
||||
// tracks and is forbidden from stopping them.
|
||||
//
|
||||
// 2. WHO DIALS. Both ends of a pair would otherwise place a call to each
|
||||
// other at the same moment and negotiate over the top of themselves. The
|
||||
// rule is the whole of the fix: the member with the lexicographically
|
||||
// smaller fingerprint calls, the other answers. Every member computes it
|
||||
// from the same two values and gets the same answer, so exactly one call
|
||||
// is placed per pair with nothing to coordinate.
|
||||
//
|
||||
// 3. WHAT THE UI SEES. One snapshot covering every leg, so the call renders
|
||||
// as one call rather than as N-1 unrelated ones.
|
||||
//
|
||||
// WHAT IT DOES NOT OWN
|
||||
// --------------------
|
||||
// Who is in the call. That is GroupSession's, and it travels as signed group
|
||||
// frames that reach members this class can never reach — a member with no
|
||||
// direct link has no leg here, and is shown as "connecting" while the mesh
|
||||
// builds one, rather than being invisible.
|
||||
//
|
||||
// Injected rather than imported (`getManager`, `getUserMedia`), for the same
|
||||
// reason GroupSession injects its transport: the dialling rule and the leg
|
||||
// bookkeeping are the parts worth testing, and they should not need a browser.
|
||||
|
||||
/** Per-leg lifecycle, as the UI reads it. */
|
||||
export const LEG_STATE = Object.freeze({
|
||||
UNREACHABLE: 'unreachable', // no direct link yet — the mesh is still building one
|
||||
CONNECTING: 'connecting', // a leg exists, media has not started flowing
|
||||
ACTIVE: 'active', // audio (and video, if any) is flowing
|
||||
FAILED: 'failed', // the leg was attempted and did not come up
|
||||
});
|
||||
|
||||
/** Media the leg is actually carrying, for the tile that renders it. */
|
||||
const PHASE_TO_STATE = {
|
||||
idle: LEG_STATE.CONNECTING,
|
||||
outgoing: LEG_STATE.CONNECTING,
|
||||
incoming: LEG_STATE.CONNECTING,
|
||||
connecting: LEG_STATE.CONNECTING,
|
||||
active: LEG_STATE.ACTIVE,
|
||||
ended: LEG_STATE.FAILED,
|
||||
};
|
||||
|
||||
/**
|
||||
* Speech detection thresholds, on the RMS of a 0..1 normalised waveform.
|
||||
*
|
||||
* Two of them, not one, because a single threshold makes the indicator flicker
|
||||
* on every syllable boundary: normal speech crosses any fixed line several times
|
||||
* a second. Rising past ON marks someone as speaking; they stay marked until
|
||||
* they have been continuously below OFF for HOLD_MS, which is roughly the length
|
||||
* of a pause between words.
|
||||
*/
|
||||
export const SPEAKING = Object.freeze({ ON: 0.05, OFF: 0.025, HOLD_MS: 600, SAMPLE_MS: 120 });
|
||||
|
||||
/**
|
||||
* How long the answering side waits before dialling the pair itself.
|
||||
*
|
||||
* Long enough that a working dial has certainly arrived — offers travel the same
|
||||
* data channel the group already uses, so this is seconds of margin, not
|
||||
* guesswork — and short enough that a member who would otherwise be stuck on
|
||||
* "connecting" for the whole call gets a second chance while the call still
|
||||
* matters.
|
||||
*/
|
||||
export const FALLBACK_DIAL_MS = 8000;
|
||||
|
||||
/**
|
||||
* A level meter over one MediaStream, built on Web Audio.
|
||||
*
|
||||
* Web Audio rather than the WebRTC stats API on purpose: `audioLevel` on a
|
||||
* receiver is not carried by every browser this app runs in, and where it is,
|
||||
* it arrives on the stats interval rather than on demand. An AnalyserNode is
|
||||
* available everywhere, reads the actual decoded waveform, and works the same
|
||||
* way for the local microphone as for a remote leg — so "who is speaking" is one
|
||||
* mechanism rather than two that disagree.
|
||||
*/
|
||||
function webAudioLevelMeter(context, stream) {
|
||||
if (!context || !stream) return null;
|
||||
let source;
|
||||
try {
|
||||
source = context.createMediaStreamSource(stream);
|
||||
} catch (_) {
|
||||
return null; // a stream with no audio track, or a context that is gone
|
||||
}
|
||||
const analyser = context.createAnalyser();
|
||||
analyser.fftSize = 512;
|
||||
analyser.smoothingTimeConstant = 0.2;
|
||||
source.connect(analyser);
|
||||
// Deliberately NOT connected to the destination: playback is the audio
|
||||
// element's job, and routing it here too would play every voice twice.
|
||||
const buffer = new Uint8Array(analyser.fftSize);
|
||||
|
||||
return {
|
||||
read() {
|
||||
analyser.getByteTimeDomainData(buffer);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
const v = (buffer[i] - 128) / 128;
|
||||
sum += v * v;
|
||||
}
|
||||
return Math.sqrt(sum / buffer.length);
|
||||
},
|
||||
close() {
|
||||
try { source.disconnect(); } catch (_) {}
|
||||
try { analyser.disconnect(); } catch (_) {}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class GroupCallMedia {
|
||||
/**
|
||||
* @param {object} deps
|
||||
* @param {(sessionId: string) => object|null} deps.getManager pairwise manager by session id
|
||||
* @param {(constraints: object) => Promise<MediaStream>} [deps.getUserMedia]
|
||||
* @param {() => void} [deps.onChange] called whenever the snapshot changes
|
||||
* @param {(level: string, message: string, context?: object) => void} [deps.log]
|
||||
*/
|
||||
constructor({
|
||||
getManager, getUserMedia = null, createAudioSink = null, createLevelMeter = null,
|
||||
onChange = () => {}, log = () => {},
|
||||
}) {
|
||||
this._getManager = getManager;
|
||||
this._getUserMedia = getUserMedia
|
||||
|| ((constraints) => navigator.mediaDevices.getUserMedia(constraints));
|
||||
/**
|
||||
* Where a member's voice actually plays.
|
||||
*
|
||||
* NOT in the call's React tree, deliberately. The tiles unmount whenever
|
||||
* the user switches to another chat, and audio elements that live in
|
||||
* them take the call's sound with them — a call that goes silent because
|
||||
* somebody looked at a different window is not a call. The controller
|
||||
* owns one detached audio element per leg for the life of that leg, and
|
||||
* the tiles render video only.
|
||||
*/
|
||||
this._createAudioSink = createAudioSink
|
||||
|| (() => (typeof Audio === 'function' ? new Audio() : null));
|
||||
/**
|
||||
* How loud a stream is right now, for the speaking indicator.
|
||||
*
|
||||
* Injected so the whole hysteresis rule below can be tested without a
|
||||
* browser, and so a platform with no Web Audio simply has no indicator
|
||||
* rather than no call.
|
||||
*/
|
||||
this._createLevelMeter = createLevelMeter
|
||||
|| ((stream) => webAudioLevelMeter(this._audioContext(), stream));
|
||||
this._onChange = onChange;
|
||||
this._log = log;
|
||||
|
||||
this.callId = null;
|
||||
this.selfFp = '';
|
||||
this.withVideo = false;
|
||||
this.micEnabled = true;
|
||||
this.cameraEnabled = false;
|
||||
this.facingMode = 'user';
|
||||
this.error = null;
|
||||
|
||||
this.localStream = null;
|
||||
/** fp -> { fp, name, sessionId, manager, unsubscribe, sink, state, quality, hasVideo } */
|
||||
this._legs = new Map();
|
||||
/** The participant list as GroupSession last reported it. */
|
||||
this._peers = [];
|
||||
/** Guards flipCamera / setCamera against overlapping captures. */
|
||||
this._busy = false;
|
||||
|
||||
/** Shared AudioContext for every level meter in this call. */
|
||||
this._ctx = null;
|
||||
/** Our own microphone's meter, and whether it is currently carrying speech. */
|
||||
this._selfMeter = null;
|
||||
this.selfSpeaking = false;
|
||||
this._selfQuietSince = 0;
|
||||
/** The polling timer that reads every meter. */
|
||||
this._levelTimer = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One AudioContext for the whole call, created on first use.
|
||||
*
|
||||
* A context per leg would be several audio graphs and several hardware
|
||||
* callbacks for one conversation, and browsers cap how many a page may hold.
|
||||
* It is resumed rather than assumed: a context created outside a gesture
|
||||
* starts suspended, and a suspended context reads silence forever — which
|
||||
* would look exactly like nobody ever speaking.
|
||||
*/
|
||||
_audioContext() {
|
||||
if (this._ctx) return this._ctx;
|
||||
const Ctor = typeof AudioContext !== 'undefined' ? AudioContext
|
||||
: (typeof webkitAudioContext !== 'undefined' ? webkitAudioContext : null);
|
||||
if (!Ctor) return null;
|
||||
try {
|
||||
this._ctx = new Ctor();
|
||||
if (this._ctx.state === 'suspended') this._ctx.resume().catch(() => {});
|
||||
} catch (_) {
|
||||
this._ctx = null;
|
||||
}
|
||||
return this._ctx;
|
||||
}
|
||||
|
||||
get active() {
|
||||
return this.callId !== null;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// joining and leaving
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Capture, then start building legs.
|
||||
*
|
||||
* The capture comes FIRST and its failure aborts the join, because a call
|
||||
* this device cannot speak into is not a call — and the failure has to be
|
||||
* reported as a device problem (permission, no microphone, another app
|
||||
* holding it) rather than left to look like the group failed to connect.
|
||||
*
|
||||
* CALL IT WITH NO PEERS to capture without connecting anything, then call
|
||||
* setPeers once the group has been told you are in the call. That ordering
|
||||
* is not cosmetic — see setPeers.
|
||||
*
|
||||
* @param {{callId: string, selfFp: string, withVideo?: boolean, peers?: object[]}} opts
|
||||
*/
|
||||
async join({ callId, selfFp, withVideo = false, peers = [] }) {
|
||||
if (this.callId === callId) { this.setPeers(peers); return; }
|
||||
if (this.callId) await this.leave();
|
||||
|
||||
this.callId = callId;
|
||||
this.selfFp = selfFp;
|
||||
this.withVideo = withVideo === true;
|
||||
this.micEnabled = true;
|
||||
this.cameraEnabled = this.withVideo;
|
||||
this.error = null;
|
||||
this._changed();
|
||||
|
||||
try {
|
||||
this.localStream = await this._getUserMedia({
|
||||
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
|
||||
video: this.withVideo
|
||||
? { facingMode: this.facingMode, width: { ideal: 1280 }, height: { ideal: 720 } }
|
||||
: false,
|
||||
});
|
||||
} catch (error) {
|
||||
this.callId = null;
|
||||
this.error = mediaErrorCode(error);
|
||||
this._changed();
|
||||
throw error;
|
||||
}
|
||||
|
||||
this._selfMeter = this._createLevelMeter(this.localStream);
|
||||
this._startLevelPolling();
|
||||
this.setPeers(peers);
|
||||
}
|
||||
|
||||
/**
|
||||
* The participant list changed: reconcile the legs against it.
|
||||
*
|
||||
* Called whenever anything moves — somebody joins, somebody leaves, a member
|
||||
* who was relayed gets a direct link. Idempotent, so the caller can run it
|
||||
* on every group event without tracking what actually differs.
|
||||
*
|
||||
* ORDERING: the group must be told we are in the call BEFORE this runs.
|
||||
* Attaching a leg can place an offer immediately, and an offer that arrives
|
||||
* at a member who has not yet heard we joined lands on a session they do not
|
||||
* yet know is a call leg — so they ring instead of answering, and the dialler
|
||||
* sits at "connecting" for the rest of the call. Announcing first works
|
||||
* because both the join frame and the offer travel the same ordered data
|
||||
* channel, so the join cannot overtake it.
|
||||
*
|
||||
* @param {{fp: string, name: string, sessionId: string|null, self?: boolean}[]} peers
|
||||
*/
|
||||
setPeers(peers) {
|
||||
this._peers = Array.isArray(peers) ? peers : [];
|
||||
if (!this.active) return;
|
||||
|
||||
const wanted = new Map();
|
||||
for (const peer of this._peers) {
|
||||
if (!peer || !peer.fp || peer.fp === this.selfFp) continue;
|
||||
wanted.set(peer.fp, peer);
|
||||
}
|
||||
|
||||
// Legs whose member left the call, or whose link moved to a different
|
||||
// session, are torn down first — a leg on a dead session id would keep a
|
||||
// sender attached to a peer connection nobody is on the other end of.
|
||||
for (const [fp, leg] of [...this._legs]) {
|
||||
const peer = wanted.get(fp);
|
||||
if (!peer || peer.sessionId !== leg.sessionId) this._detach(fp);
|
||||
}
|
||||
|
||||
for (const [fp, peer] of wanted) {
|
||||
const existing = this._legs.get(fp);
|
||||
if (existing) {
|
||||
// A leg that failed is retried on the next reconciliation rather
|
||||
// than being written off for the rest of the call. The usual
|
||||
// cause is transient — the pairwise session was mid-repair when
|
||||
// the offer was placed — and without this the member stayed
|
||||
// silent for as long as the call lasted even after their link
|
||||
// came back.
|
||||
if (existing.state === LEG_STATE.FAILED && this._weDial(fp)) this._dial(existing);
|
||||
continue;
|
||||
}
|
||||
if (!peer.sessionId) continue; // relayed only: no leg to build yet
|
||||
this._attach(fp, peer);
|
||||
}
|
||||
|
||||
this._changed();
|
||||
}
|
||||
|
||||
/** Tear down every leg and release the capture. */
|
||||
async leave() {
|
||||
this._stopLevelPolling();
|
||||
if (this._selfMeter) { try { this._selfMeter.close?.(); } catch (_) {} this._selfMeter = null; }
|
||||
this.selfSpeaking = false;
|
||||
for (const fp of [...this._legs.keys()]) this._detach(fp);
|
||||
if (this._ctx) { try { this._ctx.close?.(); } catch (_) {} this._ctx = null; }
|
||||
if (this.localStream) {
|
||||
for (const track of this.localStream.getTracks()) {
|
||||
try { track.stop(); } catch (_) { /* already ended */ }
|
||||
}
|
||||
}
|
||||
this.localStream = null;
|
||||
this.callId = null;
|
||||
this.selfFp = '';
|
||||
this.withVideo = false;
|
||||
this.cameraEnabled = false;
|
||||
this.micEnabled = true;
|
||||
this._peers = [];
|
||||
this._changed();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// legs
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Who places the call for this pair.
|
||||
*
|
||||
* Both members run this and get opposite answers, so exactly one offer is
|
||||
* made. Comparing fingerprints rather than, say, join order means the answer
|
||||
* does not depend on anything the two members might disagree about.
|
||||
*/
|
||||
_weDial(peerFp) {
|
||||
return this.selfFp < peerFp;
|
||||
}
|
||||
|
||||
_attach(fp, peer) {
|
||||
const manager = this._getManager(peer.sessionId);
|
||||
if (!manager) return;
|
||||
|
||||
const leg = {
|
||||
fp,
|
||||
name: peer.name || 'Member',
|
||||
sessionId: peer.sessionId,
|
||||
manager,
|
||||
unsubscribe: null,
|
||||
sink: this._createAudioSink(),
|
||||
meter: null,
|
||||
fallback: null,
|
||||
speaking: false,
|
||||
quietSince: 0,
|
||||
state: LEG_STATE.CONNECTING,
|
||||
quality: null,
|
||||
hasVideo: false,
|
||||
};
|
||||
if (leg.sink) { leg.sink.autoplay = true; leg.sink.muted = false; }
|
||||
this._legs.set(fp, leg);
|
||||
|
||||
// Order matters: the context has to be set before the offer can arrive,
|
||||
// or the answering side rings the user for a call they already joined.
|
||||
try { manager.setCallGroupContext(this.callId); } catch (_) {}
|
||||
try { manager.setExternalMediaStream(this.localStream); } catch (_) {}
|
||||
|
||||
if (typeof manager.addCallStateListener === 'function') {
|
||||
leg.unsubscribe = manager.addCallStateListener((state) => this._onLegState(fp, state));
|
||||
}
|
||||
|
||||
// Start from what the session is ACTUALLY doing. A leg attached to a
|
||||
// manager that is already mid-call — a member rebound to a link that was
|
||||
// carrying us a moment ago — would otherwise sit at "connecting" until
|
||||
// the next state change happened to come along, which for a call that is
|
||||
// already up may be never.
|
||||
this._onLegState(fp, manager.getCallState?.() || {});
|
||||
|
||||
if (this._weDial(fp)) {
|
||||
this._dial(leg);
|
||||
return;
|
||||
}
|
||||
// We are the answering side, so there is nothing to do but wait — unless
|
||||
// the offer never comes. That is not hypothetical: the peer's dial can
|
||||
// fail on their side and their retry only runs when a group event happens
|
||||
// to fire. After a grace period long enough that a working dial would
|
||||
// have arrived, take the call ourselves. Only ONE side of a pair ever
|
||||
// does this — the side that was not going to dial — so it cannot produce
|
||||
// two offers crossing, and it is skipped outright if anything has reached
|
||||
// this session in the meantime.
|
||||
leg.fallback = setTimeout(() => this._dialFallback(fp), FALLBACK_DIAL_MS);
|
||||
}
|
||||
|
||||
_dialFallback(fp) {
|
||||
const leg = this._legs.get(fp);
|
||||
if (!leg || !this.active) return;
|
||||
leg.fallback = null;
|
||||
const phase = leg.manager.getCallState?.().phase || 'idle';
|
||||
if (phase !== 'idle') return; // something arrived; leave it alone
|
||||
this._log('info', 'group call leg was never dialled by the peer; dialling it', {});
|
||||
Promise.resolve()
|
||||
.then(() => leg.manager.startCall(this.withVideo))
|
||||
.catch(() => {
|
||||
const live = this._legs.get(fp);
|
||||
if (live === leg) { live.state = LEG_STATE.FAILED; this._changed(); }
|
||||
});
|
||||
}
|
||||
|
||||
/** Place this leg's call. */
|
||||
_dial(leg) {
|
||||
const phase = leg.manager.getCallState?.().phase || 'idle';
|
||||
if (phase !== 'idle') return;
|
||||
leg.state = LEG_STATE.CONNECTING;
|
||||
Promise.resolve()
|
||||
.then(() => leg.manager.startCall(this.withVideo))
|
||||
.catch((error) => {
|
||||
this._log('warn', 'group call leg could not be placed', {
|
||||
errorType: error?.constructor?.name,
|
||||
});
|
||||
const live = this._legs.get(leg.fp);
|
||||
if (live === leg) { live.state = LEG_STATE.FAILED; this._changed(); }
|
||||
});
|
||||
}
|
||||
|
||||
_detach(fp) {
|
||||
const leg = this._legs.get(fp);
|
||||
if (!leg) return;
|
||||
this._legs.delete(fp);
|
||||
if (leg.fallback) { clearTimeout(leg.fallback); leg.fallback = null; }
|
||||
try { leg.unsubscribe?.(); } catch (_) {}
|
||||
if (leg.sink) {
|
||||
try { leg.sink.pause?.(); } catch (_) {}
|
||||
try { leg.sink.srcObject = null; } catch (_) {}
|
||||
}
|
||||
if (leg.meter) { try { leg.meter.close?.(); } catch (_) {} leg.meter = null; }
|
||||
// End first, then release the borrowed capture and the context: ending
|
||||
// the other way round would have the manager tear down a call it no
|
||||
// longer believes belongs to a group.
|
||||
try { leg.manager.endCall?.(); } catch (_) {}
|
||||
try { leg.manager.setExternalMediaStream?.(null); } catch (_) {}
|
||||
try { leg.manager.setCallGroupContext?.(null); } catch (_) {}
|
||||
}
|
||||
|
||||
_onLegState(fp, state) {
|
||||
const leg = this._legs.get(fp);
|
||||
if (!leg) return;
|
||||
const phase = state?.phase || 'idle';
|
||||
if (leg.fallback && phase !== 'idle') { clearTimeout(leg.fallback); leg.fallback = null; }
|
||||
leg.state = PHASE_TO_STATE[phase] || LEG_STATE.CONNECTING;
|
||||
leg.quality = state?.quality || null;
|
||||
leg.hasVideo = state?.remoteHasVideo === true;
|
||||
this._pumpAudio(leg);
|
||||
this._changed();
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep this leg's audio element pointed at its current inbound stream.
|
||||
*
|
||||
* The manager rebuilds that stream whenever its receivers change — a track
|
||||
* arriving late, a camera being switched on — and hands back a NEW
|
||||
* MediaStream each time so a consumer can tell. Reassigning here on every
|
||||
* state change is what keeps a leg audible across those rebuilds; assigning
|
||||
* once at attach time left the first seconds of some calls silent.
|
||||
*/
|
||||
_pumpAudio(leg) {
|
||||
if (!leg.sink) return;
|
||||
let stream = null;
|
||||
try { stream = leg.manager.getRemoteMediaStream?.() || null; } catch (_) { return; }
|
||||
if (!stream || leg.sink.srcObject === stream) return;
|
||||
try {
|
||||
leg.sink.srcObject = stream;
|
||||
const played = leg.sink.play?.();
|
||||
if (played && played.catch) played.catch(() => {});
|
||||
} catch (_) { /* an element that will not play is not worth throwing over */ }
|
||||
// The meter follows the stream, not the leg: the manager hands back a new
|
||||
// MediaStream whenever its receivers change, and a meter left on the old
|
||||
// one reads silence from then on.
|
||||
if (leg.meter) { try { leg.meter.close?.(); } catch (_) {} }
|
||||
leg.meter = this._createLevelMeter(stream);
|
||||
leg.speaking = false;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// who is speaking
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
_startLevelPolling() {
|
||||
if (this._levelTimer || typeof setInterval !== 'function') return;
|
||||
this._levelTimer = setInterval(() => this._sampleLevels(), SPEAKING.SAMPLE_MS);
|
||||
}
|
||||
|
||||
_stopLevelPolling() {
|
||||
if (this._levelTimer) { clearInterval(this._levelTimer); this._levelTimer = null; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a level into a speaking flag, with hysteresis and a hold.
|
||||
*
|
||||
* `holder` is whatever object carries the flag — a leg, or this instance for
|
||||
* our own microphone — so the same rule runs for everyone and the local tile
|
||||
* cannot disagree with a remote one about what counts as talking.
|
||||
*/
|
||||
_applyLevel(holder, key, quietKey, level, now) {
|
||||
const speaking = holder[key] === true;
|
||||
if (level >= SPEAKING.ON) {
|
||||
holder[quietKey] = 0;
|
||||
if (!speaking) { holder[key] = true; return true; }
|
||||
return false;
|
||||
}
|
||||
if (!speaking) return false;
|
||||
if (level > SPEAKING.OFF) { holder[quietKey] = 0; return false; }
|
||||
if (!holder[quietKey]) { holder[quietKey] = now; return false; }
|
||||
if (now - holder[quietKey] >= SPEAKING.HOLD_MS) { holder[key] = false; return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
_sampleLevels() {
|
||||
if (!this.active) return;
|
||||
const now = Date.now();
|
||||
let changed = false;
|
||||
|
||||
// A muted microphone is not speech, whatever the waveform says — the
|
||||
// track is disabled, so nothing is going out, and showing ourselves as
|
||||
// talking would be telling the user the opposite of what is happening.
|
||||
const selfLevel = (this.micEnabled && this._selfMeter) ? safeRead(this._selfMeter) : 0;
|
||||
if (this._applyLevel(this, 'selfSpeaking', '_selfQuietSince', selfLevel, now)) changed = true;
|
||||
|
||||
for (const leg of this._legs.values()) {
|
||||
const level = leg.meter ? safeRead(leg.meter) : 0;
|
||||
if (this._applyLevel(leg, 'speaking', 'quietSince', level, now)) changed = true;
|
||||
}
|
||||
|
||||
// Only when a flag actually flipped. Pushing a snapshot every 120ms would
|
||||
// re-render the whole call ten times a second for no visible reason.
|
||||
if (changed) this._changed();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// controls — applied to every leg at once, because it is one call
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
setMic(enabled) {
|
||||
this.micEnabled = enabled !== false;
|
||||
if (this.localStream) {
|
||||
for (const track of this.localStream.getAudioTracks()) track.enabled = this.micEnabled;
|
||||
}
|
||||
// Muting clears the speaking mark at once rather than letting it decay
|
||||
// through the hold: the hold exists to ride out pauses in speech, and
|
||||
// pressing mute is not a pause. Half a second of still looking like you
|
||||
// are talking is exactly the half second that matters.
|
||||
if (!this.micEnabled) { this.selfSpeaking = false; this._selfQuietSince = 0; }
|
||||
// The per-leg managers read enabled-ness off the shared tracks, so this
|
||||
// is the whole of it — but their own call state still carries a mic flag
|
||||
// that a 1:1 UI would render, so keep it honest.
|
||||
for (const leg of this._legs.values()) {
|
||||
try { leg.manager.setMicEnabled?.(this.micEnabled); } catch (_) {}
|
||||
}
|
||||
this._changed();
|
||||
}
|
||||
|
||||
toggleMic() { this.setMic(!this.micEnabled); }
|
||||
|
||||
/**
|
||||
* Turn the camera on or off for the whole call.
|
||||
*
|
||||
* Turning it on when the call started as voice captures a camera track and
|
||||
* adds it to every leg, each of which renegotiates its own connection. That
|
||||
* is N-1 renegotiations for one button, which is the honest cost of having
|
||||
* no server in the middle.
|
||||
*/
|
||||
async setCamera(enabled) {
|
||||
if (!this.active || this._busy) return;
|
||||
if (enabled === false) {
|
||||
this.cameraEnabled = false;
|
||||
if (this.localStream) {
|
||||
for (const track of this.localStream.getVideoTracks()) track.enabled = false;
|
||||
}
|
||||
for (const leg of this._legs.values()) {
|
||||
try { leg.manager.setCameraEnabled?.(false); } catch (_) {}
|
||||
}
|
||||
this._changed();
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = this.localStream?.getVideoTracks?.() || [];
|
||||
if (existing.length) {
|
||||
for (const track of existing) track.enabled = true;
|
||||
this.cameraEnabled = true;
|
||||
this.withVideo = true;
|
||||
this._changed();
|
||||
return;
|
||||
}
|
||||
|
||||
this._busy = true;
|
||||
try {
|
||||
const camera = await this._getUserMedia({
|
||||
video: { facingMode: this.facingMode, width: { ideal: 1280 }, height: { ideal: 720 } },
|
||||
});
|
||||
const track = camera.getVideoTracks()[0];
|
||||
if (!track) return;
|
||||
this.localStream.addTrack(track);
|
||||
this.cameraEnabled = true;
|
||||
this.withVideo = true;
|
||||
await Promise.allSettled([...this._legs.values()].map(
|
||||
(leg) => Promise.resolve().then(() => leg.manager.addVideoTrack?.(track)),
|
||||
));
|
||||
} catch (error) {
|
||||
this.error = mediaErrorCode(error);
|
||||
this.cameraEnabled = false;
|
||||
} finally {
|
||||
this._busy = false;
|
||||
this._changed();
|
||||
}
|
||||
}
|
||||
|
||||
async toggleCamera() { await this.setCamera(!this.cameraEnabled); }
|
||||
|
||||
/** Front/back camera. One capture, swapped into every leg without renegotiating. */
|
||||
async flipCamera() {
|
||||
if (!this.active || this._busy || !this.localStream) return;
|
||||
const old = this.localStream.getVideoTracks()[0];
|
||||
if (!old) return;
|
||||
|
||||
this._busy = true;
|
||||
const previous = this.facingMode;
|
||||
this.facingMode = this.facingMode === 'user' ? 'environment' : 'user';
|
||||
try {
|
||||
const camera = await this._getUserMedia({ video: { facingMode: this.facingMode } });
|
||||
const track = camera.getVideoTracks()[0];
|
||||
if (!track) { this.facingMode = previous; return; }
|
||||
track.enabled = this.cameraEnabled;
|
||||
this.localStream.removeTrack(old);
|
||||
try { old.stop(); } catch (_) {}
|
||||
this.localStream.addTrack(track);
|
||||
await Promise.allSettled([...this._legs.values()].map(
|
||||
(leg) => Promise.resolve().then(() => leg.manager.replaceVideoTrack?.(track)),
|
||||
));
|
||||
} catch (error) {
|
||||
this.facingMode = previous;
|
||||
this._log('warn', 'group call camera flip failed', { errorType: error?.constructor?.name });
|
||||
} finally {
|
||||
this._busy = false;
|
||||
this._changed();
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// what the UI reads
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
getLocalStream() {
|
||||
return this.localStream;
|
||||
}
|
||||
|
||||
/** The inbound stream for one member, or null while their leg is coming up. */
|
||||
getRemoteStream(fp) {
|
||||
const leg = this._legs.get(fp);
|
||||
if (!leg) return null;
|
||||
try { return leg.manager.getRemoteMediaStream?.() || null; } catch (_) { return null; }
|
||||
}
|
||||
|
||||
/**
|
||||
* One call, as one object.
|
||||
*
|
||||
* Every participant appears, including the ones with no leg: a member who is
|
||||
* in the call but reachable only through a relay is CONNECTING, not absent.
|
||||
* Hiding them would make a mesh that is still building look like a member
|
||||
* who declined.
|
||||
*/
|
||||
snapshot() {
|
||||
const peers = this._peers
|
||||
.filter((peer) => peer && peer.fp !== this.selfFp)
|
||||
.map((peer) => {
|
||||
const leg = this._legs.get(peer.fp);
|
||||
return {
|
||||
fp: peer.fp,
|
||||
name: peer.name || 'Member',
|
||||
state: leg ? leg.state : LEG_STATE.UNREACHABLE,
|
||||
quality: leg ? leg.quality : null,
|
||||
hasVideo: leg ? leg.hasVideo : false,
|
||||
speaking: leg ? leg.speaking === true : false,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => (a.fp < b.fp ? -1 : a.fp > b.fp ? 1 : 0));
|
||||
|
||||
return {
|
||||
active: this.active,
|
||||
callId: this.callId,
|
||||
withVideo: this.withVideo,
|
||||
micEnabled: this.micEnabled,
|
||||
cameraEnabled: this.cameraEnabled,
|
||||
facingMode: this.facingMode,
|
||||
error: this.error,
|
||||
selfSpeaking: this.selfSpeaking,
|
||||
peers,
|
||||
connected: peers.filter((p) => p.state === LEG_STATE.ACTIVE).length,
|
||||
};
|
||||
}
|
||||
|
||||
_changed() {
|
||||
try { this._onChange(this.snapshot()); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A getUserMedia failure, as a code the UI can turn into a sentence.
|
||||
*
|
||||
* Kept identical to the 1:1 call's vocabulary: these are device and permission
|
||||
* problems, and telling them apart from a connection problem is the difference
|
||||
* between "allow the microphone" and a user staring at a call that looks broken.
|
||||
*/
|
||||
/** A meter that throws is a meter that is gone; it must not take the call with it. */
|
||||
function safeRead(meter) {
|
||||
try { return meter.read(); } catch (_) { return 0; }
|
||||
}
|
||||
|
||||
export function mediaErrorCode(error) {
|
||||
const name = error?.name || '';
|
||||
if (name === 'NotAllowedError' || name === 'SecurityError') return 'permission_denied';
|
||||
if (name === 'NotFoundError' || name === 'OverconstrainedError') return 'device_not_found';
|
||||
if (name === 'NotReadableError' || name === 'AbortError') return 'device_busy';
|
||||
return 'media_failed';
|
||||
}
|
||||
@@ -118,6 +118,31 @@ export const GROUP_LIMITS = Object.freeze({
|
||||
MAX_DESCRIPTOR_CHARS: 768,
|
||||
/** Binds an answer to the one dial attempt that asked for it. */
|
||||
MESH_NONCE_BYTES: 16,
|
||||
|
||||
/**
|
||||
* A group call's identifier, in bytes.
|
||||
*
|
||||
* Random rather than derived, and long enough that two members who press
|
||||
* "call" at the same instant cannot collide. Everything about a call is
|
||||
* scoped to it: a `join` for one call says nothing about another, and a
|
||||
* `leave` replayed from a finished call cannot end a later one.
|
||||
*/
|
||||
CALL_ID_BYTES: 16,
|
||||
});
|
||||
|
||||
/**
|
||||
* What one member is telling the group about a call.
|
||||
*
|
||||
* There is deliberately no "end the call for everyone": a call ends when the
|
||||
* last person in it leaves, which is a fact every member can observe from the
|
||||
* frames they already have. An explicit end would be a button one member could
|
||||
* press to hang up on the others, and nothing in a group without a server makes
|
||||
* that person more entitled to it than anybody else.
|
||||
*/
|
||||
export const CALL_ACTIONS = Object.freeze({
|
||||
START: 'start', // I have opened a call and I am in it
|
||||
JOIN: 'join', // I am joining the call already open
|
||||
LEAVE: 'leave', // I have left; the call ends when nobody is left
|
||||
});
|
||||
|
||||
/** Which half of a mesh dial a signature covers. */
|
||||
@@ -764,4 +789,69 @@ export async function verifyLinkProbe(subtle, publicKey, fields, signature) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The bytes a call-control frame is signed over.
|
||||
*
|
||||
* Call control is membership-visible metadata, not content, but it is signed
|
||||
* with the same group identity key for the same reason a message is: a relaying
|
||||
* member carries these frames, and a relay that could forge one could put a
|
||||
* member into a call they never joined, or take one out of a call they are in,
|
||||
* with no way for anyone to tell who did it.
|
||||
*
|
||||
* `seq` is a per-sender counter over call frames only. It is what makes a
|
||||
* captured frame unusable later: a `leave` from a finished call replays with a
|
||||
* sequence number the receiver has already passed, and is dropped before its
|
||||
* action is ever considered.
|
||||
*/
|
||||
export function groupCallPayload({ groupId, epoch, callId, action, fp, seq, withVideo }) {
|
||||
assertGroupId(groupId);
|
||||
assertEpoch(epoch);
|
||||
assertCallId(callId);
|
||||
assertEpoch(seq);
|
||||
assertFingerprint(fp);
|
||||
if (action !== CALL_ACTIONS.START && action !== CALL_ACTIONS.JOIN && action !== CALL_ACTIONS.LEAVE) {
|
||||
fail('unknown call action', 'bad_call_action');
|
||||
}
|
||||
return lp(
|
||||
'securebit/group/call/v1',
|
||||
fromHex(groupId), u32(epoch), fromHex(callId), action,
|
||||
fromHex(fp), u32(seq), withVideo === true ? 'v' : 'a',
|
||||
);
|
||||
}
|
||||
|
||||
export function assertCallId(callId) {
|
||||
if (typeof callId !== 'string' || callId.length !== GROUP_LIMITS.CALL_ID_BYTES * 2 || !/^[0-9a-f]+$/.test(callId)) {
|
||||
fail('malformed call id', 'bad_call_id');
|
||||
}
|
||||
return callId;
|
||||
}
|
||||
|
||||
export function newCallId() {
|
||||
return toHex(randomBytes(GROUP_LIMITS.CALL_ID_BYTES));
|
||||
}
|
||||
|
||||
export async function signGroupCall(subtle, privateKey, fields) {
|
||||
const sig = await subtle.sign({ name: 'ECDSA', hash: 'SHA-384' }, privateKey, groupCallPayload(fields));
|
||||
return new Uint8Array(sig);
|
||||
}
|
||||
|
||||
export async function verifyGroupCall(subtle, publicKey, fields, signature) {
|
||||
if (!(signature instanceof Uint8Array)
|
||||
|| signature.length < GROUP_LIMITS.MIN_SIG_BYTES
|
||||
|| signature.length > GROUP_LIMITS.MAX_SIG_BYTES) {
|
||||
return false;
|
||||
}
|
||||
let payload;
|
||||
try {
|
||||
payload = groupCallPayload(fields);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return await subtle.verify({ name: 'ECDSA', hash: 'SHA-384' }, publicKey, signature, payload);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export { GroupCryptoError };
|
||||
|
||||
+338
-13
@@ -99,6 +99,9 @@ export const LOCALE_META = {
|
||||
export const DICTIONARIES = {
|
||||
"en": {
|
||||
"language.label": "Language",
|
||||
"language.suggest.text": "This page is also available in English.",
|
||||
"language.suggest.cta": "Read in English",
|
||||
"language.suggest.dismiss": "Dismiss",
|
||||
"community.title": "Join the future of privacy",
|
||||
"community.description": "SecureBit grows thanks to its community. Your ideas and feedback shape the future of secure communication - built in the open, with complete ASN.1 validation end-to-end.",
|
||||
"community.github": "GitHub Repository",
|
||||
@@ -844,10 +847,35 @@ export const DICTIONARIES = {
|
||||
"offline.disconnect": "Disconnect",
|
||||
"offline.learnMore": "Learn more",
|
||||
"pwa.installApp": "Install App",
|
||||
"chat.onWeb": "You're on Web"
|
||||
"chat.onWeb": "You're on Web",
|
||||
"groupCall.startVoice": "Start a group voice call",
|
||||
"groupCall.startVideo": "Start a group video call",
|
||||
"groupCall.join": "Join",
|
||||
"groupCall.dismiss": "Not now",
|
||||
"groupCall.leave": "Leave call",
|
||||
"groupCall.you": "You",
|
||||
"groupCall.connecting": "Connecting…",
|
||||
"groupCall.waitingLink": "Waiting for a direct link…",
|
||||
"groupCall.legFailed": "Could not connect",
|
||||
"groupCall.startedVoice": "{name} started a voice call",
|
||||
"groupCall.startedVideo": "{name} started a video call",
|
||||
"groupCall.inCall": "{count} in the call",
|
||||
"groupCall.err.permission_denied": "The call could not start - microphone and camera access is blocked. Allow it for this site, then try again.",
|
||||
"groupCall.err.device_not_found": "The call could not start - no microphone was found on this device.",
|
||||
"groupCall.err.device_busy": "The call could not start - your microphone is in use by another app. Close it and try again.",
|
||||
"groupCall.err.media_failed": "The call could not start - the microphone could not be opened.",
|
||||
"groupCall.err.call_in_progress": "A call is already running in this group. Join it instead of starting another.",
|
||||
"groupCall.err.not_ready": "Confirm the group code before calling.",
|
||||
"groupCall.speaking": "{name} is speaking",
|
||||
"groupCall.pin": "Show {name} large",
|
||||
"groupCall.unpin": "Back to everyone",
|
||||
"groupCall.showEveryone": "Everyone"
|
||||
},
|
||||
"de": {
|
||||
"language.label": "Sprache",
|
||||
"language.suggest.text": "Diese Seite gibt es auch auf Deutsch.",
|
||||
"language.suggest.cta": "Auf Deutsch lesen",
|
||||
"language.suggest.dismiss": "Schließen",
|
||||
"community.title": "Gestalten Sie die Zukunft der Privatsphäre mit",
|
||||
"community.description": "SecureBit wächst durch seine Community. Ihre Ideen und Ihr Feedback prägen die Zukunft sicherer Kommunikation - offen entwickelt, mit vollständiger ASN.1-Validierung von Ende zu Ende.",
|
||||
"community.github": "GitHub-Repository",
|
||||
@@ -1593,10 +1621,35 @@ export const DICTIONARIES = {
|
||||
"offline.disconnect": "Trennen",
|
||||
"offline.learnMore": "Mehr erfahren",
|
||||
"pwa.installApp": "App installieren",
|
||||
"chat.onWeb": "Sie nutzen die Web-Version"
|
||||
"chat.onWeb": "Sie nutzen die Web-Version",
|
||||
"groupCall.startVoice": "Gruppen-Sprachanruf starten",
|
||||
"groupCall.startVideo": "Gruppen-Videoanruf starten",
|
||||
"groupCall.join": "Beitreten",
|
||||
"groupCall.dismiss": "Jetzt nicht",
|
||||
"groupCall.leave": "Anruf verlassen",
|
||||
"groupCall.you": "Du",
|
||||
"groupCall.connecting": "Verbinden…",
|
||||
"groupCall.waitingLink": "Warten auf eine direkte Verbindung…",
|
||||
"groupCall.legFailed": "Verbindung fehlgeschlagen",
|
||||
"groupCall.startedVoice": "{name} hat einen Sprachanruf gestartet",
|
||||
"groupCall.startedVideo": "{name} hat einen Videoanruf gestartet",
|
||||
"groupCall.inCall": "{count} im Anruf",
|
||||
"groupCall.err.permission_denied": "Der Anruf konnte nicht starten - der Zugriff auf Mikrofon und Kamera ist blockiert. Erlaube ihn für diese Seite und versuche es erneut.",
|
||||
"groupCall.err.device_not_found": "Der Anruf konnte nicht starten - auf diesem Gerät wurde kein Mikrofon gefunden.",
|
||||
"groupCall.err.device_busy": "Der Anruf konnte nicht starten - dein Mikrofon wird von einer anderen App verwendet. Schließe sie und versuche es erneut.",
|
||||
"groupCall.err.media_failed": "Der Anruf konnte nicht starten - das Mikrofon ließ sich nicht öffnen.",
|
||||
"groupCall.err.call_in_progress": "In dieser Gruppe läuft bereits ein Anruf. Tritt ihm bei, statt einen neuen zu starten.",
|
||||
"groupCall.err.not_ready": "Bestätige zuerst den Gruppencode, bevor du anrufst.",
|
||||
"groupCall.speaking": "{name} spricht",
|
||||
"groupCall.pin": "{name} groß anzeigen",
|
||||
"groupCall.unpin": "Zurück zu allen",
|
||||
"groupCall.showEveryone": "Alle"
|
||||
},
|
||||
"fr": {
|
||||
"language.label": "Langue",
|
||||
"language.suggest.text": "Cette page est aussi disponible en français.",
|
||||
"language.suggest.cta": "Lire en français",
|
||||
"language.suggest.dismiss": "Fermer",
|
||||
"community.title": "Construisez l'avenir de la vie privée",
|
||||
"community.description": "SecureBit grandit grâce à sa communauté. Vos idées et vos retours façonnent l'avenir des communications sécurisées, développées à ciel ouvert, avec une validation ASN.1 complète de bout en bout.",
|
||||
"community.github": "Dépôt GitHub",
|
||||
@@ -2342,10 +2395,35 @@ export const DICTIONARIES = {
|
||||
"offline.disconnect": "Se déconnecter",
|
||||
"offline.learnMore": "En savoir plus",
|
||||
"pwa.installApp": "Installer l'application",
|
||||
"chat.onWeb": "Vous êtes sur la version web"
|
||||
"chat.onWeb": "Vous êtes sur la version web",
|
||||
"groupCall.startVoice": "Démarrer un appel vocal de groupe",
|
||||
"groupCall.startVideo": "Démarrer un appel vidéo de groupe",
|
||||
"groupCall.join": "Rejoindre",
|
||||
"groupCall.dismiss": "Pas maintenant",
|
||||
"groupCall.leave": "Quitter l'appel",
|
||||
"groupCall.you": "Vous",
|
||||
"groupCall.connecting": "Connexion…",
|
||||
"groupCall.waitingLink": "En attente d'un lien direct…",
|
||||
"groupCall.legFailed": "Connexion impossible",
|
||||
"groupCall.startedVoice": "{name} a démarré un appel vocal",
|
||||
"groupCall.startedVideo": "{name} a démarré un appel vidéo",
|
||||
"groupCall.inCall": "{count} dans l'appel",
|
||||
"groupCall.err.permission_denied": "L'appel n'a pas pu démarrer - l'accès au micro et à la caméra est bloqué. Autorisez-le pour ce site, puis réessayez.",
|
||||
"groupCall.err.device_not_found": "L'appel n'a pas pu démarrer - aucun micro n'a été trouvé sur cet appareil.",
|
||||
"groupCall.err.device_busy": "L'appel n'a pas pu démarrer - votre micro est utilisé par une autre application. Fermez-la et réessayez.",
|
||||
"groupCall.err.media_failed": "L'appel n'a pas pu démarrer - le micro n'a pas pu être ouvert.",
|
||||
"groupCall.err.call_in_progress": "Un appel est déjà en cours dans ce groupe. Rejoignez-le plutôt que d'en démarrer un autre.",
|
||||
"groupCall.err.not_ready": "Confirmez le code du groupe avant d'appeler.",
|
||||
"groupCall.speaking": "{name} parle",
|
||||
"groupCall.pin": "Afficher {name} en grand",
|
||||
"groupCall.unpin": "Revenir à tous",
|
||||
"groupCall.showEveryone": "Tout le monde"
|
||||
},
|
||||
"es": {
|
||||
"language.label": "Idioma",
|
||||
"language.suggest.text": "Esta página también está disponible en español.",
|
||||
"language.suggest.cta": "Leer en español",
|
||||
"language.suggest.dismiss": "Cerrar",
|
||||
"community.title": "Construye el futuro de la privacidad",
|
||||
"community.description": "SecureBit crece gracias a su comunidad. Tus ideas y comentarios dan forma al futuro de la comunicación segura, desarrollada de forma abierta y con validación ASN.1 completa de extremo a extremo.",
|
||||
"community.github": "Repositorio en GitHub",
|
||||
@@ -3091,10 +3169,35 @@ export const DICTIONARIES = {
|
||||
"offline.disconnect": "Desconectar",
|
||||
"offline.learnMore": "Más información",
|
||||
"pwa.installApp": "Instalar la app",
|
||||
"chat.onWeb": "Estás en la versión web"
|
||||
"chat.onWeb": "Estás en la versión web",
|
||||
"groupCall.startVoice": "Iniciar llamada de voz grupal",
|
||||
"groupCall.startVideo": "Iniciar videollamada grupal",
|
||||
"groupCall.join": "Unirse",
|
||||
"groupCall.dismiss": "Ahora no",
|
||||
"groupCall.leave": "Salir de la llamada",
|
||||
"groupCall.you": "Tú",
|
||||
"groupCall.connecting": "Conectando…",
|
||||
"groupCall.waitingLink": "Esperando un enlace directo…",
|
||||
"groupCall.legFailed": "No se pudo conectar",
|
||||
"groupCall.startedVoice": "{name} inició una llamada de voz",
|
||||
"groupCall.startedVideo": "{name} inició una videollamada",
|
||||
"groupCall.inCall": "{count} en la llamada",
|
||||
"groupCall.err.permission_denied": "La llamada no pudo iniciarse - el acceso al micrófono y a la cámara está bloqueado. Permítelo para este sitio e inténtalo de nuevo.",
|
||||
"groupCall.err.device_not_found": "La llamada no pudo iniciarse - no se encontró ningún micrófono en este dispositivo.",
|
||||
"groupCall.err.device_busy": "La llamada no pudo iniciarse - otra aplicación está usando tu micrófono. Ciérrala e inténtalo de nuevo.",
|
||||
"groupCall.err.media_failed": "La llamada no pudo iniciarse - no se pudo abrir el micrófono.",
|
||||
"groupCall.err.call_in_progress": "Ya hay una llamada en este grupo. Únete en lugar de iniciar otra.",
|
||||
"groupCall.err.not_ready": "Confirma el código del grupo antes de llamar.",
|
||||
"groupCall.speaking": "{name} está hablando",
|
||||
"groupCall.pin": "Ver a {name} en grande",
|
||||
"groupCall.unpin": "Volver a todos",
|
||||
"groupCall.showEveryone": "Todos"
|
||||
},
|
||||
"uk": {
|
||||
"language.label": "Мова",
|
||||
"language.suggest.text": "Ця сторінка також доступна українською.",
|
||||
"language.suggest.cta": "Читати українською",
|
||||
"language.suggest.dismiss": "Закрити",
|
||||
"community.title": "Долучайтеся до майбутнього приватності",
|
||||
"community.description": "SecureBit росте завдяки своїй спільноті. Ваші ідеї та відгуки формують майбутнє захищеного спілкування - розробка ведеться відкрито, з повною валідацією ASN.1 на всьому шляху.",
|
||||
"community.github": "Репозиторій на GitHub",
|
||||
@@ -3840,10 +3943,35 @@ export const DICTIONARIES = {
|
||||
"offline.disconnect": "Від'єднатися",
|
||||
"offline.learnMore": "Докладніше",
|
||||
"pwa.installApp": "Встановити застосунок",
|
||||
"chat.onWeb": "Ви у вебверсії"
|
||||
"chat.onWeb": "Ви у вебверсії",
|
||||
"groupCall.startVoice": "Почати груповий голосовий дзвінок",
|
||||
"groupCall.startVideo": "Почати груповий відеодзвінок",
|
||||
"groupCall.join": "Приєднатися",
|
||||
"groupCall.dismiss": "Не зараз",
|
||||
"groupCall.leave": "Вийти з дзвінка",
|
||||
"groupCall.you": "Ви",
|
||||
"groupCall.connecting": "З'єднання…",
|
||||
"groupCall.waitingLink": "Очікування прямого з'єднання…",
|
||||
"groupCall.legFailed": "Не вдалося з'єднатися",
|
||||
"groupCall.startedVoice": "{name} почав голосовий дзвінок",
|
||||
"groupCall.startedVideo": "{name} почав відеодзвінок",
|
||||
"groupCall.inCall": "{count} у дзвінку",
|
||||
"groupCall.err.permission_denied": "Дзвінок не почався - доступ до мікрофона й камери заблоковано. Дозвольте його для цього сайту й спробуйте ще раз.",
|
||||
"groupCall.err.device_not_found": "Дзвінок не почався - на цьому пристрої не знайдено мікрофона.",
|
||||
"groupCall.err.device_busy": "Дзвінок не почався - мікрофон зайнятий іншим застосунком. Закрийте його й спробуйте ще раз.",
|
||||
"groupCall.err.media_failed": "Дзвінок не почався - не вдалося відкрити мікрофон.",
|
||||
"groupCall.err.call_in_progress": "У цій групі вже триває дзвінок. Приєднайтеся до нього, а не починайте новий.",
|
||||
"groupCall.err.not_ready": "Спершу підтвердьте код групи.",
|
||||
"groupCall.speaking": "{name} говорить",
|
||||
"groupCall.pin": "Показати {name} великим",
|
||||
"groupCall.unpin": "Повернутися до всіх",
|
||||
"groupCall.showEveryone": "Усі"
|
||||
},
|
||||
"ru": {
|
||||
"language.label": "Язык",
|
||||
"language.suggest.text": "Эта страница также доступна на русском.",
|
||||
"language.suggest.cta": "Читать по-русски",
|
||||
"language.suggest.dismiss": "Закрыть",
|
||||
"community.title": "Присоединяйтесь к будущему приватности",
|
||||
"community.description": "SecureBit растёт благодаря сообществу. Ваши идеи и отзывы формируют будущее защищённого общения - разработка ведётся открыто, с полной проверкой ASN.1 на всём пути.",
|
||||
"community.github": "Репозиторий на GitHub",
|
||||
@@ -4589,10 +4717,35 @@ export const DICTIONARIES = {
|
||||
"offline.disconnect": "Отключиться",
|
||||
"offline.learnMore": "Подробнее",
|
||||
"pwa.installApp": "Установить приложение",
|
||||
"chat.onWeb": "Вы в веб-версии"
|
||||
"chat.onWeb": "Вы в веб-версии",
|
||||
"groupCall.startVoice": "Начать групповой голосовой звонок",
|
||||
"groupCall.startVideo": "Начать групповой видеозвонок",
|
||||
"groupCall.join": "Присоединиться",
|
||||
"groupCall.dismiss": "Не сейчас",
|
||||
"groupCall.leave": "Выйти из звонка",
|
||||
"groupCall.you": "Вы",
|
||||
"groupCall.connecting": "Соединение…",
|
||||
"groupCall.waitingLink": "Ожидание прямого соединения…",
|
||||
"groupCall.legFailed": "Не удалось соединиться",
|
||||
"groupCall.startedVoice": "{name} начал голосовой звонок",
|
||||
"groupCall.startedVideo": "{name} начал видеозвонок",
|
||||
"groupCall.inCall": "{count} в звонке",
|
||||
"groupCall.err.permission_denied": "Звонок не начался - доступ к микрофону и камере заблокирован. Разрешите его для этого сайта и попробуйте снова.",
|
||||
"groupCall.err.device_not_found": "Звонок не начался - на этом устройстве не найден микрофон.",
|
||||
"groupCall.err.device_busy": "Звонок не начался - микрофон занят другим приложением. Закройте его и попробуйте снова.",
|
||||
"groupCall.err.media_failed": "Звонок не начался - не удалось открыть микрофон.",
|
||||
"groupCall.err.call_in_progress": "В этой группе уже идёт звонок. Присоединитесь к нему, а не начинайте новый.",
|
||||
"groupCall.err.not_ready": "Сначала подтвердите код группы.",
|
||||
"groupCall.speaking": "{name} говорит",
|
||||
"groupCall.pin": "Показать {name} крупно",
|
||||
"groupCall.unpin": "Вернуться ко всем",
|
||||
"groupCall.showEveryone": "Все"
|
||||
},
|
||||
"zh": {
|
||||
"language.label": "语言",
|
||||
"language.suggest.text": "本页面也有简体中文版本。",
|
||||
"language.suggest.cta": "阅读简体中文",
|
||||
"language.suggest.dismiss": "关闭",
|
||||
"community.title": "一起构筑隐私的未来",
|
||||
"community.description": "SecureBit 因社区而成长。你的想法与反馈塑造着安全通讯的未来 —— 开放开发,全程完整的 ASN.1 校验。",
|
||||
"community.github": "GitHub 仓库",
|
||||
@@ -5338,10 +5491,35 @@ export const DICTIONARIES = {
|
||||
"offline.disconnect": "断开连接",
|
||||
"offline.learnMore": "了解更多",
|
||||
"pwa.installApp": "安装应用",
|
||||
"chat.onWeb": "你正在使用网页版"
|
||||
"chat.onWeb": "你正在使用网页版",
|
||||
"groupCall.startVoice": "发起群组语音通话",
|
||||
"groupCall.startVideo": "发起群组视频通话",
|
||||
"groupCall.join": "加入",
|
||||
"groupCall.dismiss": "暂不加入",
|
||||
"groupCall.leave": "离开通话",
|
||||
"groupCall.you": "你",
|
||||
"groupCall.connecting": "连接中…",
|
||||
"groupCall.waitingLink": "正在等待直连…",
|
||||
"groupCall.legFailed": "无法连接",
|
||||
"groupCall.startedVoice": "{name} 发起了语音通话",
|
||||
"groupCall.startedVideo": "{name} 发起了视频通话",
|
||||
"groupCall.inCall": "{count} 人在通话中",
|
||||
"groupCall.err.permission_denied": "通话无法开始 - 麦克风和摄像头权限被拒绝。请为本站点允许后重试。",
|
||||
"groupCall.err.device_not_found": "通话无法开始 - 此设备上未找到麦克风。",
|
||||
"groupCall.err.device_busy": "通话无法开始 - 麦克风正被其他应用占用。请关闭后重试。",
|
||||
"groupCall.err.media_failed": "通话无法开始 - 无法打开麦克风。",
|
||||
"groupCall.err.call_in_progress": "该群组已有通话正在进行。请加入,而不是另开一个。",
|
||||
"groupCall.err.not_ready": "请先确认群组安全码再通话。",
|
||||
"groupCall.speaking": "{name} 正在讲话",
|
||||
"groupCall.pin": "放大显示 {name}",
|
||||
"groupCall.unpin": "返回所有人",
|
||||
"groupCall.showEveryone": "所有人"
|
||||
},
|
||||
"ko": {
|
||||
"language.label": "언어",
|
||||
"language.suggest.text": "이 페이지는 한국어로도 볼 수 있습니다.",
|
||||
"language.suggest.cta": "한국어로 보기",
|
||||
"language.suggest.dismiss": "닫기",
|
||||
"community.title": "프라이버시의 미래를 함께 만듭니다",
|
||||
"community.description": "SecureBit은 커뮤니티 덕분에 자랍니다. 여러분의 아이디어와 의견이 안전한 소통의 미래를 만듭니다 - 모든 과정이 공개되어 있고, 전 구간에서 ASN.1 검증을 수행합니다.",
|
||||
"community.github": "GitHub 저장소",
|
||||
@@ -6087,10 +6265,35 @@ export const DICTIONARIES = {
|
||||
"offline.disconnect": "연결 끊기",
|
||||
"offline.learnMore": "자세히 보기",
|
||||
"pwa.installApp": "앱 설치",
|
||||
"chat.onWeb": "웹 버전을 쓰고 있습니다"
|
||||
"chat.onWeb": "웹 버전을 쓰고 있습니다",
|
||||
"groupCall.startVoice": "그룹 음성 통화 시작",
|
||||
"groupCall.startVideo": "그룹 영상 통화 시작",
|
||||
"groupCall.join": "참여",
|
||||
"groupCall.dismiss": "나중에",
|
||||
"groupCall.leave": "통화 나가기",
|
||||
"groupCall.you": "나",
|
||||
"groupCall.connecting": "연결 중…",
|
||||
"groupCall.waitingLink": "직접 연결을 기다리는 중…",
|
||||
"groupCall.legFailed": "연결하지 못했습니다",
|
||||
"groupCall.startedVoice": "{name} 님이 음성 통화를 시작했습니다",
|
||||
"groupCall.startedVideo": "{name} 님이 영상 통화를 시작했습니다",
|
||||
"groupCall.inCall": "통화 중 {count}명",
|
||||
"groupCall.err.permission_denied": "통화를 시작할 수 없습니다 - 마이크와 카메라 접근이 차단되어 있습니다. 이 사이트에 허용한 뒤 다시 시도하세요.",
|
||||
"groupCall.err.device_not_found": "통화를 시작할 수 없습니다 - 이 기기에서 마이크를 찾을 수 없습니다.",
|
||||
"groupCall.err.device_busy": "통화를 시작할 수 없습니다 - 마이크를 다른 앱이 사용 중입니다. 종료한 뒤 다시 시도하세요.",
|
||||
"groupCall.err.media_failed": "통화를 시작할 수 없습니다 - 마이크를 열지 못했습니다.",
|
||||
"groupCall.err.call_in_progress": "이 그룹에서 이미 통화가 진행 중입니다. 새로 시작하지 말고 참여하세요.",
|
||||
"groupCall.err.not_ready": "통화하기 전에 그룹 코드를 확인하세요.",
|
||||
"groupCall.speaking": "{name} 님이 말하는 중",
|
||||
"groupCall.pin": "{name} 크게 보기",
|
||||
"groupCall.unpin": "전체 보기로 돌아가기",
|
||||
"groupCall.showEveryone": "전체"
|
||||
},
|
||||
"hi": {
|
||||
"language.label": "भाषा",
|
||||
"language.suggest.text": "यह पृष्ठ हिन्दी में भी उपलब्ध है।",
|
||||
"language.suggest.cta": "हिन्दी में पढ़ें",
|
||||
"language.suggest.dismiss": "बंद करें",
|
||||
"community.title": "निजता के भविष्य में शामिल हों",
|
||||
"community.description": "SecureBit अपने समुदाय से बढ़ता है। आपके विचार और सुझाव सुरक्षित संवाद का भविष्य गढ़ते हैं - खुले में बना, और पूरे रास्ते पूरी ASN.1 जाँच के साथ।",
|
||||
"community.github": "GitHub रिपॉज़िटरी",
|
||||
@@ -6836,10 +7039,35 @@ export const DICTIONARIES = {
|
||||
"offline.disconnect": "जुड़ाव तोड़ें",
|
||||
"offline.learnMore": "और जानें",
|
||||
"pwa.installApp": "ऐप इंस्टॉल करें",
|
||||
"chat.onWeb": "आप वेब संस्करण पर हैं"
|
||||
"chat.onWeb": "आप वेब संस्करण पर हैं",
|
||||
"groupCall.startVoice": "समूह वॉइस कॉल शुरू करें",
|
||||
"groupCall.startVideo": "समूह वीडियो कॉल शुरू करें",
|
||||
"groupCall.join": "शामिल हों",
|
||||
"groupCall.dismiss": "अभी नहीं",
|
||||
"groupCall.leave": "कॉल छोड़ें",
|
||||
"groupCall.you": "आप",
|
||||
"groupCall.connecting": "जुड़ रहा है…",
|
||||
"groupCall.waitingLink": "सीधे लिंक की प्रतीक्षा…",
|
||||
"groupCall.legFailed": "कनेक्ट नहीं हो सका",
|
||||
"groupCall.startedVoice": "{name} ने वॉइस कॉल शुरू की",
|
||||
"groupCall.startedVideo": "{name} ने वीडियो कॉल शुरू की",
|
||||
"groupCall.inCall": "कॉल में {count}",
|
||||
"groupCall.err.permission_denied": "कॉल शुरू नहीं हो सकी - माइक्रोफ़ोन और कैमरे की अनुमति अवरुद्ध है। इस साइट के लिए अनुमति दें और फिर कोशिश करें।",
|
||||
"groupCall.err.device_not_found": "कॉल शुरू नहीं हो सकी - इस डिवाइस पर कोई माइक्रोफ़ोन नहीं मिला।",
|
||||
"groupCall.err.device_busy": "कॉल शुरू नहीं हो सकी - आपका माइक्रोफ़ोन किसी अन्य ऐप के उपयोग में है। उसे बंद करके फिर कोशिश करें।",
|
||||
"groupCall.err.media_failed": "कॉल शुरू नहीं हो सकी - माइक्रोफ़ोन नहीं खुल सका।",
|
||||
"groupCall.err.call_in_progress": "इस समूह में पहले से एक कॉल चल रही है। नई शुरू करने के बजाय उसमें शामिल हों।",
|
||||
"groupCall.err.not_ready": "कॉल करने से पहले समूह कोड की पुष्टि करें।",
|
||||
"groupCall.speaking": "{name} बोल रहे हैं",
|
||||
"groupCall.pin": "{name} को बड़ा दिखाएँ",
|
||||
"groupCall.unpin": "सभी पर लौटें",
|
||||
"groupCall.showEveryone": "सभी"
|
||||
},
|
||||
"ar": {
|
||||
"language.label": "اللغة",
|
||||
"language.suggest.text": "هذه الصفحة متوفرة أيضًا بالعربية.",
|
||||
"language.suggest.cta": "اقرأ بالعربية",
|
||||
"language.suggest.dismiss": "إغلاق",
|
||||
"community.title": "انضمّ إلى مستقبل الخصوصية",
|
||||
"community.description": "ينمو SecureBit بفضل مجتمعه. أفكاركم وملاحظاتكم هي ما يصوغ مستقبل التواصل الآمن - مبنيّ في العلن، مع تحقّق ASN.1 كامل من طرف إلى طرف.",
|
||||
"community.github": "مستودع GitHub",
|
||||
@@ -7585,10 +7813,35 @@ export const DICTIONARIES = {
|
||||
"offline.disconnect": "قطع الاتصال",
|
||||
"offline.learnMore": "اعرف المزيد",
|
||||
"pwa.installApp": "ثبّت التطبيق",
|
||||
"chat.onWeb": "أنت على الويب"
|
||||
"chat.onWeb": "أنت على الويب",
|
||||
"groupCall.startVoice": "بدء مكالمة صوتية جماعية",
|
||||
"groupCall.startVideo": "بدء مكالمة فيديو جماعية",
|
||||
"groupCall.join": "انضمام",
|
||||
"groupCall.dismiss": "ليس الآن",
|
||||
"groupCall.leave": "مغادرة المكالمة",
|
||||
"groupCall.you": "أنت",
|
||||
"groupCall.connecting": "جارٍ الاتصال…",
|
||||
"groupCall.waitingLink": "في انتظار رابط مباشر…",
|
||||
"groupCall.legFailed": "تعذّر الاتصال",
|
||||
"groupCall.startedVoice": "بدأ {name} مكالمة صوتية",
|
||||
"groupCall.startedVideo": "بدأ {name} مكالمة فيديو",
|
||||
"groupCall.inCall": "{count} في المكالمة",
|
||||
"groupCall.err.permission_denied": "تعذّر بدء المكالمة - الوصول إلى الميكروفون والكاميرا محظور. اسمح به لهذا الموقع ثم أعد المحاولة.",
|
||||
"groupCall.err.device_not_found": "تعذّر بدء المكالمة - لم يُعثر على ميكروفون في هذا الجهاز.",
|
||||
"groupCall.err.device_busy": "تعذّر بدء المكالمة - الميكروفون مستخدَم من تطبيق آخر. أغلقه ثم أعد المحاولة.",
|
||||
"groupCall.err.media_failed": "تعذّر بدء المكالمة - لم يمكن فتح الميكروفون.",
|
||||
"groupCall.err.call_in_progress": "هناك مكالمة جارية بالفعل في هذه المجموعة. انضم إليها بدل بدء أخرى.",
|
||||
"groupCall.err.not_ready": "أكّد رمز المجموعة قبل الاتصال.",
|
||||
"groupCall.speaking": "{name} يتحدّث الآن",
|
||||
"groupCall.pin": "عرض {name} بحجم كبير",
|
||||
"groupCall.unpin": "العودة إلى الجميع",
|
||||
"groupCall.showEveryone": "الجميع"
|
||||
},
|
||||
"he": {
|
||||
"language.label": "שפה",
|
||||
"language.suggest.text": "הדף הזה זמין גם בעברית.",
|
||||
"language.suggest.cta": "לקריאה בעברית",
|
||||
"language.suggest.dismiss": "סגירה",
|
||||
"community.title": "הצטרפו לעתיד של הפרטיות",
|
||||
"community.description": "SecureBit גדל בזכות הקהילה שלו. הרעיונות והמשוב שלכם מעצבים את עתיד התקשורת המאובטחת - נבנה בגלוי, עם אימות ASN.1 מלא מקצה לקצה.",
|
||||
"community.github": "מאגר GitHub",
|
||||
@@ -8334,10 +8587,35 @@ export const DICTIONARIES = {
|
||||
"offline.disconnect": "ניתוק",
|
||||
"offline.learnMore": "מידע נוסף",
|
||||
"pwa.installApp": "התקנת האפליקציה",
|
||||
"chat.onWeb": "אתם בגרסת הווב"
|
||||
"chat.onWeb": "אתם בגרסת הווב",
|
||||
"groupCall.startVoice": "התחלת שיחה קולית קבוצתית",
|
||||
"groupCall.startVideo": "התחלת שיחת וידאו קבוצתית",
|
||||
"groupCall.join": "הצטרפות",
|
||||
"groupCall.dismiss": "לא עכשיו",
|
||||
"groupCall.leave": "יציאה מהשיחה",
|
||||
"groupCall.you": "את/ה",
|
||||
"groupCall.connecting": "מתחבר…",
|
||||
"groupCall.waitingLink": "ממתין לקישור ישיר…",
|
||||
"groupCall.legFailed": "ההתחברות נכשלה",
|
||||
"groupCall.startedVoice": "{name} התחיל שיחה קולית",
|
||||
"groupCall.startedVideo": "{name} התחיל שיחת וידאו",
|
||||
"groupCall.inCall": "{count} בשיחה",
|
||||
"groupCall.err.permission_denied": "השיחה לא התחילה - הגישה למיקרופון ולמצלמה חסומה. אפשרו אותה לאתר הזה ונסו שוב.",
|
||||
"groupCall.err.device_not_found": "השיחה לא התחילה - לא נמצא מיקרופון במכשיר הזה.",
|
||||
"groupCall.err.device_busy": "השיחה לא התחילה - המיקרופון בשימוש אפליקציה אחרת. סגרו אותה ונסו שוב.",
|
||||
"groupCall.err.media_failed": "השיחה לא התחילה - לא ניתן היה לפתוח את המיקרופון.",
|
||||
"groupCall.err.call_in_progress": "כבר מתקיימת שיחה בקבוצה הזו. הצטרפו אליה במקום להתחיל אחת חדשה.",
|
||||
"groupCall.err.not_ready": "אשרו את קוד הקבוצה לפני שיחה.",
|
||||
"groupCall.speaking": "{name} מדבר/ת",
|
||||
"groupCall.pin": "הצגת {name} בגדול",
|
||||
"groupCall.unpin": "חזרה לכולם",
|
||||
"groupCall.showEveryone": "כולם"
|
||||
},
|
||||
"fa": {
|
||||
"language.label": "زبان",
|
||||
"language.suggest.text": "این صفحه به فارسی هم در دسترس است.",
|
||||
"language.suggest.cta": "خواندن به فارسی",
|
||||
"language.suggest.dismiss": "بستن",
|
||||
"community.title": "به آیندهٔ حریم خصوصی بپیوندید",
|
||||
"community.description": "SecureBit بهلطف جامعهاش رشد میکند. ایدهها و بازخوردهای شما آیندهٔ ارتباط امن را شکل میدهد - ساختهشده در فضای باز، با اعتبارسنجی کامل ASN.1 از یک سر تا سر دیگر.",
|
||||
"community.github": "مخزن GitHub",
|
||||
@@ -9083,10 +9361,35 @@ export const DICTIONARIES = {
|
||||
"offline.disconnect": "قطع اتصال",
|
||||
"offline.learnMore": "بیشتر بدانید",
|
||||
"pwa.installApp": "نصب برنامه",
|
||||
"chat.onWeb": "شما روی نسخهٔ وب هستید"
|
||||
"chat.onWeb": "شما روی نسخهٔ وب هستید",
|
||||
"groupCall.startVoice": "شروع تماس صوتی گروهی",
|
||||
"groupCall.startVideo": "شروع تماس تصویری گروهی",
|
||||
"groupCall.join": "پیوستن",
|
||||
"groupCall.dismiss": "الان نه",
|
||||
"groupCall.leave": "خروج از تماس",
|
||||
"groupCall.you": "شما",
|
||||
"groupCall.connecting": "در حال اتصال…",
|
||||
"groupCall.waitingLink": "در انتظار یک پیوند مستقیم…",
|
||||
"groupCall.legFailed": "اتصال برقرار نشد",
|
||||
"groupCall.startedVoice": "{name} یک تماس صوتی آغاز کرد",
|
||||
"groupCall.startedVideo": "{name} یک تماس تصویری آغاز کرد",
|
||||
"groupCall.inCall": "{count} نفر در تماس",
|
||||
"groupCall.err.permission_denied": "تماس آغاز نشد - دسترسی به میکروفون و دوربین مسدود است. آن را برای این سایت مجاز کنید و دوباره تلاش کنید.",
|
||||
"groupCall.err.device_not_found": "تماس آغاز نشد - میکروفونی روی این دستگاه پیدا نشد.",
|
||||
"groupCall.err.device_busy": "تماس آغاز نشد - میکروفون شما در حال استفاده توسط برنامه دیگری است. آن را ببندید و دوباره تلاش کنید.",
|
||||
"groupCall.err.media_failed": "تماس آغاز نشد - میکروفون باز نشد.",
|
||||
"groupCall.err.call_in_progress": "همین حالا تماسی در این گروه در جریان است. بهجای شروع تماس تازه، به آن بپیوندید.",
|
||||
"groupCall.err.not_ready": "پیش از تماس، کد گروه را تأیید کنید.",
|
||||
"groupCall.speaking": "{name} در حال صحبت است",
|
||||
"groupCall.pin": "نمایش بزرگ {name}",
|
||||
"groupCall.unpin": "بازگشت به همه",
|
||||
"groupCall.showEveryone": "همه"
|
||||
},
|
||||
"ur": {
|
||||
"language.label": "زبان",
|
||||
"language.suggest.text": "یہ صفحہ اردو میں بھی دستیاب ہے۔",
|
||||
"language.suggest.cta": "اردو میں پڑھیں",
|
||||
"language.suggest.dismiss": "بند کریں",
|
||||
"community.title": "نجیت کے مستقبل میں شامل ہوں",
|
||||
"community.description": "SecureBit اپنی برادری کی بدولت بڑھتا ہے۔ آپ کے خیالات اور آراء محفوظ رابطے کا مستقبل تشکیل دیتے ہیں - کھلے عام بنایا گیا، سرے سے سرے تک مکمل ASN.1 توثیق کے ساتھ۔",
|
||||
"community.github": "GitHub مخزن",
|
||||
@@ -9832,6 +10135,28 @@ export const DICTIONARIES = {
|
||||
"offline.disconnect": "منقطع کریں",
|
||||
"offline.learnMore": "مزید جانیں",
|
||||
"pwa.installApp": "ایپ نصب کریں",
|
||||
"chat.onWeb": "آپ ویب پر ہیں"
|
||||
"chat.onWeb": "آپ ویب پر ہیں",
|
||||
"groupCall.startVoice": "گروپ وائس کال شروع کریں",
|
||||
"groupCall.startVideo": "گروپ ویڈیو کال شروع کریں",
|
||||
"groupCall.join": "شامل ہوں",
|
||||
"groupCall.dismiss": "ابھی نہیں",
|
||||
"groupCall.leave": "کال چھوڑیں",
|
||||
"groupCall.you": "آپ",
|
||||
"groupCall.connecting": "منسلک ہو رہا ہے…",
|
||||
"groupCall.waitingLink": "براہِ راست رابطے کا انتظار…",
|
||||
"groupCall.legFailed": "منسلک نہیں ہو سکا",
|
||||
"groupCall.startedVoice": "{name} نے وائس کال شروع کی",
|
||||
"groupCall.startedVideo": "{name} نے ویڈیو کال شروع کی",
|
||||
"groupCall.inCall": "کال میں {count}",
|
||||
"groupCall.err.permission_denied": "کال شروع نہیں ہو سکی - مائیکروفون اور کیمرے تک رسائی بند ہے۔ اس سائٹ کے لیے اجازت دیں اور دوبارہ کوشش کریں۔",
|
||||
"groupCall.err.device_not_found": "کال شروع نہیں ہو سکی - اس ڈیوائس پر کوئی مائیکروفون نہیں ملا۔",
|
||||
"groupCall.err.device_busy": "کال شروع نہیں ہو سکی - آپ کا مائیکروفون کسی اور ایپ کے زیرِ استعمال ہے۔ اسے بند کر کے دوبارہ کوشش کریں۔",
|
||||
"groupCall.err.media_failed": "کال شروع نہیں ہو سکی - مائیکروفون نہیں کھل سکا۔",
|
||||
"groupCall.err.call_in_progress": "اس گروپ میں پہلے ہی ایک کال جاری ہے۔ نئی شروع کرنے کے بجائے اس میں شامل ہوں۔",
|
||||
"groupCall.err.not_ready": "کال سے پہلے گروپ کوڈ کی تصدیق کریں۔",
|
||||
"groupCall.speaking": "{name} بول رہے ہیں",
|
||||
"groupCall.pin": "{name} کو بڑا دکھائیں",
|
||||
"groupCall.unpin": "سب پر واپس جائیں",
|
||||
"groupCall.showEveryone": "سب"
|
||||
}
|
||||
};
|
||||
|
||||
@@ -148,6 +148,29 @@ export function direction(code = currentLocale()) {
|
||||
*/
|
||||
export const LTR_TEXT = { dir: 'ltr', style: { unicodeBidi: 'isolate', textAlign: 'start' } };
|
||||
|
||||
/**
|
||||
* The suggestion is an offer, and an offer that keeps coming back after it was turned
|
||||
* down is an ad. One dismissal is remembered for good; storage can be unavailable, in
|
||||
* which case the bar simply comes back — a worse experience, never a broken one.
|
||||
*/
|
||||
const SUGGEST_KEY = 'securebit-locale-suggest-dismissed';
|
||||
|
||||
export function localeSuggestionDismissed() {
|
||||
try {
|
||||
return localStorage.getItem(SUGGEST_KEY) === '1';
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function dismissLocaleSuggestion() {
|
||||
try {
|
||||
localStorage.setItem(SUGGEST_KEY, '1');
|
||||
} catch (_) {
|
||||
// Nothing to do: the bar reappears next visit, which is not worth an exception.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A locale the visitor would probably rather read, when it is not the one they are on.
|
||||
* Used to offer a link, never to redirect: an automatic redirect sends Googlebot —
|
||||
|
||||
@@ -609,6 +609,7 @@ this._secureLog('info', '🔒 Enhanced Mutex system fully initialized and valida
|
||||
remoteHasVideo: false,
|
||||
callId: null,
|
||||
quality: null, // 'excellent'|'good'|'fair'|'poor'|null — link quality for the UI
|
||||
groupCallId: null, // set while this call is one leg of a group call
|
||||
error: null
|
||||
};
|
||||
this.localMediaStream = null;
|
||||
@@ -620,6 +621,32 @@ this._secureLog('info', '🔒 Enhanced Mutex system fully initialized and valida
|
||||
this._callFacingMode = 'user';
|
||||
this._adaptationController = null; // NetworkAdaptationController while a call is active
|
||||
|
||||
// ── Group calls ────────────────────────────────────────────────────────
|
||||
// A group call is N-1 ordinary calls, one per member, each on its own
|
||||
// already-verified pairwise session. Two things differ from a 1:1 call and
|
||||
// both live here, because the group layer owns them and this manager only
|
||||
// carries one leg of the result.
|
||||
//
|
||||
// _callGroupContext The group call this leg belongs to. While it is
|
||||
// set, an inbound offer is answered without ringing —
|
||||
// the user already consented, once, by joining the
|
||||
// call, and asking again per member would mean seven
|
||||
// prompts for one decision. It is set ONLY by the
|
||||
// group-call controller while the local user is in a
|
||||
// call, and cleared the moment they leave, so it can
|
||||
// never auto-answer a call the user did not ask for.
|
||||
//
|
||||
// _externalMediaStream One capture shared across every leg. Without it
|
||||
// each leg would open its own getUserMedia, which is
|
||||
// N microphone captures of the same microphone and N
|
||||
// camera indicators for one call. The stream is owned
|
||||
// by the controller: this manager attaches its tracks
|
||||
// and never stops them.
|
||||
this._callGroupContext = null;
|
||||
this._externalMediaStream = null;
|
||||
/** Extra call-state subscribers, so the group layer and the 1:1 UI coexist. */
|
||||
this._callStateListeners = new Set();
|
||||
|
||||
// PFS (Perfect Forward Secrecy) Implementation
|
||||
this.keyRotationInterval = null; // отключаем таймерную ротацию
|
||||
this.lastKeyRotation = Date.now();
|
||||
@@ -14523,6 +14550,13 @@ async processMessage(data) {
|
||||
try { this._stopLocalMediaPermanently?.(); } catch (_) {}
|
||||
this._callAudioSender = null;
|
||||
this._callVideoSender = null;
|
||||
// A session that is ending is no longer a leg of anything. Clearing
|
||||
// the group context matters most: it is what allows an inbound offer
|
||||
// to be answered without asking, and it must not survive the session
|
||||
// it was granted for.
|
||||
this._callGroupContext = null;
|
||||
this._externalMediaStream = null;
|
||||
this._callStateListeners?.clear?.();
|
||||
|
||||
// Preserve the explicit-disconnect notification flow before channels are closed.
|
||||
this.intentionalDisconnect = true;
|
||||
@@ -15157,6 +15191,71 @@ checkFileTransferReadiness() {
|
||||
return { ...this.callState };
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to call-state changes without taking the onCallStateChanged slot.
|
||||
* @param {(state: object) => void} listener
|
||||
* @returns {() => void} unsubscribe
|
||||
*/
|
||||
addCallStateListener(listener) {
|
||||
if (typeof listener !== 'function') return () => {};
|
||||
this._callStateListeners.add(listener);
|
||||
return () => this._callStateListeners.delete(listener);
|
||||
}
|
||||
|
||||
removeCallStateListener(listener) {
|
||||
this._callStateListeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark this session as one leg of a group call, or clear it.
|
||||
*
|
||||
* While set, an inbound call offer is answered without ringing. That is safe
|
||||
* only because of who sets it: the group-call controller, and only while the
|
||||
* local user is in that group's call. Nothing on the wire can set it, so a
|
||||
* peer cannot cause this session to open a microphone on its own.
|
||||
*
|
||||
* @param {string|null} groupCallId
|
||||
*/
|
||||
setCallGroupContext(groupCallId) {
|
||||
this._callGroupContext = (typeof groupCallId === 'string' && groupCallId) ? groupCallId : null;
|
||||
// Reflect it immediately: the 1:1 call UI uses this field to stay out of
|
||||
// the way of a group call running on the same session.
|
||||
this._updateCallState({});
|
||||
|
||||
// An offer that arrived BEFORE we knew this session was a call leg is
|
||||
// sitting here ringing, and nothing would ever pick it up: the auto-answer
|
||||
// is checked when the offer lands, not afterwards. That window is real —
|
||||
// a peer can dial the moment they join, which can beat the announcement
|
||||
// telling us they joined — and the visible symptom was one member stuck
|
||||
// at "connecting" for the whole call while the other's phone rang for a
|
||||
// call they had already agreed to. Answer it now.
|
||||
if (this._callGroupContext && this._pendingCallOffer && this.callState.phase === 'incoming') {
|
||||
this.acceptCall().catch((error) => {
|
||||
this._secureLog('warn', '⚠️ Failed to answer a group call leg that was already ringing', {
|
||||
errorType: error?.constructor?.name
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use a capture owned by somebody else for the next call on this session.
|
||||
*
|
||||
* The group-call controller captures the microphone and camera ONCE and
|
||||
* hands the same stream to every leg. This manager attaches its tracks and
|
||||
* must never stop them: the other legs are still sending them.
|
||||
*
|
||||
* @param {MediaStream|null} stream
|
||||
*/
|
||||
setExternalMediaStream(stream) {
|
||||
this._externalMediaStream = stream || null;
|
||||
}
|
||||
|
||||
/** True when the live capture belongs to somebody else. */
|
||||
_usingExternalMedia() {
|
||||
return !!this._externalMediaStream && this.localMediaStream === this._externalMediaStream;
|
||||
}
|
||||
|
||||
getRemoteMediaStream() {
|
||||
return this.remoteMediaStream;
|
||||
}
|
||||
@@ -15195,13 +15294,19 @@ checkFileTransferReadiness() {
|
||||
}
|
||||
|
||||
_updateCallState(patch) {
|
||||
this.callState = { ...this.callState, ...patch };
|
||||
this.callState = { ...this.callState, ...patch, groupCallId: this._callGroupContext };
|
||||
const snapshot = this.getCallState();
|
||||
// Adaptation controller follows the call lifecycle: run while active,
|
||||
// stop when the call ends.
|
||||
if (snapshot.phase === 'active') this._startAdaptation();
|
||||
else if (snapshot.phase === 'idle') this._stopAdaptation();
|
||||
try { this.onCallStateChanged?.(snapshot); } catch (_) {}
|
||||
// A single callback property cannot serve two owners, and a group call
|
||||
// has two: the group's media controller and whatever UI is bound to this
|
||||
// session. Listeners are additive so neither has to displace the other.
|
||||
for (const listener of this._callStateListeners) {
|
||||
try { listener(snapshot); } catch (_) {}
|
||||
}
|
||||
if (typeof document !== 'undefined') {
|
||||
try {
|
||||
this._dispatchAppEvent?.(new CustomEvent('securebit-call-state', {
|
||||
@@ -15238,7 +15343,9 @@ checkFileTransferReadiness() {
|
||||
// Fresh capture each call (the mic/camera is fully released on hang-up, so
|
||||
// the OS indicator goes off). Senders are reused across calls via
|
||||
// replaceTrack; addTrack only on first use.
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
// A group call captures once and shares the result across every leg, so
|
||||
// there is nothing to capture here — just tracks to attach.
|
||||
const stream = this._externalMediaStream || await navigator.mediaDevices.getUserMedia({
|
||||
audio: this._audioConstraints(),
|
||||
video: withVideo ? this._videoConstraints() : false
|
||||
});
|
||||
@@ -15261,7 +15368,7 @@ checkFileTransferReadiness() {
|
||||
// Fully release the mic/camera — only on session disconnect, not between calls.
|
||||
_stopLocalMediaPermanently() {
|
||||
try {
|
||||
if (this.localMediaStream) {
|
||||
if (this.localMediaStream && !this._usingExternalMedia()) {
|
||||
for (const t of this.localMediaStream.getTracks()) { try { t.stop(); } catch (_) {} }
|
||||
}
|
||||
} catch (_) {}
|
||||
@@ -15415,6 +15522,12 @@ checkFileTransferReadiness() {
|
||||
active: true, phase: 'incoming', withVideo: !!data.withVideo,
|
||||
callId: data.callId, remoteHasVideo: !!data.withVideo, error: null
|
||||
});
|
||||
// One leg of a group call the user has already joined: answer it rather
|
||||
// than ring. The consent was given once, for the whole call — see
|
||||
// setCallGroupContext for why nothing on the wire can reach this branch.
|
||||
if (this._callGroupContext) {
|
||||
await this.acceptCall();
|
||||
}
|
||||
}
|
||||
|
||||
async _answerCallOffer(data, renegotiation = false) {
|
||||
@@ -15483,8 +15596,10 @@ checkFileTransferReadiness() {
|
||||
const pc = this.peerConnection;
|
||||
try {
|
||||
// 1. STOP our capture tracks → releases the mic/camera so the OS
|
||||
// indicator goes off after the call.
|
||||
if (this.localMediaStream) {
|
||||
// indicator goes off after the call. A capture we borrowed from a
|
||||
// group call is left alone: the other legs are still sending it,
|
||||
// and stopping it here would mute this device for all of them.
|
||||
if (this.localMediaStream && !this._usingExternalMedia()) {
|
||||
for (const track of this.localMediaStream.getTracks()) { try { track.stop(); } catch (_) {} }
|
||||
}
|
||||
// 2. Do NOT stop the REMOTE receiver tracks: the transceivers are reused
|
||||
@@ -15559,18 +15674,47 @@ checkFileTransferReadiness() {
|
||||
const camStream = await navigator.mediaDevices.getUserMedia({ video: this._videoConstraints() });
|
||||
const videoTrack = camStream.getVideoTracks()[0];
|
||||
if (!videoTrack) return;
|
||||
this.localMediaStream.addTrack(videoTrack);
|
||||
if (this._callVideoSender) await this._callVideoSender.replaceTrack(videoTrack);
|
||||
else this._callVideoSender = this.peerConnection.addTrack(videoTrack, this.localMediaStream);
|
||||
this._applyCallCodecPrefs();
|
||||
this._updateCallState({ cameraEnabled: true, withVideo: true });
|
||||
await this._renegotiateCall();
|
||||
await this.addVideoTrack(videoTrack);
|
||||
} catch (error) {
|
||||
this._secureLog('error', '❌ upgradeToVideo failed', { errorType: error?.constructor?.name });
|
||||
this._updateCallState({ cameraEnabled: false, error: 'camera_failed' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Put an already-captured camera track on this call and renegotiate.
|
||||
*
|
||||
* Split out of upgradeToVideo so a group call can capture its camera once
|
||||
* and add the SAME track to every leg — N captures of one camera is both
|
||||
* wasteful and, on several devices, simply not permitted.
|
||||
*
|
||||
* @param {MediaStreamTrack} videoTrack
|
||||
*/
|
||||
async addVideoTrack(videoTrack) {
|
||||
if (!videoTrack || !this.localMediaStream || !this.peerConnection) return;
|
||||
if (!this.localMediaStream.getVideoTracks().includes(videoTrack)) {
|
||||
this.localMediaStream.addTrack(videoTrack);
|
||||
}
|
||||
if (this._callVideoSender) await this._callVideoSender.replaceTrack(videoTrack);
|
||||
else this._callVideoSender = this.peerConnection.addTrack(videoTrack, this.localMediaStream);
|
||||
this._applyCallCodecPrefs();
|
||||
this._updateCallState({ cameraEnabled: true, withVideo: true });
|
||||
await this._renegotiateCall();
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap the outgoing camera track without renegotiating.
|
||||
*
|
||||
* The group's camera flip captures one new track and hands it to every leg;
|
||||
* replaceTrack keeps the transceiver, so no leg has to renegotiate for it.
|
||||
*
|
||||
* @param {MediaStreamTrack} videoTrack
|
||||
*/
|
||||
async replaceVideoTrack(videoTrack) {
|
||||
if (!videoTrack || !this._callVideoSender) return;
|
||||
await this._callVideoSender.replaceTrack(videoTrack);
|
||||
}
|
||||
|
||||
// Flip between front/back cameras without renegotiation (replaceTrack).
|
||||
async switchCamera() {
|
||||
if (!this._callVideoSender || !this.localMediaStream) return;
|
||||
|
||||
+126
-107
@@ -1,4 +1,5 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
import { prefersReducedMotion } from '../ui/motion.js';
|
||||
|
||||
class PWAInstallPrompt {
|
||||
constructor() {
|
||||
@@ -202,38 +203,45 @@ class PWAInstallPrompt {
|
||||
return;
|
||||
}
|
||||
|
||||
// Android gets a bottom sheet rather than the desktop pill: it sits where the
|
||||
// thumb already is, and a phone has no room for a floating pill that does not
|
||||
// land on the composer. It was the one surface in this file still built from
|
||||
// the old Tailwind palette — a grey card with blue accents, wrapped by an
|
||||
// orange gradient bar from pwa.css — which is why it read as another app's.
|
||||
// Styling is inline in the same idiom as the install pill and the install
|
||||
// guide, so all three track one design.
|
||||
this.installBanner = document.createElement('div');
|
||||
this.installBanner.id = 'pwa-install-banner';
|
||||
this.installBanner.className = 'pwa-install-banner fixed bottom-0 left-0 right-0 transform translate-y-full transition-transform duration-300 z-40';
|
||||
this.installBanner.style.cssText =
|
||||
"position:fixed; inset-inline:12px; bottom:calc(12px + env(safe-area-inset-bottom, 0px)); z-index:1000; " +
|
||||
"font-family:'Manrope',system-ui,-apple-system,sans-serif; " +
|
||||
"transform:translateY(calc(100% + 28px)); opacity:0; " +
|
||||
(prefersReducedMotion()
|
||||
? "transition:opacity .2s linear;"
|
||||
: "transition:transform .44s cubic-bezier(.2,.7,.3,1), opacity .3s ease;");
|
||||
|
||||
this.installBanner.innerHTML = `
|
||||
<div class="bg-gray-800/95 backdrop-blur-sm border-t border-gray-600/30 p-4">
|
||||
<div class="max-w-4xl mx-auto flex items-center justify-between">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="w-12 h-12 bg-orange-500/10 border border-orange-500/20 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-shield-halved text-orange-400 text-xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium text-white">${t('pwa.bannerTitle')}</div>
|
||||
<div class="text-sm text-gray-300">${t('pwa.bannerDesc')}</div>
|
||||
</div>
|
||||
<div style="max-width:520px; margin-inline:auto; border-radius:20px; background:#121214; border:1px solid rgba(255,255,255,0.08); padding:18px; box-shadow:0 20px 50px rgba(0,0,0,0.55);">
|
||||
<div style="display:flex; align-items:center; gap:14px; margin-bottom:16px;">
|
||||
<div style="flex:none; width:44px; height:44px; border-radius:12px; display:grid; place-items:center; background:rgba(240,137,42,0.12); border:1px solid rgba(240,137,42,0.28);">
|
||||
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="#f0892a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v11"/><path d="M7.5 10.5L12 15l4.5-4.5"/><path d="M5 20h14"/></svg>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<button class="install-btn bg-orange-500 hover:bg-orange-600 text-white px-4 py-2 rounded-lg font-medium transition-colors" data-action="install">
|
||||
<i class="fas fa-download me-2"></i>
|
||||
${t('pwa.install')}
|
||||
</button>
|
||||
<button class="close-btn text-gray-400 hover:text-white px-3 py-2 rounded-lg transition-colors" data-action="close">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
<div style="flex:1; min-width:0;">
|
||||
<div style="font-size:15.5px; font-weight:800; letter-spacing:-0.3px; color:#f4f4f6; margin-bottom:3px;">${t('pwa.bannerTitle')}</div>
|
||||
<div style="font-size:13px; line-height:1.45; color:#8a8a92;">${t('pwa.bannerDesc')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex; gap:10px;">
|
||||
<button class="install-btn" type="button" data-action="install" style="flex:1; padding:13px 20px; border-radius:13px; border:none; background:#f0892a; color:#1a0f04; font-family:inherit; font-size:15px; font-weight:700; letter-spacing:-0.2px; cursor:pointer; transition:background .2s cubic-bezier(.2,.7,.3,1);">${t('pwa.install')}</button>
|
||||
<button class="close-btn" type="button" data-action="close" style="flex:none; padding:13px 18px; border-radius:13px; border:1px solid rgba(255,255,255,0.1); background:rgba(255,255,255,0.03); color:#9a9aa2; font-family:inherit; font-size:15px; font-weight:700; cursor:pointer; transition:all .2s cubic-bezier(.2,.7,.3,1);">${t('pwa.dismiss')}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Handle banner actions
|
||||
this.installBanner.addEventListener('click', (event) => {
|
||||
const action = event.target.closest('[data-action]')?.dataset.action;
|
||||
|
||||
|
||||
if (action === 'install') {
|
||||
this.handleInstallClick();
|
||||
} else if (action === 'close') {
|
||||
@@ -284,8 +292,8 @@ class PWAInstallPrompt {
|
||||
|
||||
if (this.installBanner && !this.isInstalled) {
|
||||
setTimeout(() => {
|
||||
this.installBanner.classList.add('show');
|
||||
this.installBanner.style.transform = 'translateY(0)';
|
||||
this.installBanner.style.opacity = '1';
|
||||
}, 1000);
|
||||
|
||||
} else {
|
||||
@@ -304,8 +312,10 @@ class PWAInstallPrompt {
|
||||
}
|
||||
|
||||
if (this.installBanner) {
|
||||
this.installBanner.classList.remove('show');
|
||||
this.installBanner.style.transform = 'translateY(100%)';
|
||||
// Clear of the bottom inset as well as the sheet's own height, or the
|
||||
// card's top edge stays parked on the screen.
|
||||
this.installBanner.style.transform = 'translateY(calc(100% + 28px))';
|
||||
this.installBanner.style.opacity = '0';
|
||||
if (this.isInstalled) {
|
||||
setTimeout(() => {
|
||||
if (this.installBanner) {
|
||||
@@ -351,80 +361,81 @@ class PWAInstallPrompt {
|
||||
}
|
||||
|
||||
showIOSInstallInstructions() {
|
||||
// Same surface language as the install guide below: dark card, orange accent,
|
||||
// inline styles. The previous version was Tailwind blue-on-grey and had three
|
||||
// strings baked in English, which a thirteen-locale site cannot ship.
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 backdrop-blur-sm';
|
||||
modal.id = 'pwa-ios-install';
|
||||
modal.style.cssText = "position:fixed; inset:0; z-index:9999; display:flex; align-items:center; justify-content:center; padding:24px; background:rgba(8,8,10,0.55); backdrop-filter:blur(3px); -webkit-backdrop-filter:blur(3px); animation:igFade .3s ease; font-family:'Manrope',system-ui,-apple-system,sans-serif;";
|
||||
|
||||
const step = (n, title, hint, delay) => `
|
||||
<div style="display:flex; align-items:flex-start; gap:14px; padding:14px 16px; border-radius:13px; background:#161618; border:1px solid rgba(255,255,255,0.06); animation:igRow ${delay} cubic-bezier(.2,.7,.3,1);">
|
||||
<div style="flex:none; width:26px; height:26px; border-radius:9px; display:grid; place-items:center; background:rgba(240,137,42,0.12); border:1px solid rgba(240,137,42,0.28); font-size:13px; font-weight:800; color:#f0892a;">${n}</div>
|
||||
<div style="flex:1; min-width:0;">
|
||||
<div style="font-size:14.5px; font-weight:700; color:#f4f4f6; margin-bottom:2px;">${title}</div>
|
||||
<div style="font-size:13px; line-height:1.45; color:#8a8a92;">${hint}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="bg-gray-800 rounded-xl p-6 max-w-sm w-full text-center">
|
||||
<div class="w-16 h-16 bg-blue-500/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<i class="fab fa-apple text-blue-400 text-2xl"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-white mb-4">${t('pwa.iosTitle')}</h3>
|
||||
|
||||
<div class="space-y-4 text-start text-sm text-gray-300 mb-6">
|
||||
<div class="flex items-start space-x-3">
|
||||
<div class="w-8 h-8 bg-blue-500 rounded-full text-white flex items-center justify-center text-sm font-bold flex-shrink-0 mt-0.5">1</div>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-white mb-1">${t('pwa.iosStep1')}</div>
|
||||
<div class="flex items-center text-blue-400">
|
||||
<i class="fas fa-share me-2"></i>
|
||||
<span>${t('pwa.iosStep1Hint')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start space-x-3">
|
||||
<div class="w-8 h-8 bg-blue-500 rounded-full text-white flex items-center justify-center text-sm font-bold flex-shrink-0 mt-0.5">2</div>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-white mb-1">${t('pwa.iosStep2')}</div>
|
||||
<div class="text-gray-400">${t('pwa.iosStep2Hint')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start space-x-3">
|
||||
<div class="w-8 h-8 bg-blue-500 rounded-full text-white flex items-center justify-center text-sm font-bold flex-shrink-0 mt-0.5">3</div>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-white mb-1">${t('pwa.iosStep3')}</div>
|
||||
<div class="text-gray-400">${t('pwa.iosStep3Hint')}</div>
|
||||
</div>
|
||||
<div style="position:relative; z-index:2; width:440px; max-width:calc(100vw - 48px); border-radius:22px; background:#121214; border:1px solid rgba(255,255,255,0.08); padding:34px 30px 26px; box-shadow:0 30px 70px rgba(0,0,0,0.6); animation:igPop .32s cubic-bezier(.2,.7,.3,1);">
|
||||
<button class="close-x" type="button" title="${t('pwa.close')}" aria-label="${t('pwa.close')}" style="position:absolute; top:18px; inset-inline-end:18px; width:30px; height:30px; padding:0; border-radius:9px; display:grid; place-items:center; border:1px solid rgba(255,255,255,0.08); background:rgba(255,255,255,0.02); color:#8a8a92; cursor:pointer; transition:all .18s cubic-bezier(.2,.7,.3,1);">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" style="pointer-events:none;"><path d="M6 6l12 12M18 6L6 18"/></svg>
|
||||
</button>
|
||||
|
||||
<div style="text-align:center; margin-bottom:22px;">
|
||||
<div style="display:inline-flex; width:60px; height:60px; border-radius:16px; align-items:center; justify-content:center; background:rgba(240,137,42,0.12); border:1px solid rgba(240,137,42,0.3); margin-bottom:18px;">
|
||||
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#f0892a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V4M8.5 7.5L12 4l3.5 3.5"/><path d="M6 11H5a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1h-1"/></svg>
|
||||
</div>
|
||||
<h3 style="margin:0; font-size:23px; font-weight:800; letter-spacing:-0.6px; color:#f4f4f6;">${t('pwa.iosTitle')}</h3>
|
||||
</div>
|
||||
|
||||
<div class="bg-orange-500/10 border border-orange-500/20 rounded-lg p-3 mb-4">
|
||||
<p class="text-orange-300 text-xs">
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
After installation, open SecureBit from your home screen for the best experience.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-3">
|
||||
<button class="got-it-btn flex-1 bg-blue-500 hover:bg-blue-600 text-white py-3 px-4 rounded-lg font-medium transition-colors">
|
||||
Got it
|
||||
</button>
|
||||
<button class="close-btn flex-1 bg-gray-600 hover:bg-gray-500 text-white py-3 px-4 rounded-lg font-medium transition-colors">
|
||||
Close
|
||||
</button>
|
||||
|
||||
<div style="display:flex; flex-direction:column; gap:10px; margin-bottom:22px;">
|
||||
${step(1, t('pwa.iosStep1'), t('pwa.iosStep1Hint'), '.34s')}
|
||||
${step(2, t('pwa.iosStep2'), t('pwa.iosStep2Hint'), '.42s')}
|
||||
${step(3, t('pwa.iosStep3'), t('pwa.iosStep3Hint'), '.5s')}
|
||||
</div>
|
||||
|
||||
<button class="got-it" type="button" style="width:100%; padding:14px 20px; border-radius:13px; border:none; background:#f0892a; color:#1a0f04; font-family:inherit; font-size:15px; font-weight:700; cursor:pointer; transition:background .2s cubic-bezier(.2,.7,.3,1);">${t('pwa.gotIt')}</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const gotItBtn = modal.querySelector('.got-it-btn');
|
||||
const closeBtn = modal.querySelector('.close-btn');
|
||||
|
||||
gotItBtn.addEventListener('click', () => {
|
||||
|
||||
const closeX = modal.querySelector('.close-x');
|
||||
closeX.addEventListener('mouseenter', () => { closeX.style.color = '#e5727a'; closeX.style.borderColor = 'rgba(229,114,122,0.4)'; });
|
||||
closeX.addEventListener('mouseleave', () => { closeX.style.color = '#8a8a92'; closeX.style.borderColor = 'rgba(255,255,255,0.08)'; });
|
||||
|
||||
const gotIt = modal.querySelector('.got-it');
|
||||
|
||||
// "Got it" means the steps landed, so the 24h quiet period starts. Dismissing
|
||||
// with the X is a rejection and still counts against maxDismissals — the two
|
||||
// are not the same signal, and were not before this redesign either.
|
||||
gotIt.addEventListener('click', () => {
|
||||
modal.remove();
|
||||
this.saveInstallPreference('ios_instructions_shown', Date.now());
|
||||
});
|
||||
|
||||
closeBtn.addEventListener('click', () => {
|
||||
|
||||
closeX.addEventListener('click', () => {
|
||||
modal.remove();
|
||||
this.dismissedCount++;
|
||||
this.saveInstallPreference('dismissed', this.dismissedCount);
|
||||
});
|
||||
|
||||
|
||||
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
|
||||
|
||||
this.ensureModalKeyframes();
|
||||
document.body.appendChild(modal);
|
||||
this.saveInstallPreference('ios_instructions_shown', Date.now());
|
||||
}
|
||||
|
||||
// Entrance keyframes shared by the iOS instructions and the install guide.
|
||||
ensureModalKeyframes() {
|
||||
if (document.getElementById('pwa-install-guide-kf')) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = 'pwa-install-guide-kf';
|
||||
style.textContent = '@keyframes igPop{from{opacity:0;transform:scale(.96) translateY(10px)}to{opacity:1;transform:scale(1) translateY(0)}}@keyframes igFade{from{opacity:0}to{opacity:1}}@keyframes igRow{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}';
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
showFallbackInstructions() {
|
||||
// Per-browser install guide — translated from the Claude Design component
|
||||
// (Install Guide.dc.html). Styling is inline so it tracks the design.
|
||||
@@ -486,46 +497,54 @@ class PWAInstallPrompt {
|
||||
gotIt.addEventListener('click', close);
|
||||
modal.addEventListener('click', (e) => { if (e.target === modal) close(); });
|
||||
|
||||
if (!document.getElementById('pwa-install-guide-kf')) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'pwa-install-guide-kf';
|
||||
style.textContent = '@keyframes igPop{from{opacity:0;transform:scale(.96) translateY(10px)}to{opacity:1;transform:scale(1) translateY(0)}}@keyframes igFade{from{opacity:0}to{opacity:1}}@keyframes igRow{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}';
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
this.ensureModalKeyframes();
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
showInstallSuccess() {
|
||||
|
||||
const notification = document.createElement('div');
|
||||
notification.className = 'fixed top-4 end-4 bg-green-500 text-white p-4 rounded-lg shadow-lg z-50 max-w-sm transform translate-x-full transition-transform duration-300';
|
||||
|
||||
const successText = this.isIOSSafari() ?
|
||||
t('pwa.installedIos') :
|
||||
|
||||
const successText = this.isIOSSafari() ?
|
||||
t('pwa.installedIos') :
|
||||
t('pwa.installedGeneric');
|
||||
|
||||
|
||||
// Confirmation lands on the same dark surface as everything else, and clears
|
||||
// the iOS status bar: installed, this toast would otherwise sit under the clock.
|
||||
const notification = document.createElement('div');
|
||||
notification.id = 'pwa-install-success';
|
||||
notification.style.cssText =
|
||||
"position:fixed; top:calc(16px + var(--sb-safe-top, 0px)); inset-inline-end:16px; inset-inline-start:auto; max-width:min(360px, calc(100vw - 32px)); z-index:9999; " +
|
||||
"font-family:'Manrope',system-ui,-apple-system,sans-serif; " +
|
||||
"border-radius:15px; background:#121214; border:1px solid rgba(62,207,142,0.28); padding:15px 17px; box-shadow:0 20px 50px rgba(0,0,0,0.55); " +
|
||||
"transform:translateX(calc(100% + 24px)); opacity:0; " +
|
||||
(prefersReducedMotion()
|
||||
? "transition:opacity .2s linear;"
|
||||
: "transition:transform .42s cubic-bezier(.2,.7,.3,1), opacity .3s ease;");
|
||||
|
||||
notification.innerHTML = `
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-8 h-8 bg-white/20 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-check text-lg"></i>
|
||||
<div style="display:flex; align-items:center; gap:13px;">
|
||||
<div style="flex:none; width:36px; height:36px; border-radius:11px; display:grid; place-items:center; background:rgba(62,207,142,0.12); border:1px solid rgba(62,207,142,0.28);">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#3ecf8e" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium">${t('pwa.installedTitle')}</div>
|
||||
<div class="text-sm opacity-90">${successText}</div>
|
||||
<div style="flex:1; min-width:0;">
|
||||
<div style="font-size:14.5px; font-weight:800; letter-spacing:-0.2px; color:#f4f4f6; margin-bottom:2px;">${t('pwa.installedTitle')}</div>
|
||||
<div style="font-size:12.5px; line-height:1.4; color:#8a8a92;">${successText}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
|
||||
// Two frames, not one: the element has to be laid out with the off-screen
|
||||
// transform before the transition has anything to animate from.
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
notification.style.transform = 'translateX(0)';
|
||||
notification.style.opacity = '1';
|
||||
}));
|
||||
|
||||
setTimeout(() => {
|
||||
notification.classList.remove('translate-x-full');
|
||||
}, 100);
|
||||
|
||||
setTimeout(() => {
|
||||
notification.classList.add('translate-x-full');
|
||||
setTimeout(() => notification.remove(), 300);
|
||||
notification.style.transform = 'translateX(calc(100% + 24px))';
|
||||
notification.style.opacity = '0';
|
||||
setTimeout(() => notification.remove(), 450);
|
||||
}, 5000);
|
||||
|
||||
this.hideInstallPrompts();
|
||||
|
||||
+43
-25
@@ -237,6 +237,14 @@ class PWAOfflineManager {
|
||||
updateConnectionStatus(isOnline) {
|
||||
if (!this.offlineIndicator) return;
|
||||
|
||||
// A pending auto-hide from a previous "back online" would otherwise fire on
|
||||
// top of whatever is showing now — flap once inside three seconds and the
|
||||
// timer hides the offline pill you have just been given.
|
||||
if (this.connectionPillTimer) {
|
||||
clearTimeout(this.connectionPillTimer);
|
||||
this.connectionPillTimer = null;
|
||||
}
|
||||
|
||||
// Clean pill matching the app's design language (no emoji, no FontAwesome,
|
||||
// proper SVG close wired via a real listener — the old inline onclick was
|
||||
// blocked by the CSP anyway).
|
||||
@@ -250,7 +258,8 @@ class PWAOfflineManager {
|
||||
</div>`;
|
||||
this.offlineIndicator.classList.remove('hidden');
|
||||
// Auto-hide after 3 seconds.
|
||||
setTimeout(() => {
|
||||
this.connectionPillTimer = setTimeout(() => {
|
||||
this.connectionPillTimer = null;
|
||||
if (this.offlineIndicator) this.offlineIndicator.classList.add('hidden');
|
||||
}, 3000);
|
||||
} else {
|
||||
@@ -274,23 +283,50 @@ class PWAOfflineManager {
|
||||
|
||||
async handleConnectionRestored() {
|
||||
console.log('🔄 Handling connection restoration...');
|
||||
|
||||
|
||||
// Both routes back online converge here — the `online` event, and the ten
|
||||
// second probe in checkOnlineStatus() for the reconnections the OS never
|
||||
// announces. Taking the offline UI down has to happen here for that reason:
|
||||
// it used to be wired to neither. The full-screen offline modal had no
|
||||
// dismissal at all except a click from the user, so it simply stayed up
|
||||
// after the network came back; and the probe route never touched the pill
|
||||
// either, so that outlived the outage too. Do this first and unconditionally
|
||||
// — before any await — so a failure further down cannot leave the screen
|
||||
// claiming to be offline while the app is demonstrably not.
|
||||
this.dismissOfflineUI();
|
||||
this.updateConnectionStatus(true);
|
||||
|
||||
// The retry loop notices on its next tick anyway; stopping it here means the
|
||||
// outage does not keep probing for up to ten more seconds after it is over.
|
||||
if (this.reconnectInterval) {
|
||||
clearInterval(this.reconnectInterval);
|
||||
this.reconnectInterval = null;
|
||||
}
|
||||
this.reconnectAttempts = 0;
|
||||
|
||||
try {
|
||||
// Process offline queue
|
||||
await this.processOfflineQueue();
|
||||
|
||||
|
||||
// Restore WebRTC connections if needed
|
||||
await this.attemptWebRTCReconnection();
|
||||
|
||||
// Show success notification
|
||||
this.showReconnectionSuccess();
|
||||
|
||||
|
||||
// No success toast here: updateConnectionStatus(true) above already shows
|
||||
// the "Back online" pill. The old one was a second, English-only banner
|
||||
// that landed on top of it.
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Connection restoration failed:', error);
|
||||
this.showReconnectionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Everything the offline state puts on screen, taken down in one place.
|
||||
dismissOfflineUI() {
|
||||
const modal = document.getElementById('pwa-offline-modal');
|
||||
if (modal) modal.remove();
|
||||
}
|
||||
|
||||
handleConnectionLost() {
|
||||
console.log('📴 Handling connection loss...');
|
||||
|
||||
@@ -794,24 +830,6 @@ class PWAOfflineManager {
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
showReconnectionSuccess() {
|
||||
const notification = document.createElement('div');
|
||||
notification.className = 'fixed top-4 end-4 bg-green-500 text-white p-4 rounded-lg shadow-lg z-50 max-w-sm';
|
||||
notification.innerHTML = `
|
||||
<div class="flex items-center space-x-3">
|
||||
<i class="fas fa-check-circle text-lg"></i>
|
||||
<div>
|
||||
<div class="font-medium">Reconnected!</div>
|
||||
<div class="text-sm opacity-90">All services restored</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
setTimeout(() => notification.remove(), 3000);
|
||||
}
|
||||
|
||||
showReconnectionError(error) {
|
||||
const notification = document.createElement('div');
|
||||
notification.className = 'fixed top-4 end-4 bg-yellow-500 text-black p-4 rounded-lg shadow-lg z-50 max-w-sm';
|
||||
|
||||
@@ -39,6 +39,11 @@
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.header-minimal {
|
||||
/* Browser only. Installed, this fixed height is what left the brand row
|
||||
hanging below the bar's own blurred background: box-sizing is border-box
|
||||
globally, so the status-bar inset added as padding is subtracted from
|
||||
this box rather than added to it. src/styles/pwa.css — the last
|
||||
stylesheet in the document — replaces this with height:auto there. */
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
@@ -806,7 +811,7 @@ body.sb-in-chat #pwa-install-button { display: none !important; }
|
||||
header there, and keep it clear of the notch. It stays where it is on the landing
|
||||
page, which has no header of its own to collide with. */
|
||||
body.sb-in-chat #pwa-connection-status {
|
||||
top: calc(64px + 12px + env(safe-area-inset-top, 0px)) !important;
|
||||
top: calc(var(--sb-bar-h, 64px) + 12px + var(--sb-safe-top, 0px) + var(--sb-bar-extra, 0px)) !important;
|
||||
}
|
||||
/* The new design spaces icons with flex gap, not icon margins — neutralise the
|
||||
global `button i { margin-inline-end: .5rem }` so icons stay centered in their tiles. */
|
||||
|
||||
+84
-124
@@ -28,31 +28,95 @@
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* PWA Specific Layout Adjustments */
|
||||
.pwa-installed {
|
||||
/* Adjustments for when app is installed as PWA */
|
||||
/* Status-bar inset for the installed app.
|
||||
*
|
||||
* Installed on iOS, the web view runs edge-to-edge: `viewport-fit=cover` plus
|
||||
* `apple-mobile-web-app-status-bar-style: black-translucent` put our own pixels
|
||||
* under the clock and the notch. Every bar pinned to the top of the screen has
|
||||
* to gutter itself out of that strip, or it reads as if it is sliding under the
|
||||
* status bar. In a browser tab the inset is 0 — the browser chrome already
|
||||
* reserves the space — so one variable covers both cases and the bars can use
|
||||
* it unconditionally.
|
||||
*
|
||||
* The previous version of this file padded `body` instead. That is the wrong
|
||||
* element twice over: the landing header is `position: fixed`, which ignores an
|
||||
* ancestor's padding entirely, and the chat shell is sized to the visual
|
||||
* viewport (`height: var(--sb-vh)`), so padding above it pushed the composer
|
||||
* off the bottom by exactly the inset. It also hung the rule on a `.header`
|
||||
* class that no component has ever carried.
|
||||
*/
|
||||
:root {
|
||||
--sb-safe-top: 0px;
|
||||
/* Extra painted height below the content row, installed only. The blurred
|
||||
surface has to reach past the wordmark, not stop level with it — a bar that
|
||||
ends on its own contents reads as a strip laid over the page rather than as
|
||||
the top of the app. It is deliberately NOT part of --sb-bar-h: the row stays
|
||||
the height it is, so this deepens the background without moving the logo. */
|
||||
--sb-bar-extra: 0px;
|
||||
/* Height of a top bar's content row, NOT counting the status-bar strip above
|
||||
it. Installed, the strip already separates the bar from the top of the
|
||||
screen, so the bar itself does not need its full browser-tab height — this
|
||||
is the same split iOS uses for a navigation bar (44pt of content sitting on
|
||||
a 59pt safe area) and it is what keeps the total from reading as a slab. */
|
||||
--sb-bar-h: 64px;
|
||||
}
|
||||
|
||||
.pwa-installed .header {
|
||||
/* Adjust header for PWA mode */
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
.pwa-browser {
|
||||
/* Adjustments for browser mode */
|
||||
}
|
||||
|
||||
/* iOS PWA Status Bar */
|
||||
@supports (-webkit-touch-callout: none) {
|
||||
.pwa-installed {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
.pwa-installed .header {
|
||||
padding-top: calc(env(safe-area-inset-top) + 1rem);
|
||||
/* The 3px optical trim.
|
||||
*
|
||||
* env(safe-area-inset-top) is a few points taller than the status bar it
|
||||
* describes: 59px of inset for roughly 54px of clock and island. Handing the bar
|
||||
* all of it leaves a sliver of blurred nothing above the wordmark, which is what
|
||||
* makes the installed header read as deeper than the browser one.
|
||||
*
|
||||
* The trim cannot be a plain subtraction, because the surplus is not universal.
|
||||
* On an iPhone SE the inset IS the status bar height (20px for 20px), and on
|
||||
* Android standalone it is 0 — subtracting 3px from either puts the wordmark
|
||||
* under the clock, or opens a gap where none belongs. So take the 3px only where
|
||||
* there are 3px to take: max() refuses to go below 20px, and min() refuses to
|
||||
* exceed the inset itself. 59 becomes 56, 47 becomes 44, 20 stays 20, 0 stays 0.
|
||||
*
|
||||
* It is spelled out twice rather than composed from a second variable: a custom
|
||||
* property is substituted where it is DECLARED, so a --sb-safe-top defined once
|
||||
* on :root would keep the root's inset even after body redefined the input.
|
||||
*/
|
||||
@media (display-mode: standalone) {
|
||||
:root {
|
||||
--sb-safe-top: min(env(safe-area-inset-top, 0px), max(20px, env(safe-area-inset-top, 0px) - 3px));
|
||||
--sb-bar-h: 56px;
|
||||
--sb-bar-extra: 7px;
|
||||
}
|
||||
|
||||
/* The bar must WRAP its contents, never be clipped by them.
|
||||
*
|
||||
* components.css pins .header-minimal to a fixed 56px on a phone. With the
|
||||
* global box-sizing:border-box, a status-bar inset added as padding does not
|
||||
* grow that box — it is subtracted from the inside, and the brand row is left
|
||||
* hanging below the painted, blurred surface with the bar's bottom edge drawn
|
||||
* above the logo. That is the same failure at three different padding values,
|
||||
* which is why raising the logo and deepening the background both looked like
|
||||
* they did nothing: the box could not grow to show either.
|
||||
*
|
||||
* An auto height cannot clip. The bar becomes inset + --sb-bar-h + --sb-bar-extra
|
||||
* by construction, whatever those resolve to on the device — so the background
|
||||
* always reaches past the wordmark and the numbers above are free to be tuned.
|
||||
*
|
||||
* pwa.css is the last stylesheet in the document, so this wins the tie with the
|
||||
* max-width rule on equal specificity without needing !important. The browser
|
||||
* keeps its fixed 56px bar untouched.
|
||||
*/
|
||||
.header-minimal { height: auto; }
|
||||
}
|
||||
|
||||
/* iOS before 16.4 does not match (display-mode: standalone); install-prompt.js
|
||||
sets .pwa-installed from navigator.standalone, which is the older signal. */
|
||||
body.pwa-installed {
|
||||
--sb-safe-top: min(env(safe-area-inset-top, 0px), max(20px, env(safe-area-inset-top, 0px) - 3px));
|
||||
--sb-bar-h: 56px;
|
||||
--sb-bar-extra: 7px;
|
||||
}
|
||||
|
||||
body.pwa-installed .header-minimal { height: auto; }
|
||||
|
||||
/* PWA Splash Screen Styles */
|
||||
.pwa-splash {
|
||||
position: fixed;
|
||||
@@ -197,110 +261,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* PWA Install Promotion Banner */
|
||||
.pwa-install-banner {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(135deg, #ff6b35, #f7941d);
|
||||
color: white;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.3s ease-out;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.pwa-install-banner.show {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.pwa-install-banner .content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pwa-install-banner .icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-inline-end: 16px;
|
||||
}
|
||||
|
||||
.pwa-install-banner .text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pwa-install-banner .title {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.pwa-install-banner .subtitle {
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.pwa-install-banner .actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pwa-install-banner button {
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pwa-install-banner .install-btn {
|
||||
background: white;
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.pwa-install-banner .install-btn:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.pwa-install-banner .dismiss-btn {
|
||||
background: transparent;
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.pwa-install-banner .dismiss-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* PWA Responsive Adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.pwa-install-banner {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.pwa-install-banner .actions {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pwa-install-banner button {
|
||||
font-size: 12px;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* PWA Dark Mode Support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.pwa-notification {
|
||||
|
||||
@@ -11,7 +11,7 @@ let DYNAMIC_CACHE = 'securebit-pwa-dynamic-v4.7.56';
|
||||
// Build stamp — rewritten by scripts/post-build.js on every release so this file's
|
||||
// bytes change each deploy. That is what makes the browser detect a new Service Worker,
|
||||
// reinstall it, drop stale caches and (via controllerchange) prompt the page to update.
|
||||
const SW_BUILD_VERSION = '1788040116230';
|
||||
const SW_BUILD_VERSION = '1788237895492';
|
||||
|
||||
// Locale subdirectories, rewritten by scripts/build-i18n.js. Each localized page is a
|
||||
// separate document at its own URL, so the shell has to be cached and served per
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
// the code reaching the store fails here rather than on a user's screen.
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const { GroupSession, GROUP_FRAMES, groupFrameType, decodeEnvelope } =
|
||||
await import('../src/group/GroupSession.js');
|
||||
@@ -199,4 +200,43 @@ async function formPair(groupName) {
|
||||
'a failed group must not be confirmable');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// a group must not be able to trap the user inside it
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// One conversation owns the column at a time, and a group owns it
|
||||
// unconditionally: the 1:1 side of the screen is rendered only when no group is
|
||||
// active. So every action that is supposed to bring a CHAT to the front has to
|
||||
// clear the active group first, or it silently does nothing.
|
||||
//
|
||||
// "New chat" did not, and the consequence was not cosmetic. A group is built out
|
||||
// of chats that have already been verified, so adding a member means first
|
||||
// opening a chat with them — and from inside a group, the button that opens one
|
||||
// created a session that was never rendered: no invitation to copy, nothing to
|
||||
// verify. The admin was told there was nobody left to add and had no way to
|
||||
// change that.
|
||||
//
|
||||
// app.jsx cannot be imported here (it is a .jsx module that expects a browser),
|
||||
// so this reads the wiring. Crude, but it is the invariant that broke.
|
||||
{
|
||||
const app = readFileSync(new URL('../src/app.jsx', import.meta.url), 'utf8');
|
||||
|
||||
const newChat = app.slice(app.indexOf('const handleNewChat'), app.indexOf('const handleRenameSession'));
|
||||
assert.match(newChat, /SET_ACTIVE_GROUP, id: null/,
|
||||
'starting a new chat must clear the active group, or the new chat is never shown');
|
||||
assert.ok(
|
||||
newChat.indexOf('SET_ACTIVE_GROUP') < newChat.indexOf('createSessionRef'),
|
||||
'the group must be cleared before the session is created, so the new chat lands in front',
|
||||
);
|
||||
|
||||
// The sibling path has always done it; keep them together.
|
||||
const sidebar = app.slice(app.indexOf('React.createElement(SessionsSidebar'), app.indexOf('onSelectGroup:'));
|
||||
assert.match(sidebar, /onSelect: \(id\) => \{ groupsDispatch\(\{ type: GA\.SET_ACTIVE_GROUP, id: null \}\)/,
|
||||
'selecting a chat must clear the active group too');
|
||||
|
||||
// And the group view really is exclusive, which is why the above matters.
|
||||
assert.match(app, /activeGroup && React\.createElement\('main', \{\s*key: 'group-main'/);
|
||||
assert.match(app, /!activeGroup && React\.createElement\('main', \{\s*key: 'main'/);
|
||||
}
|
||||
|
||||
console.log('group-app-integration.test.mjs: all assertions passed');
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// One leg of a group call must never sit there ringing.
|
||||
//
|
||||
// A group call is N-1 ordinary 1:1 calls, and each of them is answered without
|
||||
// prompting because the user already consented once, by joining. That answer is
|
||||
// decided when the offer LANDS — so an offer that arrives before this session
|
||||
// has been told it is a call leg falls through the check and rings instead.
|
||||
//
|
||||
// The window is real. A member who joins can place their offers immediately,
|
||||
// and those offers can overtake the group frame announcing that they joined. The
|
||||
// symptom was asymmetric and confusing: one member's tile read "connecting" for
|
||||
// the whole call while the other member's browser was quietly ringing for a call
|
||||
// they had already agreed to be in.
|
||||
//
|
||||
// The ordering that opens the window is fixed elsewhere (the app announces
|
||||
// before it connects). This covers the safety net underneath it: being told,
|
||||
// late, that a session is a call leg has to pick up whatever is already ringing.
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
globalThis.window = { EnhancedSecureCryptoUtils: { secureLog: { log() {} } } };
|
||||
|
||||
const { EnhancedSecureWebRTCManager } = await import('../src/network/EnhancedSecureWebRTCManager.js');
|
||||
|
||||
/** A manager with only the call-state machinery a leg touches. */
|
||||
function callLeg(phase, { pending = null } = {}) {
|
||||
const leg = Object.create(EnhancedSecureWebRTCManager.prototype);
|
||||
leg.callState = {
|
||||
active: phase !== 'idle', phase, withVideo: false, micEnabled: true,
|
||||
cameraEnabled: false, remoteHasVideo: false, callId: 'c1', quality: null,
|
||||
groupCallId: null, error: null,
|
||||
};
|
||||
leg._callGroupContext = null;
|
||||
leg._callStateListeners = new Set();
|
||||
leg._pendingCallOffer = pending;
|
||||
leg.onCallStateChanged = null;
|
||||
leg.accepted = 0;
|
||||
leg.acceptCall = async function () { this.accepted += 1; };
|
||||
leg._startAdaptation = () => {};
|
||||
leg._stopAdaptation = () => {};
|
||||
leg._secureLog = () => {};
|
||||
return leg;
|
||||
}
|
||||
|
||||
const offer = { sdp: 'v=0', callId: 'c1', withVideo: false };
|
||||
|
||||
// ── an offer that was already ringing is answered when the leg is claimed ────
|
||||
{
|
||||
const leg = callLeg('incoming', { pending: offer });
|
||||
leg.setCallGroupContext('group-call-1');
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
assert.equal(leg.accepted, 1, 'a ringing group leg must be answered as soon as it is claimed');
|
||||
assert.equal(leg.getCallState().groupCallId, 'group-call-1',
|
||||
'and the state must say which group call it belongs to, so the 1:1 UI stays out of the way');
|
||||
}
|
||||
|
||||
// ── an idle session is left alone: there is nothing to answer ────────────────
|
||||
{
|
||||
const leg = callLeg('idle');
|
||||
leg.setCallGroupContext('group-call-1');
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
assert.equal(leg.accepted, 0, 'claiming an idle session must not invent a call');
|
||||
}
|
||||
|
||||
// ── a call already up is not answered a second time ─────────────────────────
|
||||
{
|
||||
const leg = callLeg('active', { pending: offer });
|
||||
leg.setCallGroupContext('group-call-1');
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
assert.equal(leg.accepted, 0, 'a live call must not be re-answered');
|
||||
}
|
||||
|
||||
// ── THE guard: releasing a leg must never auto-answer anything ───────────────
|
||||
//
|
||||
// This is the part that would be a security bug rather than a display one. The
|
||||
// flag is what allows a microphone to open without asking, so clearing it — which
|
||||
// is what leaving a call does — must not be a path to opening one.
|
||||
{
|
||||
const leg = callLeg('incoming', { pending: offer });
|
||||
leg._callGroupContext = 'group-call-1';
|
||||
leg.setCallGroupContext(null);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
assert.equal(leg.accepted, 0, 'clearing the group context must never answer a call');
|
||||
assert.equal(leg.getCallState().groupCallId, null);
|
||||
}
|
||||
|
||||
// ── and a session that is no longer a leg rings normally again ──────────────
|
||||
{
|
||||
const leg = callLeg('idle');
|
||||
leg.setCallGroupContext('group-call-1');
|
||||
leg.setCallGroupContext(null);
|
||||
assert.equal(leg._callGroupContext, null,
|
||||
'a cleared context must not linger — it is what lets a call open a microphone unasked');
|
||||
}
|
||||
|
||||
console.log('group-call-autoanswer: ok');
|
||||
@@ -0,0 +1,927 @@
|
||||
// Group calls: the control plane and the media plane, separately.
|
||||
//
|
||||
// A group call in this app is N-1 ordinary 1:1 calls over links that were
|
||||
// already SAS-verified, plus a signed roster saying who is in it. The two
|
||||
// halves fail in different ways and are tested apart:
|
||||
//
|
||||
// CONTROL (GroupSession + groupCrypto)
|
||||
// 1. a call opened by one member is seen by every member, including one
|
||||
// who is only reachable through a relay;
|
||||
// 2. a call frame from a non-member, or with a broken signature, is refused
|
||||
// — a relay carries these frames and must not be able to write one;
|
||||
// 3. a captured frame cannot be replayed to drag somebody back into a call
|
||||
// or to end one that is running;
|
||||
// 4. two calls opened at the same instant converge on one, so the group
|
||||
// does not split into two rooms;
|
||||
// 5. a member who leaves the group leaves the call.
|
||||
//
|
||||
// MEDIA (GroupCallMedia)
|
||||
// 6. exactly one side of each pair places the call, so there is no glare;
|
||||
// 7. one capture is shared across every leg, and no leg stops it;
|
||||
// 8. a member with no direct link is IN the call and shown as connecting,
|
||||
// rather than dropped;
|
||||
// 9. leaving releases the capture and every leg;
|
||||
// 10. a member's voice plays from an element the controller owns, not from a
|
||||
// tile that unmounts the moment the user opens another chat;
|
||||
// 11. the microphone is opened BEFORE the group is told a call exists, so a
|
||||
// device that cannot capture does not ring everybody else;
|
||||
// 12. the speaking indicator holds through the pauses inside speech and drops
|
||||
// when someone actually stops, and never marks a muted microphone;
|
||||
// 13. the gallery layout fills the room it is given rather than dividing the
|
||||
// width and hoping — which is what made tiles tiny on a desktop;
|
||||
// 14. the spotlight view splits the stage without ever claiming more of it
|
||||
// than there is;
|
||||
// 15. every hook in the call UI runs before its early returns — the one that
|
||||
// did not took the whole call down with React #310 the moment it started;
|
||||
// 16. the stage is measured through a callback ref, so the person who JOINS a
|
||||
// call measures it too — an object ref only ever measured the person who
|
||||
// STARTED one;
|
||||
// 17. a leg attached to a session that is already in a call is ACTIVE at once
|
||||
// rather than waiting at "connecting" for a state change that never comes;
|
||||
// 18. the answering side dials for itself if the offer never arrives, so a
|
||||
// member cannot be stranded at "connecting" for the whole call.
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const { GroupSession, GROUP_FRAMES, groupFrameType, decodeEnvelope, encodeEnvelope } =
|
||||
await import('../src/group/GroupSession.js');
|
||||
const { GROUP_PHASE, MEMBER_STATE } = await import('../src/state/groupsStore.js');
|
||||
const { toB64, signGroupCall, CALL_ACTIONS, newCallId } = await import('../src/group/groupCrypto.js');
|
||||
const { GroupCallMedia, LEG_STATE, SPEAKING, FALLBACK_DIAL_MS } =
|
||||
await import('../src/group/groupCallMedia.js');
|
||||
const { gridLayout, spotlightLayout, TILE_GAP, TILE_ASPECT, MIN_TILE_W } =
|
||||
await import('../src/components/ui/callLayout.js');
|
||||
const { readFileSync } = await import('node:fs');
|
||||
|
||||
const subtle = crypto.subtle;
|
||||
const tick = async (n = 8) => { for (let i = 0; i < n; i++) await new Promise((r) => setTimeout(r, 0)); };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// the same incomplete mesh the e2e suite uses: Alice knows both, Bob and Carol
|
||||
// know only each other's fingerprint. Every Bob↔Carol frame is relayed.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeMesh() {
|
||||
const links = new Map();
|
||||
const nodes = new Map();
|
||||
|
||||
function makeNode(name, { isAdmin, groupId, groupName } = {}) {
|
||||
const events = [];
|
||||
const node = { name, session: null, events };
|
||||
const send = async (sessionId, frame) => {
|
||||
const pair = links.get(sessionId);
|
||||
if (!pair) throw new Error('no such link');
|
||||
const other = pair[0] === name ? pair[1] : pair[0];
|
||||
const target = nodes.get(other);
|
||||
if (!target) return;
|
||||
const wire = JSON.parse(JSON.stringify(frame));
|
||||
if (groupFrameType(wire) === GROUP_FRAMES.INVITE && !target.session) {
|
||||
const invite = decodeEnvelope(wire);
|
||||
target.session = new GroupSession({
|
||||
groupId: invite.gid, name: invite.name, isAdmin: false,
|
||||
subtle, send: target.send, emit: target.emit,
|
||||
});
|
||||
await target.session.acceptInvite(sessionId, invite);
|
||||
return;
|
||||
}
|
||||
if (!target.session) return;
|
||||
await target.session.handleFrame(sessionId, wire);
|
||||
};
|
||||
node.send = send;
|
||||
node.emit = (event, payload) => { events.push({ event, payload }); };
|
||||
if (isAdmin) node.session = new GroupSession({ groupId, name: groupName, isAdmin: true, subtle, send, emit: node.emit });
|
||||
nodes.set(name, node);
|
||||
return node;
|
||||
}
|
||||
|
||||
return { connect: (a, b, sid) => links.set(sid, [a, b]), makeNode };
|
||||
}
|
||||
|
||||
const last = (node, event) => [...node.events].reverse().find((e) => e.event === event)?.payload;
|
||||
|
||||
async function readyGroup() {
|
||||
const mesh = makeMesh();
|
||||
const gid = GroupSession.newId();
|
||||
const alice = mesh.makeNode('alice', { isAdmin: true, groupId: gid, groupName: 'Field team' });
|
||||
const bob = mesh.makeNode('bob');
|
||||
const carol = mesh.makeNode('carol');
|
||||
mesh.connect('alice', 'bob', 'A-B');
|
||||
mesh.connect('alice', 'carol', 'A-C');
|
||||
await alice.session.init();
|
||||
await alice.session.invite([
|
||||
{ sessionId: 'A-B', name: 'Bob' },
|
||||
{ sessionId: 'A-C', name: 'Carol' },
|
||||
]);
|
||||
await tick(20);
|
||||
for (const n of [alice, bob, carol]) n.session.confirmSas();
|
||||
return { alice, bob, carol };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. a call reaches every member, relayed ones included
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const { alice, bob, carol } = await readyGroup();
|
||||
|
||||
const { callId } = await alice.session.startCall({ withVideo: false });
|
||||
await tick();
|
||||
|
||||
for (const node of [bob, carol]) {
|
||||
const call = node.session.getCallSnapshot();
|
||||
assert.ok(call, `${node.name} must see the call`);
|
||||
assert.equal(call.callId, callId);
|
||||
assert.equal(call.joined, false, 'nobody is put into a call without joining it');
|
||||
assert.equal(call.startedBy, alice.session.selfFp);
|
||||
assert.deepEqual(call.participants.map((p) => p.fp), [alice.session.selfFp]);
|
||||
assert.ok(last(node, 'call'), 'the app is told a call exists');
|
||||
}
|
||||
// Carol has no link to Alice's caller... she has one; Bob↔Carol is the relayed
|
||||
// pair, so check that Carol learns about Bob joining through Alice.
|
||||
await bob.session.joinCall();
|
||||
await tick();
|
||||
assert.deepEqual(
|
||||
carol.session.getCallSnapshot().participants.map((p) => p.fp).sort(),
|
||||
[alice.session.selfFp, bob.session.selfFp].sort(),
|
||||
'a join relayed through the admin still counts',
|
||||
);
|
||||
|
||||
// Leaving takes the member out everywhere, and the last one out ends it.
|
||||
await bob.session.leaveCall();
|
||||
await tick();
|
||||
assert.deepEqual(carol.session.getCallSnapshot().participants.map((p) => p.fp), [alice.session.selfFp]);
|
||||
await alice.session.leaveCall();
|
||||
await tick();
|
||||
assert.equal(carol.session.getCallSnapshot(), null, 'the call ends when the last member leaves');
|
||||
assert.equal(alice.session.call, null);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. a relay cannot write a call frame
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const { alice, bob, carol } = await readyGroup();
|
||||
await alice.session.startCall({ withVideo: false });
|
||||
await tick();
|
||||
const callId = alice.session.call.callId;
|
||||
|
||||
// Alice relays for Bob↔Carol. A forged "Carol joined", signed by Alice's own
|
||||
// key but claiming Carol's fingerprint, must not verify.
|
||||
const forged = {
|
||||
type: GROUP_FRAMES.CALL,
|
||||
gid: bob.session.groupId,
|
||||
epoch: bob.session.epoch,
|
||||
callId,
|
||||
action: CALL_ACTIONS.JOIN,
|
||||
fp: carol.session.selfFp,
|
||||
seq: 1,
|
||||
v: false,
|
||||
ts: Date.now(),
|
||||
sig: toB64(await signGroupCall(subtle, alice.session.identity.keyPair.privateKey, {
|
||||
groupId: bob.session.groupId, epoch: bob.session.epoch, callId,
|
||||
action: CALL_ACTIONS.JOIN, fp: carol.session.selfFp, seq: 1, withVideo: false,
|
||||
})),
|
||||
};
|
||||
await assert.rejects(
|
||||
() => bob.session.handleFrame('A-B', encodeEnvelope(forged)),
|
||||
/signature did not verify/,
|
||||
'a member cannot join somebody else to a call',
|
||||
);
|
||||
assert.deepEqual(bob.session.getCallSnapshot().participants.map((p) => p.fp), [alice.session.selfFp]);
|
||||
|
||||
// A frame from someone who is not in the group at all is refused before the
|
||||
// signature is even relevant.
|
||||
const stranger = new GroupSession({ groupId: bob.session.groupId, name: 'x', isAdmin: false, subtle, send: async () => {}, emit: () => {} });
|
||||
await stranger.init();
|
||||
const outside = {
|
||||
...forged,
|
||||
fp: stranger.selfFp,
|
||||
sig: toB64(await signGroupCall(subtle, stranger.identity.keyPair.privateKey, {
|
||||
groupId: bob.session.groupId, epoch: bob.session.epoch, callId,
|
||||
action: CALL_ACTIONS.JOIN, fp: stranger.selfFp, seq: 1, withVideo: false,
|
||||
})),
|
||||
};
|
||||
await assert.rejects(() => bob.session.handleFrame('A-B', encodeEnvelope(outside)), /non-member/);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. a captured frame cannot be replayed
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const { alice, bob, carol } = await readyGroup();
|
||||
await alice.session.startCall({ withVideo: false });
|
||||
await tick();
|
||||
await carol.session.joinCall();
|
||||
await tick();
|
||||
|
||||
const callId = alice.session.call.callId;
|
||||
// Capture Carol's join as Bob saw it, then replay it after she has left.
|
||||
const carolJoin = {
|
||||
type: GROUP_FRAMES.CALL,
|
||||
gid: bob.session.groupId, epoch: bob.session.epoch, callId,
|
||||
action: CALL_ACTIONS.JOIN, fp: carol.session.selfFp, seq: 1, v: false, ts: Date.now(),
|
||||
sig: toB64(await signGroupCall(subtle, carol.session.identity.keyPair.privateKey, {
|
||||
groupId: bob.session.groupId, epoch: bob.session.epoch, callId,
|
||||
action: CALL_ACTIONS.JOIN, fp: carol.session.selfFp, seq: 1, withVideo: false,
|
||||
})),
|
||||
};
|
||||
await carol.session.leaveCall();
|
||||
await tick();
|
||||
assert.ok(!bob.session.getCallSnapshot().participants.some((p) => p.fp === carol.session.selfFp));
|
||||
|
||||
await bob.session.handleFrame('A-B', encodeEnvelope(carolJoin));
|
||||
assert.ok(
|
||||
!bob.session.getCallSnapshot().participants.some((p) => p.fp === carol.session.selfFp),
|
||||
'a replayed join must not put a member back into a call they left',
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. two calls opened at once converge on one
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const { alice, bob, carol } = await readyGroup();
|
||||
|
||||
// Open both locally without letting either announcement land first, which is
|
||||
// exactly what "at the same instant" means on a network with no server.
|
||||
const opened = await Promise.all([
|
||||
alice.session.startCall({ withVideo: false }),
|
||||
bob.session.startCall({ withVideo: true }),
|
||||
]);
|
||||
await tick(20);
|
||||
|
||||
const ids = [alice, bob, carol].map((n) => n.session.getCallSnapshot()?.callId);
|
||||
assert.equal(new Set(ids).size, 1, `the group must converge on one call, got ${ids.join(', ')}`);
|
||||
// And on the SAME one everywhere, by a rule every member can evaluate alone:
|
||||
// the lower call id wins. Nothing is negotiated and nobody arbitrates.
|
||||
const expected = opened.map((r) => r.callId).sort()[0];
|
||||
assert.equal(ids[0], expected, 'the lower call id is the one that survives');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. a member who leaves the group leaves the call
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const { alice, bob, carol } = await readyGroup();
|
||||
await alice.session.startCall({ withVideo: false });
|
||||
await tick();
|
||||
await bob.session.joinCall();
|
||||
await tick();
|
||||
assert.equal(carol.session.getCallSnapshot().participants.length, 2);
|
||||
|
||||
await bob.session.leave();
|
||||
await tick();
|
||||
assert.ok(
|
||||
!carol.session.getCallSnapshot()?.participants.some((p) => p.fp === bob.session.selfFp),
|
||||
'leaving the group leaves the call',
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// a fake pairwise manager, with just the surface a call leg uses
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeManager(name) {
|
||||
return {
|
||||
name,
|
||||
groupContext: null,
|
||||
external: null,
|
||||
started: 0,
|
||||
startedWithVideo: null,
|
||||
ended: 0,
|
||||
addedVideo: [],
|
||||
replacedVideo: [],
|
||||
listeners: new Set(),
|
||||
callState: { phase: 'idle', quality: null, remoteHasVideo: false },
|
||||
setCallGroupContext(id) { this.groupContext = id; },
|
||||
setExternalMediaStream(stream) { this.external = stream; },
|
||||
addCallStateListener(fn) { this.listeners.add(fn); return () => this.listeners.delete(fn); },
|
||||
getCallState() { return { ...this.callState }; },
|
||||
remote: null,
|
||||
getRemoteMediaStream() { return this.remote || null; },
|
||||
async startCall(withVideo) { this.started += 1; this.startedWithVideo = withVideo; },
|
||||
endCall() { this.ended += 1; },
|
||||
setMicEnabled() {},
|
||||
setCameraEnabled() {},
|
||||
async addVideoTrack(track) { this.addedVideo.push(track); },
|
||||
async replaceVideoTrack(track) { this.replacedVideo.push(track); },
|
||||
emit(state) {
|
||||
this.callState = { ...this.callState, ...state };
|
||||
for (const fn of this.listeners) fn(this.getCallState());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeTrack(kind) {
|
||||
return { kind, enabled: true, stopped: false, stop() { this.stopped = true; } };
|
||||
}
|
||||
|
||||
function makeStream(kinds) {
|
||||
const tracks = kinds.map(makeTrack);
|
||||
return {
|
||||
tracks,
|
||||
getTracks() { return [...this.tracks]; },
|
||||
getAudioTracks() { return this.tracks.filter((t) => t.kind === 'audio'); },
|
||||
getVideoTracks() { return this.tracks.filter((t) => t.kind === 'video'); },
|
||||
addTrack(t) { this.tracks.push(t); },
|
||||
removeTrack(t) { this.tracks = this.tracks.filter((x) => x !== t); },
|
||||
};
|
||||
}
|
||||
|
||||
// Fingerprints only have to be comparable strings for the dialling rule.
|
||||
const FP = {
|
||||
low: '1'.repeat(64),
|
||||
mid: '5'.repeat(64),
|
||||
high: 'a'.repeat(64),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6/7/8. one capture, one dialler per pair, and relayed members still counted
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const managers = { 's-mid': makeManager('mid'), 's-high': makeManager('high') };
|
||||
const captured = [];
|
||||
const media = new GroupCallMedia({
|
||||
getManager: (sid) => managers[sid] || null,
|
||||
getUserMedia: async (constraints) => {
|
||||
captured.push(constraints);
|
||||
const kinds = [];
|
||||
if (constraints.audio) kinds.push('audio');
|
||||
if (constraints.video) kinds.push('video');
|
||||
return makeStream(kinds);
|
||||
},
|
||||
});
|
||||
|
||||
await media.join({
|
||||
callId: newCallId(),
|
||||
selfFp: FP.mid,
|
||||
withVideo: false,
|
||||
peers: [
|
||||
{ fp: FP.mid, name: 'You', sessionId: null },
|
||||
{ fp: FP.low, name: 'Ada', sessionId: 's-low' }, // no manager: relayed
|
||||
{ fp: FP.high, name: 'Zoe', sessionId: 's-high' },
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(captured.length, 1, 'one capture for the whole call, never one per leg');
|
||||
|
||||
// The dialling rule: we are FP.mid. Zoe (higher) we call; Ada (lower) would
|
||||
// call us — and in any case she has no direct link yet.
|
||||
assert.equal(managers['s-high'].started, 1, 'the lower fingerprint places the call');
|
||||
assert.equal(managers['s-high'].startedWithVideo, false);
|
||||
assert.equal(managers['s-mid'].started, 0, 'no leg is built to ourselves');
|
||||
|
||||
// Every leg borrows the SAME capture, and is told which group call it is on
|
||||
// (which is what lets it answer without ringing the user a second time).
|
||||
assert.equal(managers['s-high'].external, media.getLocalStream());
|
||||
assert.ok(managers['s-high'].groupContext, 'the leg carries the group call id');
|
||||
|
||||
// A member with no direct link is in the call, shown as unreachable, not dropped.
|
||||
const snap = media.snapshot();
|
||||
assert.deepEqual(snap.peers.map((p) => p.fp), [FP.low, FP.high]);
|
||||
assert.equal(snap.peers.find((p) => p.fp === FP.low).state, LEG_STATE.UNREACHABLE);
|
||||
assert.equal(snap.peers.find((p) => p.fp === FP.high).state, LEG_STATE.CONNECTING);
|
||||
|
||||
// The leg going active is what the tile reads, per member.
|
||||
managers['s-high'].emit({ phase: 'active', quality: 'good', remoteHasVideo: true });
|
||||
const active = media.snapshot();
|
||||
assert.equal(active.peers.find((p) => p.fp === FP.high).state, LEG_STATE.ACTIVE);
|
||||
assert.equal(active.peers.find((p) => p.fp === FP.high).quality, 'good');
|
||||
assert.equal(active.connected, 1);
|
||||
|
||||
// Ada's link comes up: a leg is built for her, and now WE do not dial (she is
|
||||
// the lower fingerprint) — so the pair still produces exactly one offer.
|
||||
managers['s-low'] = makeManager('low');
|
||||
media.setPeers([
|
||||
{ fp: FP.mid, name: 'You', sessionId: null },
|
||||
{ fp: FP.low, name: 'Ada', sessionId: 's-low' },
|
||||
{ fp: FP.high, name: 'Zoe', sessionId: 's-high' },
|
||||
]);
|
||||
await tick();
|
||||
assert.equal(managers['s-low'].started, 0, 'the higher fingerprint waits to be called');
|
||||
assert.ok(managers['s-low'].groupContext, 'but it must be ready to answer without ringing');
|
||||
assert.equal(managers['s-low'].external, media.getLocalStream());
|
||||
|
||||
// Mid-call camera: one capture, added to every leg.
|
||||
await media.setCamera(true);
|
||||
assert.equal(captured.length, 2, 'the camera is captured once, not once per leg');
|
||||
assert.equal(managers['s-low'].addedVideo.length, 1);
|
||||
assert.equal(managers['s-high'].addedVideo.length, 1);
|
||||
assert.equal(managers['s-low'].addedVideo[0], managers['s-high'].addedVideo[0], 'the same track on every leg');
|
||||
|
||||
// 9. Leaving releases the capture and every leg.
|
||||
const stream = media.getLocalStream();
|
||||
await media.leave();
|
||||
assert.ok(stream.getTracks().every((t) => t.stopped), 'the capture is released on leaving');
|
||||
for (const sid of ['s-low', 's-high']) {
|
||||
assert.equal(managers[sid].ended, 1, `${sid} was hung up`);
|
||||
assert.equal(managers[sid].groupContext, null, `${sid} is no longer a group leg`);
|
||||
assert.equal(managers[sid].external, null, `${sid} no longer holds the shared capture`);
|
||||
}
|
||||
assert.equal(media.active, false);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// a leg whose session moved is rebuilt on the new one, not left on the dead id
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const managers = { 'old': makeManager('old'), 'new': makeManager('new') };
|
||||
const media = new GroupCallMedia({
|
||||
getManager: (sid) => managers[sid] || null,
|
||||
getUserMedia: async () => makeStream(['audio']),
|
||||
});
|
||||
await media.join({
|
||||
callId: newCallId(), selfFp: FP.low, withVideo: false,
|
||||
peers: [{ fp: FP.high, name: 'Zoe', sessionId: 'old' }],
|
||||
});
|
||||
assert.equal(managers.old.started, 1);
|
||||
|
||||
media.setPeers([{ fp: FP.high, name: 'Zoe', sessionId: 'new' }]);
|
||||
await tick();
|
||||
assert.equal(managers.old.ended, 1, 'the leg on the dead session is torn down');
|
||||
assert.equal(managers.old.groupContext, null);
|
||||
assert.equal(managers.new.started, 1, 'and rebuilt on the link that replaced it');
|
||||
await media.leave();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// a leg that could not be placed is retried, not written off for the call
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const manager = makeManager('zoe');
|
||||
// The pairwise session is mid-repair: the manager refuses the call.
|
||||
let refuse = true;
|
||||
manager.startCall = async function () {
|
||||
if (refuse) throw new Error('Calls require a connected, SAS-verified session.');
|
||||
this.started += 1;
|
||||
};
|
||||
const media = new GroupCallMedia({
|
||||
getManager: () => manager,
|
||||
getUserMedia: async () => makeStream(['audio']),
|
||||
createAudioSink: () => null,
|
||||
});
|
||||
const peers = [{ fp: FP.high, name: 'Zoe', sessionId: 's' }];
|
||||
await media.join({ callId: newCallId(), selfFp: FP.low, withVideo: false, peers });
|
||||
await tick();
|
||||
assert.equal(media.snapshot().peers[0].state, LEG_STATE.FAILED, 'a refused dial is reported as failed');
|
||||
|
||||
// The link comes back. The next reconciliation — which happens on any group
|
||||
// event at all — has to try again rather than leave the member silent.
|
||||
refuse = false;
|
||||
media.setPeers(peers);
|
||||
await tick();
|
||||
assert.equal(manager.started, 1, 'the leg is retried once its session can carry a call');
|
||||
assert.equal(media.snapshot().peers[0].state, LEG_STATE.CONNECTING);
|
||||
await media.leave();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// a call cannot be opened before the humans confirmed the group code
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const mesh = makeMesh();
|
||||
const gid = GroupSession.newId();
|
||||
const alice = mesh.makeNode('alice', { isAdmin: true, groupId: gid, groupName: 'Unconfirmed' });
|
||||
mesh.makeNode('bob');
|
||||
mesh.connect('alice', 'bob', 'A-B');
|
||||
await alice.session.init();
|
||||
await alice.session.invite([{ sessionId: 'A-B', name: 'Bob' }]);
|
||||
await tick(20);
|
||||
assert.equal(alice.session.phase, GROUP_PHASE.AWAITING_SAS);
|
||||
await assert.rejects(() => alice.session.startCall({}), /group code has not been confirmed/);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 10. audio plays from the controller, not from the tiles
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const managers = { 's-high': makeManager('high') };
|
||||
const sinks = [];
|
||||
const media = new GroupCallMedia({
|
||||
getManager: (sid) => managers[sid] || null,
|
||||
getUserMedia: async () => makeStream(['audio']),
|
||||
createAudioSink: () => {
|
||||
const sink = { srcObject: null, played: 0, paused: 0, play() { this.played += 1; }, pause() { this.paused += 1; } };
|
||||
sinks.push(sink);
|
||||
return sink;
|
||||
},
|
||||
});
|
||||
|
||||
await media.join({
|
||||
callId: newCallId(), selfFp: FP.mid, withVideo: false,
|
||||
peers: [{ fp: FP.high, name: 'Zoe', sessionId: 's-high' }],
|
||||
});
|
||||
await tick();
|
||||
assert.equal(sinks.length, 1, 'one audio element per leg, owned by the controller');
|
||||
|
||||
// The manager only produces an inbound stream once the leg is up, and it
|
||||
// hands back a NEW MediaStream whenever its receivers change.
|
||||
const first = makeStream(['audio']);
|
||||
managers['s-high'].remote = first;
|
||||
managers['s-high'].emit({ phase: 'active' });
|
||||
assert.equal(sinks[0].srcObject, first, 'the leg is audible as soon as it is active');
|
||||
assert.ok(sinks[0].played > 0, 'and playback is actually started');
|
||||
|
||||
const rebuilt = makeStream(['audio', 'video']);
|
||||
managers['s-high'].remote = rebuilt;
|
||||
managers['s-high'].emit({ phase: 'active', remoteHasVideo: true });
|
||||
assert.equal(sinks[0].srcObject, rebuilt, 'a rebuilt inbound stream is picked up, not left behind');
|
||||
|
||||
await media.leave();
|
||||
assert.equal(sinks[0].srcObject, null, 'the element is released with the leg');
|
||||
assert.ok(sinks[0].paused > 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 11. the capture is opened before anybody is rung
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const { alice, bob, carol } = await readyGroup();
|
||||
|
||||
const order = [];
|
||||
const originalSend = alice.session._sendCallFrame.bind(alice.session);
|
||||
alice.session._sendCallFrame = async (...args) => { order.push('announced'); return originalSend(...args); };
|
||||
|
||||
await alice.session.startCall({
|
||||
withVideo: false,
|
||||
prepare: async () => { order.push('captured'); },
|
||||
});
|
||||
await tick();
|
||||
assert.deepEqual(order, ['captured', 'announced'], 'the microphone opens before the group is told');
|
||||
|
||||
// And a capture that fails takes the call with it — nobody else is left with
|
||||
// a ringing group for a call that never opened.
|
||||
await alice.session.leaveCall();
|
||||
await tick();
|
||||
await assert.rejects(() => alice.session.startCall({
|
||||
prepare: async () => { throw Object.assign(new Error('no mic'), { name: 'NotAllowedError' }); },
|
||||
}), /no mic/);
|
||||
assert.equal(alice.session.call, null, 'the caller is not left in a call it cannot speak into');
|
||||
for (const node of [bob, carol]) {
|
||||
assert.equal(node.session.getCallSnapshot(), null, `${node.name} was never rung`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 12. who is speaking: hysteresis, hold, and mute
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const manager = makeManager('zoe');
|
||||
const remote = makeStream(['audio']);
|
||||
manager.remote = remote;
|
||||
|
||||
// One controllable level per stream, so the rule can be driven directly.
|
||||
const levels = new Map();
|
||||
const media = new GroupCallMedia({
|
||||
getManager: () => manager,
|
||||
getUserMedia: async () => makeStream(['audio']),
|
||||
createAudioSink: () => ({ srcObject: null, play() {}, pause() {} }),
|
||||
createLevelMeter: (stream) => ({ read: () => levels.get(stream) || 0, close() {} }),
|
||||
});
|
||||
|
||||
await media.join({
|
||||
callId: newCallId(), selfFp: FP.low, withVideo: false,
|
||||
peers: [{ fp: FP.high, name: 'Zoe', sessionId: 's' }],
|
||||
});
|
||||
await tick();
|
||||
manager.emit({ phase: 'active' }); // the inbound stream (and its meter) appears
|
||||
|
||||
const local = media.getLocalStream();
|
||||
const peerState = () => media.snapshot().peers[0].speaking;
|
||||
|
||||
// Silence is silence.
|
||||
media._sampleLevels();
|
||||
assert.equal(peerState(), false);
|
||||
assert.equal(media.snapshot().selfSpeaking, false);
|
||||
|
||||
// Above ON marks them immediately — an indicator that lagged the voice would
|
||||
// point at the wrong person in a fast exchange.
|
||||
levels.set(remote, SPEAKING.ON + 0.01);
|
||||
media._sampleLevels();
|
||||
assert.equal(peerState(), true, 'crossing the upper threshold marks someone as speaking');
|
||||
|
||||
// A dip BETWEEN the thresholds is the gap between two words, not a stop.
|
||||
levels.set(remote, (SPEAKING.ON + SPEAKING.OFF) / 2);
|
||||
media._sampleLevels();
|
||||
assert.equal(peerState(), true, 'a syllable boundary must not clear the indicator');
|
||||
|
||||
// Below OFF starts the hold, and the mark survives it.
|
||||
levels.set(remote, 0);
|
||||
media._sampleLevels();
|
||||
assert.equal(peerState(), true, 'the mark holds through a pause');
|
||||
|
||||
// Only a pause longer than the hold clears it. Reach back in time rather
|
||||
// than waiting out the real duration.
|
||||
const leg = media._legs.get(FP.high);
|
||||
leg.quietSince = Date.now() - SPEAKING.HOLD_MS - 1;
|
||||
media._sampleLevels();
|
||||
assert.equal(peerState(), false, 'a real stop clears it');
|
||||
|
||||
// Our own microphone runs the same rule...
|
||||
levels.set(local, SPEAKING.ON + 0.01);
|
||||
media._sampleLevels();
|
||||
assert.equal(media.snapshot().selfSpeaking, true);
|
||||
|
||||
// ...except that a muted microphone is never speaking, whatever it hears.
|
||||
media.setMic(false);
|
||||
media._sampleLevels();
|
||||
assert.equal(media.snapshot().selfSpeaking, false, 'a muted mic must never look like it is talking');
|
||||
|
||||
await media.leave();
|
||||
assert.equal(media.selfSpeaking, false, 'leaving clears the indicator');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 13. the gallery fills the space it is given
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
// Every tile keeps its shape, and the grid never claims more room than it has.
|
||||
const fits = (n, w, h) => {
|
||||
const l = gridLayout(n, w, h);
|
||||
assert.ok(l.cols >= 1 && l.cols <= n, `columns out of range for ${n}`);
|
||||
assert.equal(l.rows, Math.ceil(n / l.cols));
|
||||
assert.ok(Math.abs(l.tileW / l.tileH - TILE_ASPECT) < 1e-6, 'tiles keep their aspect ratio');
|
||||
const usedW = l.cols * l.tileW + TILE_GAP * (l.cols - 1);
|
||||
const usedH = l.rows * l.tileH + TILE_GAP * (l.rows - 1);
|
||||
assert.ok(usedW <= w + 0.5, `grid is ${usedW} wide in ${w}`);
|
||||
assert.ok(usedH <= h + 0.5, `grid is ${usedH} tall in ${h}`);
|
||||
return l;
|
||||
};
|
||||
|
||||
// THE regression: three people on a desktop. Tiles must take up the room,
|
||||
// not sit tiny in the middle of it — this is what the old fixed cap broke.
|
||||
const desktop = fits(3, 1400, 760);
|
||||
assert.ok(desktop.tileW > 600, `three tiles on a desktop should be large, got ${Math.round(desktop.tileW)}px`);
|
||||
|
||||
// Two people on a wide screen go side by side and get most of the width each.
|
||||
const pair = fits(2, 1400, 760);
|
||||
assert.equal(pair.cols, 2);
|
||||
assert.ok(pair.tileW > 650);
|
||||
|
||||
// The same six people arrange differently on a wide screen and a tall one,
|
||||
// because the layout follows the room rather than the head count.
|
||||
assert.ok(fits(6, 1600, 700).cols > fits(6, 420, 780).cols,
|
||||
'a wide window takes more columns than a narrow one for the same call');
|
||||
|
||||
// Alone in a call, the single tile fills the stage.
|
||||
const alone = fits(1, 1200, 600);
|
||||
assert.equal(alone.cols, 1);
|
||||
assert.ok(alone.tileW > 1000);
|
||||
|
||||
// A phone: two people stack, and each still gets a real tile.
|
||||
const phone = fits(2, 390, 620);
|
||||
assert.equal(phone.cols, 1);
|
||||
assert.ok(phone.tileW > 300);
|
||||
|
||||
// Too many for the space: tiles stop shrinking and the stage scrolls rather
|
||||
// than rendering thumbnails nobody can read.
|
||||
const crowded = gridLayout(8, 300, 220);
|
||||
assert.equal(crowded.tileW, MIN_TILE_W, 'tiles stop shrinking at the floor');
|
||||
|
||||
// Before the container has been measured there is still a sane shape, and
|
||||
// never a zero-sized tile.
|
||||
const unmeasured = gridLayout(4, 0, 0);
|
||||
assert.ok(unmeasured.cols >= 1 && unmeasured.rows >= 1);
|
||||
assert.equal(unmeasured.tileW, 0, 'unmeasured falls back to CSS, not to a guessed pixel size');
|
||||
assert.deepEqual(gridLayout(0, 800, 600), { cols: 0, rows: 0, tileW: 0, tileH: 0 });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 14. the spotlight splits the stage honestly
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const fits = (others, w, h) => {
|
||||
const l = spotlightLayout(others, w, h);
|
||||
if (l.main.w === 0) return l; // correctly refused to split
|
||||
assert.ok(Math.abs(l.main.w / l.main.h - TILE_ASPECT) < 1e-6, 'the main tile keeps its shape');
|
||||
assert.ok(l.main.w <= w + 0.5, 'the main tile fits the width');
|
||||
assert.ok(l.main.h + (others > 0 ? l.stripH + TILE_GAP : 0) <= h + 0.5,
|
||||
'main tile plus strip fits the height');
|
||||
return l;
|
||||
};
|
||||
|
||||
// The point of the view: the pinned person is far larger than they would be
|
||||
// in a gallery of the same call.
|
||||
const others = 3;
|
||||
const spot = fits(others, 1440, 760);
|
||||
const gallery = gridLayout(others + 1, 1440, 760);
|
||||
assert.ok(spot.main.w > gallery.tileW * 1.5,
|
||||
`spotlighting should be a real difference, got ${Math.round(spot.main.w)} vs ${Math.round(gallery.tileW)}`);
|
||||
assert.ok(spot.thumbW > 0 && spot.stripH > 0, 'and the others stay on screen');
|
||||
|
||||
// Alone in a call there is nobody to strip, and the tile takes the stage.
|
||||
const solo = fits(0, 1200, 600);
|
||||
assert.equal(solo.stripH, 0);
|
||||
assert.ok(solo.main.w > 1000);
|
||||
|
||||
// Phones: the strip is capped rather than proportional, or the thumbnails
|
||||
// would be unrecognisable on a short screen and absurd on a tall one.
|
||||
const phone = fits(4, 390, 620);
|
||||
assert.ok(phone.thumbW >= 92 && phone.thumbW <= 168, `thumb out of range: ${phone.thumbW}`);
|
||||
|
||||
// A stage with no room to split says so instead of returning a negative tile.
|
||||
assert.equal(spotlightLayout(3, 300, 80).main.w, 0, 'no room to split is reported, not rendered');
|
||||
assert.deepEqual(spotlightLayout(2, 0, 0), { main: { w: 0, h: 0 }, stripH: 0, thumbW: 0 });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 15. every hook runs before every early return
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// React counts hooks per render and refuses a render that makes more calls than
|
||||
// the one before it (#310). GroupCallUI returns early three times — no call, a
|
||||
// call not joined, a call minimized — so a hook added below any of them is only
|
||||
// reached sometimes, and the first render that reaches it takes the call down at
|
||||
// the exact moment it starts. That is what happened, so it is worth a test that
|
||||
// does not need a browser: read the component and check the ordering directly.
|
||||
{
|
||||
const source = readFileSync(new URL('../src/components/ui/GroupCallUI.jsx', import.meta.url), 'utf8');
|
||||
const body = source.slice(source.indexOf('export function GroupCallUI('));
|
||||
|
||||
const hookAt = [...body.matchAll(/React\.(useState|useEffect|useRef|useMemo|useCallback)\s*\(/g)]
|
||||
.map((m) => m.index);
|
||||
const returnAt = [...body.matchAll(/\n {4}(?:if \([^)]*\) )?return /g)].map((m) => m.index);
|
||||
|
||||
assert.ok(hookAt.length >= 5, 'the scan found no hooks, so it is not scanning anything');
|
||||
assert.ok(returnAt.length >= 1, 'the scan found no early returns, so it proves nothing');
|
||||
assert.ok(
|
||||
Math.max(...hookAt) < Math.min(...returnAt),
|
||||
'a hook in GroupCallUI is placed after an early return — React will refuse the render (#310)',
|
||||
);
|
||||
|
||||
// And the check has to be able to fail, or it is decoration: the shape of the
|
||||
// regression is a hook call appearing below the first return.
|
||||
const broken = body.slice(0, Math.min(...returnAt) + 20) + '\n React.useRef(null);\n';
|
||||
const brokenHooks = [...broken.matchAll(/React\.(useState|useEffect|useRef|useMemo|useCallback)\s*\(/g)]
|
||||
.map((m) => m.index);
|
||||
assert.ok(Math.max(...brokenHooks) > Math.min(...returnAt),
|
||||
'the ordering check does not actually detect a hook below a return');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 16. the stage is measured whenever it appears, not only when it appears first
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// GroupCallUI renders the stage in one branch and a "somebody is calling you"
|
||||
// prompt in another. Whoever JOINS a call mounts into the prompt, so on the
|
||||
// render that first shows the stage the element is new — and an effect keyed on
|
||||
// a `useRef` object, which never changes, does not run again to see it. The
|
||||
// result was two people in one call looking at completely different layouts:
|
||||
// the caller measured fine, the joiner measured 0x0 forever and got tiles
|
||||
// collapsed to the width of their own labels.
|
||||
//
|
||||
// jsdom has no layout engine — clientWidth is always 0 there — so the honest
|
||||
// thing to check is the mechanism rather than the pixels: the observed node has
|
||||
// to be state, so that attaching it re-runs the effect.
|
||||
{
|
||||
const source = readFileSync(new URL('../src/components/ui/GroupCallUI.jsx', import.meta.url), 'utf8');
|
||||
const hook = source.slice(source.indexOf('function useMeasuredSize'), source.indexOf('One member\'s tile'));
|
||||
|
||||
assert.match(hook, /const \[node, setNode\] = React\.useState\(null\)/,
|
||||
'the measured node must be state, or the effect cannot re-run when it attaches');
|
||||
assert.match(hook, /\}, \[node\]\);/,
|
||||
'the measuring effect must depend on the node itself');
|
||||
assert.equal(/React\.useRef\(/.test(hook), false,
|
||||
'a useRef here is the regression: it never changes, so the effect runs once and misses the stage');
|
||||
|
||||
// And the setter must actually be what the stage is attached with.
|
||||
assert.match(source, /const \[stageRef, stage\] = useMeasuredSize\(\)/);
|
||||
assert.match(source, /ref: stageRef,/);
|
||||
|
||||
// Belt and braces: even unmeasured, a tile must not be able to shrink to its
|
||||
// own label. That collapse is what made the bug look like a broken call
|
||||
// rather than a slightly-off one.
|
||||
assert.match(source, /minWidth: tileW \? undefined : '\d+px'/,
|
||||
'the unmeasured tile needs a minimum width it cannot shrink through');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 17. a leg starts from what the session is really doing
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// The symptom this covers: a member sat at "connecting" forever. One of the ways
|
||||
// in is a leg attached to a manager that is ALREADY carrying the call — the peer
|
||||
// dialled before we had the leg. Nothing more will be emitted on that session, so
|
||||
// a leg that assumes "connecting" until told otherwise never learns.
|
||||
{
|
||||
const manager = makeManager('zoe');
|
||||
manager.callState = { phase: 'active', quality: 'good', remoteHasVideo: false };
|
||||
manager.remote = makeStream(['audio']);
|
||||
|
||||
const media = new GroupCallMedia({
|
||||
getManager: () => manager,
|
||||
getUserMedia: async () => makeStream(['audio']),
|
||||
createAudioSink: () => ({ srcObject: null, play() {}, pause() {} }),
|
||||
createLevelMeter: () => ({ read: () => 0, close() {} }),
|
||||
});
|
||||
await media.join({
|
||||
callId: newCallId(), selfFp: FP.high, withVideo: false, // higher fp: we answer
|
||||
peers: [{ fp: FP.low, name: 'Ada', sessionId: 's' }],
|
||||
});
|
||||
await tick();
|
||||
|
||||
assert.equal(media.snapshot().peers[0].state, LEG_STATE.ACTIVE,
|
||||
'a leg on a session already in the call must not report "connecting"');
|
||||
assert.equal(manager.started, 0, 'and it must not dial over a call that is already up');
|
||||
await media.leave();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 18. the answering side is not stranded if the offer never comes
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const manager = makeManager('ada');
|
||||
const media = new GroupCallMedia({
|
||||
getManager: () => manager,
|
||||
getUserMedia: async () => makeStream(['audio']),
|
||||
createAudioSink: () => ({ srcObject: null, play() {}, pause() {} }),
|
||||
createLevelMeter: () => ({ read: () => 0, close() {} }),
|
||||
});
|
||||
// We are the HIGHER fingerprint, so by the dialling rule we wait.
|
||||
await media.join({
|
||||
callId: newCallId(), selfFp: FP.high, withVideo: false,
|
||||
peers: [{ fp: FP.low, name: 'Ada', sessionId: 's' }],
|
||||
});
|
||||
await tick();
|
||||
assert.equal(manager.started, 0, 'the answering side does not dial first');
|
||||
|
||||
const leg = media._legs.get(FP.low);
|
||||
assert.ok(leg.fallback, 'but it does arm a fallback rather than waiting forever');
|
||||
|
||||
// Nothing ever arrives. Fire the fallback rather than waiting out the real delay.
|
||||
clearTimeout(leg.fallback);
|
||||
media._dialFallback(FP.low);
|
||||
await tick();
|
||||
assert.equal(manager.started, 1, 'the answering side eventually dials for itself');
|
||||
await media.leave();
|
||||
}
|
||||
|
||||
// ...and it stands down the moment anything reaches the session, so two offers
|
||||
// can never cross.
|
||||
{
|
||||
const manager = makeManager('ada');
|
||||
const media = new GroupCallMedia({
|
||||
getManager: () => manager,
|
||||
getUserMedia: async () => makeStream(['audio']),
|
||||
createAudioSink: () => ({ srcObject: null, play() {}, pause() {} }),
|
||||
createLevelMeter: () => ({ read: () => 0, close() {} }),
|
||||
});
|
||||
await media.join({
|
||||
callId: newCallId(), selfFp: FP.high, withVideo: false,
|
||||
peers: [{ fp: FP.low, name: 'Ada', sessionId: 's' }],
|
||||
});
|
||||
await tick();
|
||||
|
||||
// The peer's offer lands and is answered: the session is busy now.
|
||||
manager.emit({ phase: 'connecting' });
|
||||
assert.equal(media._legs.get(FP.low).fallback, null, 'the fallback is disarmed once the peer dials');
|
||||
|
||||
manager.emit({ phase: 'active' });
|
||||
media._dialFallback(FP.low); // even if it somehow fired, it must be a no-op
|
||||
await tick();
|
||||
assert.equal(manager.started, 0, 'no second offer is ever placed onto a live call');
|
||||
assert.equal(media.snapshot().peers[0].state, LEG_STATE.ACTIVE);
|
||||
await media.leave();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// the ordering that caused it: announce, THEN connect
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// join() with no peers must capture and connect nothing, so the caller can put
|
||||
// the group's "I joined" frame in between. Attaching a leg can place an offer
|
||||
// immediately, and an offer that overtakes that frame arrives at a member who
|
||||
// does not know the session is a call leg — they ring, and the dialler waits at
|
||||
// "connecting" for the rest of the call.
|
||||
{
|
||||
const manager = makeManager('zoe');
|
||||
const media = new GroupCallMedia({
|
||||
getManager: () => manager,
|
||||
getUserMedia: async () => makeStream(['audio']),
|
||||
createAudioSink: () => ({ srcObject: null, play() {}, pause() {} }),
|
||||
createLevelMeter: () => ({ read: () => 0, close() {} }),
|
||||
});
|
||||
await media.join({ callId: newCallId(), selfFp: FP.low, withVideo: false, peers: [] });
|
||||
await tick();
|
||||
assert.equal(manager.started, 0, 'capturing must not connect anything on its own');
|
||||
assert.ok(media.getLocalStream(), 'but the microphone is open, so a failure is caught before announcing');
|
||||
|
||||
media.setPeers([{ fp: FP.high, name: 'Zoe', sessionId: 's' }]);
|
||||
await tick();
|
||||
assert.equal(manager.started, 1, 'connecting happens when the caller says so, after announcing');
|
||||
await media.leave();
|
||||
}
|
||||
|
||||
// The app must actually use that order.
|
||||
{
|
||||
const app = readFileSync(new URL('../src/app.jsx', import.meta.url), 'utf8');
|
||||
const join = app.slice(app.indexOf('const handleJoinGroupCall'), app.indexOf('const handleDismissGroupCall'));
|
||||
const openAt = join.indexOf('openGroupCallMedia');
|
||||
const announceAt = join.indexOf('runtime.joinCall()');
|
||||
const connectAt = join.indexOf('setPeers');
|
||||
assert.ok(openAt >= 0 && announceAt > openAt && connectAt > announceAt,
|
||||
'joining must capture, then announce, then connect — in that order');
|
||||
assert.match(app, /peers: \[\],\s*\/\/ legs are connected after the announcement/,
|
||||
'opening the controller must not hand it peers to connect straight away');
|
||||
}
|
||||
|
||||
console.log('group-call: ok');
|
||||
@@ -0,0 +1,100 @@
|
||||
// The language bar is the safe half of "show the visitor their own language". The unsafe
|
||||
// half — redirecting on navigator.language — is invisible from a browser and fatal for
|
||||
// the twelve translated pages: a crawler asking for /de/ with an English Accept-Language
|
||||
// would be handed the English document, so no locale but one ever gets indexed, and the
|
||||
// hreflang cluster in every page would describe URLs that do not serve what they claim.
|
||||
//
|
||||
// These assertions exist because that redirect is a two-line change someone will
|
||||
// eventually be tempted to make.
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
const read = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8');
|
||||
const suggestion = read('src/components/ui/LanguageSuggestion.jsx');
|
||||
const app = read('src/app.jsx');
|
||||
const i18n = read('src/i18n/index.js');
|
||||
|
||||
// ── No automatic navigation, anywhere in the locale path ───────────────────────────────
|
||||
for (const [name, source] of [['the bar', suggestion], ['the i18n module', i18n]]) {
|
||||
for (const forbidden of [/location\s*\.\s*replace\s*\(/, /location\s*\.\s*assign\s*\(/, /location\s*\.\s*href\s*=/, /window\.location\s*=/]) {
|
||||
assert.equal(forbidden.test(source), false,
|
||||
`${name} must never navigate on its own — an automatic locale redirect leaves every other locale unindexed`);
|
||||
}
|
||||
}
|
||||
assert.equal(/preventDefault/.test(suggestion), false,
|
||||
'the offer must be an ordinary link the browser follows, not an intercepted click');
|
||||
assert.match(suggestion, /React\.createElement\('a',/, 'the offer must be an anchor');
|
||||
assert.match(suggestion, /href,/, 'the anchor needs a real href a crawler could follow');
|
||||
|
||||
// ── It is an offer, and only when there is something to offer ──────────────────────────
|
||||
assert.match(suggestion, /suggestedLocale\(/,
|
||||
'the bar must ask suggestedLocale, which returns null when the page already matches');
|
||||
assert.match(suggestion, /if \(!target \|\| gone\) return null;/,
|
||||
'nothing renders when the visitor is already on the right locale');
|
||||
assert.match(suggestion, /localeSuggestionDismissed\(\)/, 'a past dismissal must suppress the bar');
|
||||
assert.match(suggestion, /dismissLocaleSuggestion\(\)/, 'dismissing must be remembered');
|
||||
|
||||
// ── The copy is in the language being offered ──────────────────────────────────────────
|
||||
// Someone on the English page whose browser is Arabic cannot read an English offer.
|
||||
for (const key of ['language.suggest.text', 'language.suggest.cta', 'language.suggest.dismiss']) {
|
||||
assert.match(suggestion, new RegExp(`t\\('${key.replace(/\./g, '\\.')}', null, target\\)`),
|
||||
`${key} must be rendered in the offered locale, not the page's`);
|
||||
}
|
||||
assert.match(suggestion, /lang: meta\.htmlLang \|\| target/, 'the bar must declare its own language');
|
||||
assert.match(suggestion, /dir,/, 'an RTL offer on an LTR page needs its own direction');
|
||||
|
||||
// ── Landing only, like the switcher ────────────────────────────────────────────────────
|
||||
assert.match(app, /\(!isConnectedAndVerified && !showSidebar\) && React\.createElement\(LanguageSuggestion/,
|
||||
'following the link navigates, which would drop a live peer connection mid-session');
|
||||
assert.match(app, /import \{ LanguageSuggestion \} from '\.\/components\/ui\/LanguageSuggestion\.jsx';/);
|
||||
|
||||
// ── Every locale carries the three strings ─────────────────────────────────────────────
|
||||
{
|
||||
const site = JSON.parse(read('locales/site.json'));
|
||||
for (const code of site.locales) {
|
||||
const ui = JSON.parse(read(`locales/${code}.json`)).ui;
|
||||
for (const key of ['language.suggest.text', 'language.suggest.cta', 'language.suggest.dismiss']) {
|
||||
const value = ui[key];
|
||||
assert.ok(value && value.trim(), `locales/${code}.json is missing ${key}`);
|
||||
// A translation left in English would defeat the point: the offer is read by
|
||||
// someone who does not read the page it appears on.
|
||||
if (code !== 'en') {
|
||||
assert.notEqual(value, JSON.parse(read('locales/en.json')).ui[key],
|
||||
`locales/${code}.json: ${key} is still the English string`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── The dismissal survives, and a blocked storage does not throw ───────────────────────
|
||||
{
|
||||
const dom = new JSDOM('', { url: 'https://securebit.chat/' });
|
||||
global.window = dom.window;
|
||||
global.localStorage = dom.window.localStorage;
|
||||
const mod = await import('../src/i18n/index.js');
|
||||
|
||||
assert.equal(mod.localeSuggestionDismissed(), false, 'nothing dismissed on a first visit');
|
||||
mod.dismissLocaleSuggestion();
|
||||
assert.equal(mod.localeSuggestionDismissed(), true, 'a dismissal must be remembered');
|
||||
|
||||
// Private mode: reads and writes throw, and neither may take the caller with it.
|
||||
const throwing = { getItem() { throw new Error('blocked'); }, setItem() { throw new Error('blocked'); } };
|
||||
Object.defineProperty(dom.window, 'localStorage', { value: throwing, configurable: true });
|
||||
global.localStorage = throwing;
|
||||
assert.equal(mod.localeSuggestionDismissed(), false, 'blocked storage reads as "not dismissed"');
|
||||
mod.dismissLocaleSuggestion();
|
||||
}
|
||||
|
||||
// ── The offer is computed from the browser's languages, and only when it differs ───────
|
||||
{
|
||||
const mod = await import('../src/i18n/index.js');
|
||||
assert.equal(mod.suggestedLocale({ pathname: '/', languages: ['de-DE', 'en'] }), 'de');
|
||||
assert.equal(mod.suggestedLocale({ pathname: '/de/', languages: ['de-DE'] }), null);
|
||||
assert.equal(mod.suggestedLocale({ pathname: '/', languages: ['en-US'] }), null);
|
||||
// A crawler sends no languages at all: it must see no offer and, above all, no move.
|
||||
assert.equal(mod.suggestedLocale({ pathname: '/ru/', languages: [] }), null);
|
||||
}
|
||||
|
||||
console.log('language-suggestion.test.mjs: all assertions passed');
|
||||
@@ -155,12 +155,20 @@ assert.match(app, /webrtcManager: null/,
|
||||
// the connection toast must not sit on the header
|
||||
//
|
||||
// It is `fixed top-4 left-1/2` (src/pwa/pwa-manager.js), which inside the chat
|
||||
// lands on the 64px header and covers the peer name.
|
||||
// lands on the header and covers the peer name. The offset is expressed in the
|
||||
// same two variables the header's own height is (--sb-bar-h for the content row,
|
||||
// --sb-safe-top for the status-bar strip an installed app draws under), so the
|
||||
// toast follows the bar instead of restating a number that only holds in a
|
||||
// browser tab.
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const css = readFileSync(new URL('../src/styles/components.css', import.meta.url), 'utf8');
|
||||
assert.match(css, /body\.sb-in-chat #pwa-connection-status\s*\{[^}]*top:\s*calc\(64px/,
|
||||
'the online/offline toast must clear the chat header');
|
||||
const rule = css.match(/body\.sb-in-chat #pwa-connection-status\s*\{[^}]*\}/);
|
||||
assert.ok(rule, 'the online/offline toast must be offset inside the chat');
|
||||
assert.match(rule[0], /top:\s*calc\([^)]*var\(--sb-bar-h/,
|
||||
'the toast offset must track the header height, not a hard-coded 64px');
|
||||
assert.match(rule[0], /var\(--sb-safe-top/,
|
||||
'the toast must also clear the status-bar strip in an installed app');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+24
-24
@@ -30,7 +30,7 @@
|
||||
|
||||
<!-- PWA Manifest -->
|
||||
<link rel="manifest" href="/uk/manifest.json">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788040116230">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788237895492">
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
@@ -96,7 +96,7 @@
|
||||
<link rel="apple-touch-startup-image" media="screen and (device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/logo/splash/splash_screens/8.3__iPad_Mini_portrait.png">
|
||||
|
||||
<!-- Apple Touch Icons -->
|
||||
<link rel="apple-touch-icon" href="/logo/icon-180x180.png?v=1788040116230">
|
||||
<link rel="apple-touch-icon" href="/logo/icon-180x180.png?v=1788237895492">
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="/logo/icon-57x57.png">
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="/logo/icon-60x60.png">
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="/logo/icon-72x72.png">
|
||||
@@ -105,7 +105,7 @@
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="/logo/icon-120x120.png">
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="/logo/icon-144x144.png">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="/logo/icon-152x152.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/logo/icon-180x180.png?v=1788040116230">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/logo/icon-180x180.png?v=1788237895492">
|
||||
|
||||
<!-- Microsoft Tiles -->
|
||||
<meta name="msapplication-TileColor" content="#ff6b35">
|
||||
@@ -222,7 +222,7 @@
|
||||
<!-- Render-blocking JS is deferred: classic deferred scripts and module scripts
|
||||
both execute in document order after parsing, so React still runs before the
|
||||
app modules below, but the parser / first paint is no longer blocked. -->
|
||||
<script defer src="/config/ice-servers.js?v=1788040116230"></script>
|
||||
<script defer src="/config/ice-servers.js?v=1788237895492"></script>
|
||||
<script defer src="/libs/react/react.production.min.js"></script>
|
||||
<script defer src="/libs/react-dom/react-dom.production.min.js"></script>
|
||||
<!-- Prism syntax highlighting (vendored, offline). Tokenizes code as TEXT only —
|
||||
@@ -230,8 +230,8 @@
|
||||
Its CSS is loaded async via load-async-css.js (not paint-critical). -->
|
||||
<script defer src="/libs/prism/prism.js"></script>
|
||||
<!-- Critical, paint-defining CSS stays render-blocking (avoids FOUC / layout shift). -->
|
||||
<link rel="stylesheet" href="/assets/tailwind.css?v=1788040116230">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788040116230">
|
||||
<link rel="stylesheet" href="/assets/tailwind.css?v=1788237895492">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788237895492">
|
||||
<!-- Preload only the fonts needed for first paint. fa-solid covers the bulk of UI
|
||||
icons; fa-regular/fa-brands are loaded on demand by their CSS (rarely on the
|
||||
first screen). Inter latin 400/700 cover body text and headings/buttons. -->
|
||||
@@ -239,38 +239,38 @@
|
||||
<link rel="preload" href="/assets/fonts/inter/files/inter-latin-400.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="/assets/fonts/inter/files/inter-latin-700.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="stylesheet" href="/assets/fonts/inter/inter.css">
|
||||
<link rel="stylesheet" href="/src/styles/main.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/animations.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/components.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/main.css?v=1788237895492">
|
||||
<link rel="stylesheet" href="/src/styles/animations.css?v=1788237895492">
|
||||
<link rel="stylesheet" href="/src/styles/components.css?v=1788237895492">
|
||||
<!-- Loads after components.css on purpose: press feedback, material weight,
|
||||
size-specific tracking and the reduced-motion / -transparency / -contrast
|
||||
answers all need to settle arguments with the sheets above on source order. -->
|
||||
<link rel="stylesheet" href="/src/styles/apple-motion.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/apple-motion.css?v=1788237895492">
|
||||
<!-- Last of the hand-written sheets: the mirroring rules must win over anything
|
||||
above them, and they only ever apply under [dir="rtl"]. -->
|
||||
<link rel="stylesheet" href="/src/styles/rtl.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/rtl.css?v=1788237895492">
|
||||
<!-- Non-critical CSS (FontAwesome ~102KB, Prism) loaded async — no longer blocks paint. -->
|
||||
<script defer src="/src/scripts/load-async-css.js?v=1788040116230"></script>
|
||||
<script defer src="/src/scripts/load-async-css.js?v=1788237895492"></script>
|
||||
<noscript>
|
||||
<link rel="stylesheet" href="/assets/fontawesome/css/all.min.css">
|
||||
<link rel="stylesheet" href="/libs/prism/prism.css">
|
||||
</noscript>
|
||||
<script defer src="/src/scripts/fa-check.js?v=1788040116230"></script>
|
||||
<script defer src="/src/scripts/fa-check.js?v=1788237895492"></script>
|
||||
<!-- Update Manager - система принудительного обновления -->
|
||||
<script defer src="/src/utils/updateManager.js?v=1788040116230"></script>
|
||||
<script type="module" src="/src/components/UpdateChecker.jsx?v=1788040116230"></script>
|
||||
<script type="module" src="/dist/qr-local.js?v=1788040116230"></script>
|
||||
<script type="module" src="/src/components/QRScanner.js?v=1788040116230"></script>
|
||||
<script defer src="/src/utils/updateManager.js?v=1788237895492"></script>
|
||||
<script type="module" src="/src/components/UpdateChecker.jsx?v=1788237895492"></script>
|
||||
<script type="module" src="/dist/qr-local.js?v=1788237895492"></script>
|
||||
<script type="module" src="/src/components/QRScanner.js?v=1788237895492"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/dist/app-boot.js?v=1788040116230"></script>
|
||||
<script type="module" src="/dist/app.js?v=1788040116230"></script>
|
||||
<script type="module" src="/dist/app-boot.js?v=1788237895492"></script>
|
||||
<script type="module" src="/dist/app.js?v=1788237895492"></script>
|
||||
|
||||
<script defer src="/src/scripts/pwa-register.js?v=1788040116230"></script>
|
||||
<script src="/src/pwa/install-prompt.js?v=1788040116230" type="module"></script>
|
||||
<script src="/src/pwa/pwa-manager.js?v=1788040116230" type="module"></script>
|
||||
<script defer src="/src/scripts/pwa-offline-test.js?v=1788040116230"></script>
|
||||
<link rel="stylesheet" href="/src/styles/pwa.css?v=1788040116230">
|
||||
<script defer src="/src/scripts/pwa-register.js?v=1788237895492"></script>
|
||||
<script src="/src/pwa/install-prompt.js?v=1788237895492" type="module"></script>
|
||||
<script src="/src/pwa/pwa-manager.js?v=1788237895492" type="module"></script>
|
||||
<script defer src="/src/scripts/pwa-offline-test.js?v=1788237895492"></script>
|
||||
<link rel="stylesheet" href="/src/styles/pwa.css?v=1788237895492">
|
||||
</body>
|
||||
</html>
|
||||
+24
-24
@@ -30,7 +30,7 @@
|
||||
|
||||
<!-- PWA Manifest -->
|
||||
<link rel="manifest" href="/ur/manifest.json">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788040116230">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788237895492">
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
@@ -96,7 +96,7 @@
|
||||
<link rel="apple-touch-startup-image" media="screen and (device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/logo/splash/splash_screens/8.3__iPad_Mini_portrait.png">
|
||||
|
||||
<!-- Apple Touch Icons -->
|
||||
<link rel="apple-touch-icon" href="/logo/icon-180x180.png?v=1788040116230">
|
||||
<link rel="apple-touch-icon" href="/logo/icon-180x180.png?v=1788237895492">
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="/logo/icon-57x57.png">
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="/logo/icon-60x60.png">
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="/logo/icon-72x72.png">
|
||||
@@ -105,7 +105,7 @@
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="/logo/icon-120x120.png">
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="/logo/icon-144x144.png">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="/logo/icon-152x152.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/logo/icon-180x180.png?v=1788040116230">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/logo/icon-180x180.png?v=1788237895492">
|
||||
|
||||
<!-- Microsoft Tiles -->
|
||||
<meta name="msapplication-TileColor" content="#ff6b35">
|
||||
@@ -222,7 +222,7 @@
|
||||
<!-- Render-blocking JS is deferred: classic deferred scripts and module scripts
|
||||
both execute in document order after parsing, so React still runs before the
|
||||
app modules below, but the parser / first paint is no longer blocked. -->
|
||||
<script defer src="/config/ice-servers.js?v=1788040116230"></script>
|
||||
<script defer src="/config/ice-servers.js?v=1788237895492"></script>
|
||||
<script defer src="/libs/react/react.production.min.js"></script>
|
||||
<script defer src="/libs/react-dom/react-dom.production.min.js"></script>
|
||||
<!-- Prism syntax highlighting (vendored, offline). Tokenizes code as TEXT only —
|
||||
@@ -230,8 +230,8 @@
|
||||
Its CSS is loaded async via load-async-css.js (not paint-critical). -->
|
||||
<script defer src="/libs/prism/prism.js"></script>
|
||||
<!-- Critical, paint-defining CSS stays render-blocking (avoids FOUC / layout shift). -->
|
||||
<link rel="stylesheet" href="/assets/tailwind.css?v=1788040116230">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788040116230">
|
||||
<link rel="stylesheet" href="/assets/tailwind.css?v=1788237895492">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788237895492">
|
||||
<!-- Preload only the fonts needed for first paint. fa-solid covers the bulk of UI
|
||||
icons; fa-regular/fa-brands are loaded on demand by their CSS (rarely on the
|
||||
first screen). Inter latin 400/700 cover body text and headings/buttons. -->
|
||||
@@ -239,38 +239,38 @@
|
||||
<link rel="preload" href="/assets/fonts/inter/files/inter-latin-400.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="/assets/fonts/inter/files/inter-latin-700.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="stylesheet" href="/assets/fonts/inter/inter.css">
|
||||
<link rel="stylesheet" href="/src/styles/main.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/animations.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/components.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/main.css?v=1788237895492">
|
||||
<link rel="stylesheet" href="/src/styles/animations.css?v=1788237895492">
|
||||
<link rel="stylesheet" href="/src/styles/components.css?v=1788237895492">
|
||||
<!-- Loads after components.css on purpose: press feedback, material weight,
|
||||
size-specific tracking and the reduced-motion / -transparency / -contrast
|
||||
answers all need to settle arguments with the sheets above on source order. -->
|
||||
<link rel="stylesheet" href="/src/styles/apple-motion.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/apple-motion.css?v=1788237895492">
|
||||
<!-- Last of the hand-written sheets: the mirroring rules must win over anything
|
||||
above them, and they only ever apply under [dir="rtl"]. -->
|
||||
<link rel="stylesheet" href="/src/styles/rtl.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/rtl.css?v=1788237895492">
|
||||
<!-- Non-critical CSS (FontAwesome ~102KB, Prism) loaded async — no longer blocks paint. -->
|
||||
<script defer src="/src/scripts/load-async-css.js?v=1788040116230"></script>
|
||||
<script defer src="/src/scripts/load-async-css.js?v=1788237895492"></script>
|
||||
<noscript>
|
||||
<link rel="stylesheet" href="/assets/fontawesome/css/all.min.css">
|
||||
<link rel="stylesheet" href="/libs/prism/prism.css">
|
||||
</noscript>
|
||||
<script defer src="/src/scripts/fa-check.js?v=1788040116230"></script>
|
||||
<script defer src="/src/scripts/fa-check.js?v=1788237895492"></script>
|
||||
<!-- Update Manager - система принудительного обновления -->
|
||||
<script defer src="/src/utils/updateManager.js?v=1788040116230"></script>
|
||||
<script type="module" src="/src/components/UpdateChecker.jsx?v=1788040116230"></script>
|
||||
<script type="module" src="/dist/qr-local.js?v=1788040116230"></script>
|
||||
<script type="module" src="/src/components/QRScanner.js?v=1788040116230"></script>
|
||||
<script defer src="/src/utils/updateManager.js?v=1788237895492"></script>
|
||||
<script type="module" src="/src/components/UpdateChecker.jsx?v=1788237895492"></script>
|
||||
<script type="module" src="/dist/qr-local.js?v=1788237895492"></script>
|
||||
<script type="module" src="/src/components/QRScanner.js?v=1788237895492"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/dist/app-boot.js?v=1788040116230"></script>
|
||||
<script type="module" src="/dist/app.js?v=1788040116230"></script>
|
||||
<script type="module" src="/dist/app-boot.js?v=1788237895492"></script>
|
||||
<script type="module" src="/dist/app.js?v=1788237895492"></script>
|
||||
|
||||
<script defer src="/src/scripts/pwa-register.js?v=1788040116230"></script>
|
||||
<script src="/src/pwa/install-prompt.js?v=1788040116230" type="module"></script>
|
||||
<script src="/src/pwa/pwa-manager.js?v=1788040116230" type="module"></script>
|
||||
<script defer src="/src/scripts/pwa-offline-test.js?v=1788040116230"></script>
|
||||
<link rel="stylesheet" href="/src/styles/pwa.css?v=1788040116230">
|
||||
<script defer src="/src/scripts/pwa-register.js?v=1788237895492"></script>
|
||||
<script src="/src/pwa/install-prompt.js?v=1788237895492" type="module"></script>
|
||||
<script src="/src/pwa/pwa-manager.js?v=1788237895492" type="module"></script>
|
||||
<script defer src="/src/scripts/pwa-offline-test.js?v=1788237895492"></script>
|
||||
<link rel="stylesheet" href="/src/styles/pwa.css?v=1788237895492">
|
||||
</body>
|
||||
</html>
|
||||
+24
-24
@@ -30,7 +30,7 @@
|
||||
|
||||
<!-- PWA Manifest -->
|
||||
<link rel="manifest" href="/zh/manifest.json">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788040116230">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788237895492">
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
@@ -96,7 +96,7 @@
|
||||
<link rel="apple-touch-startup-image" media="screen and (device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/logo/splash/splash_screens/8.3__iPad_Mini_portrait.png">
|
||||
|
||||
<!-- Apple Touch Icons -->
|
||||
<link rel="apple-touch-icon" href="/logo/icon-180x180.png?v=1788040116230">
|
||||
<link rel="apple-touch-icon" href="/logo/icon-180x180.png?v=1788237895492">
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="/logo/icon-57x57.png">
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="/logo/icon-60x60.png">
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="/logo/icon-72x72.png">
|
||||
@@ -105,7 +105,7 @@
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="/logo/icon-120x120.png">
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="/logo/icon-144x144.png">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="/logo/icon-152x152.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/logo/icon-180x180.png?v=1788040116230">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/logo/icon-180x180.png?v=1788237895492">
|
||||
|
||||
<!-- Microsoft Tiles -->
|
||||
<meta name="msapplication-TileColor" content="#ff6b35">
|
||||
@@ -222,7 +222,7 @@
|
||||
<!-- Render-blocking JS is deferred: classic deferred scripts and module scripts
|
||||
both execute in document order after parsing, so React still runs before the
|
||||
app modules below, but the parser / first paint is no longer blocked. -->
|
||||
<script defer src="/config/ice-servers.js?v=1788040116230"></script>
|
||||
<script defer src="/config/ice-servers.js?v=1788237895492"></script>
|
||||
<script defer src="/libs/react/react.production.min.js"></script>
|
||||
<script defer src="/libs/react-dom/react-dom.production.min.js"></script>
|
||||
<!-- Prism syntax highlighting (vendored, offline). Tokenizes code as TEXT only —
|
||||
@@ -230,8 +230,8 @@
|
||||
Its CSS is loaded async via load-async-css.js (not paint-critical). -->
|
||||
<script defer src="/libs/prism/prism.js"></script>
|
||||
<!-- Critical, paint-defining CSS stays render-blocking (avoids FOUC / layout shift). -->
|
||||
<link rel="stylesheet" href="/assets/tailwind.css?v=1788040116230">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788040116230">
|
||||
<link rel="stylesheet" href="/assets/tailwind.css?v=1788237895492">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1788237895492">
|
||||
<!-- Preload only the fonts needed for first paint. fa-solid covers the bulk of UI
|
||||
icons; fa-regular/fa-brands are loaded on demand by their CSS (rarely on the
|
||||
first screen). Inter latin 400/700 cover body text and headings/buttons. -->
|
||||
@@ -239,38 +239,38 @@
|
||||
<link rel="preload" href="/assets/fonts/inter/files/inter-latin-400.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="/assets/fonts/inter/files/inter-latin-700.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="stylesheet" href="/assets/fonts/inter/inter.css">
|
||||
<link rel="stylesheet" href="/src/styles/main.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/animations.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/components.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/main.css?v=1788237895492">
|
||||
<link rel="stylesheet" href="/src/styles/animations.css?v=1788237895492">
|
||||
<link rel="stylesheet" href="/src/styles/components.css?v=1788237895492">
|
||||
<!-- Loads after components.css on purpose: press feedback, material weight,
|
||||
size-specific tracking and the reduced-motion / -transparency / -contrast
|
||||
answers all need to settle arguments with the sheets above on source order. -->
|
||||
<link rel="stylesheet" href="/src/styles/apple-motion.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/apple-motion.css?v=1788237895492">
|
||||
<!-- Last of the hand-written sheets: the mirroring rules must win over anything
|
||||
above them, and they only ever apply under [dir="rtl"]. -->
|
||||
<link rel="stylesheet" href="/src/styles/rtl.css?v=1788040116230">
|
||||
<link rel="stylesheet" href="/src/styles/rtl.css?v=1788237895492">
|
||||
<!-- Non-critical CSS (FontAwesome ~102KB, Prism) loaded async — no longer blocks paint. -->
|
||||
<script defer src="/src/scripts/load-async-css.js?v=1788040116230"></script>
|
||||
<script defer src="/src/scripts/load-async-css.js?v=1788237895492"></script>
|
||||
<noscript>
|
||||
<link rel="stylesheet" href="/assets/fontawesome/css/all.min.css">
|
||||
<link rel="stylesheet" href="/libs/prism/prism.css">
|
||||
</noscript>
|
||||
<script defer src="/src/scripts/fa-check.js?v=1788040116230"></script>
|
||||
<script defer src="/src/scripts/fa-check.js?v=1788237895492"></script>
|
||||
<!-- Update Manager - система принудительного обновления -->
|
||||
<script defer src="/src/utils/updateManager.js?v=1788040116230"></script>
|
||||
<script type="module" src="/src/components/UpdateChecker.jsx?v=1788040116230"></script>
|
||||
<script type="module" src="/dist/qr-local.js?v=1788040116230"></script>
|
||||
<script type="module" src="/src/components/QRScanner.js?v=1788040116230"></script>
|
||||
<script defer src="/src/utils/updateManager.js?v=1788237895492"></script>
|
||||
<script type="module" src="/src/components/UpdateChecker.jsx?v=1788237895492"></script>
|
||||
<script type="module" src="/dist/qr-local.js?v=1788237895492"></script>
|
||||
<script type="module" src="/src/components/QRScanner.js?v=1788237895492"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/dist/app-boot.js?v=1788040116230"></script>
|
||||
<script type="module" src="/dist/app.js?v=1788040116230"></script>
|
||||
<script type="module" src="/dist/app-boot.js?v=1788237895492"></script>
|
||||
<script type="module" src="/dist/app.js?v=1788237895492"></script>
|
||||
|
||||
<script defer src="/src/scripts/pwa-register.js?v=1788040116230"></script>
|
||||
<script src="/src/pwa/install-prompt.js?v=1788040116230" type="module"></script>
|
||||
<script src="/src/pwa/pwa-manager.js?v=1788040116230" type="module"></script>
|
||||
<script defer src="/src/scripts/pwa-offline-test.js?v=1788040116230"></script>
|
||||
<link rel="stylesheet" href="/src/styles/pwa.css?v=1788040116230">
|
||||
<script defer src="/src/scripts/pwa-register.js?v=1788237895492"></script>
|
||||
<script src="/src/pwa/install-prompt.js?v=1788237895492" type="module"></script>
|
||||
<script src="/src/pwa/pwa-manager.js?v=1788237895492" type="module"></script>
|
||||
<script defer src="/src/scripts/pwa-offline-test.js?v=1788237895492"></script>
|
||||
<link rel="stylesheet" href="/src/styles/pwa.css?v=1788237895492">
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user