feat(groups): audio and video calls in group chats; release v6.6.6
CodeQL Analysis / Analyze CodeQL (push) Canceled after 0s
Deploy Application / deploy (push) Canceled after 0s
Mirror to Codeberg / mirror (push) Canceled after 0s
Mirror to PrivacyGuides / mirror (push) Canceled after 0s

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:
lockbitchat
2026-09-01 01:04:11 -04:00
parent 5e32f547b9
commit 113bb107d3
62 changed files with 8412 additions and 1039 deletions
+63
View File
@@ -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/`.
+5
View File
@@ -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
+3 -1
View File
@@ -9,7 +9,7 @@
No accounts. No servers storing your messages. No installation required.
[![License: MIT](https://img.shields.io/badge/License-MIT-f0892a.svg)](LICENSE)
[![Version](https://img.shields.io/badge/version-6.4.0-3ecf8e.svg)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-6.6.6-3ecf8e.svg)](CHANGELOG.md)
[![PWA](https://img.shields.io/badge/PWA-installable-3ecf8e.svg)](#install-as-an-app)
[![Encryption](https://img.shields.io/badge/crypto-ECDH%20P--384%20%C2%B7%20AES--256--GCM-blue.svg)](#security-model)
[![Forward secrecy](https://img.shields.io/badge/forward%20secrecy-Double%20Ratchet-3ecf8e.svg)](#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
View File
@@ -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
View File
File diff suppressed because one or more lines are too long
+24 -24
View File
@@ -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
View File
@@ -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;
}
}
}
+50
View File
@@ -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.
+449 -27
View File
@@ -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");
+2 -2
View File
File diff suppressed because one or more lines are too long
Vendored
+2777 -354
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
File diff suppressed because one or more lines are too long
+41 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 - 私密的端到端加密通讯工具",
+7 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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', {
+5
View File
@@ -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';
+575
View File
@@ -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;
}
+69 -9
View File
@@ -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,
]);
}
+9 -3
View File
@@ -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 } }, [
+140
View File
@@ -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 };
+107
View File
@@ -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 };
}
+308
View File
@@ -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
+739
View File
@@ -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';
}
+90
View File
@@ -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
View File
@@ -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": "سب"
}
};
+23
View File
@@ -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
+155 -11
View File
@@ -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
View File
@@ -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
View File
@@ -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';
+6 -1
View File
@@ -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
View File
@@ -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 {
+1 -1
View File
@@ -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
+40
View File
@@ -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');
+97
View File
@@ -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');
+927
View File
@@ -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');
+100
View File
@@ -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');
+11 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>