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
+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.