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