feat(i18n): nine languages, each at its own address; release v6.3.0
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

The site now speaks German, French, Spanish, Ukrainian, Russian, Chinese,
Korean and Hindi alongside English — 623 strings per language, 5,607
translations, covering the landing page, the key exchange, the chat, group
calls and every error along the way.

Each locale is a real page at a real URL (/de/, /fr/, …), generated at build
time from locales/*.json. That is the whole point: the app is client-rendered,
so a language that only swaps strings at runtime has no address for a crawler
to index and no link anyone can share. Each page carries its own canonical, a
reciprocal hreflang cluster and translated schema.org.

The URL decides the language, always. A stored preference only applies where
the address does not say, and nothing ever redirects on Accept-Language —
that is how sites become invisible to search engines outside one country.

robots.txt and sitemap.xml did not exist before; both are generated now.

Bugs found on the way, each with a test that would have caught it:
  - partner logos used page-relative paths and 404'd from any /xx/ page
  - post-build stamped ?v= only into the root shell, leaving locales behind
  - "Back online" appeared on every page load, not just after being offline
  - update timestamps were hard-coded to US format for every reader
  - t() threw on a partial window, taking whole components down with it
This commit is contained in:
lockbitchat
2026-08-29 12:46:15 -04:00
parent 943e04e7ff
commit 98d42ac2fb
73 changed files with 33861 additions and 1299 deletions
+18 -23
View File
@@ -1,3 +1,5 @@
import { t } from '../../i18n/index.js';
// "Trusted by our partners" — partner ecosystem section.
// Translated from the Claude Design component (Partners.dc.html) into the
// project's React.createElement style: a full-bleed dark band with partner
@@ -26,21 +28,21 @@ const BecomePartner = () => {
{
id: 'aegis',
name: 'Aegis Investment',
logo: 'logo/aegis.png',
logo: '/logo/aegis.png',
logoHeight: '42px',
url: 'https://aegis-investment.com/',
desc: 'Capital partner securing confidential financial communications across its portfolio.',
role: 'Strategic backer',
desc: t('partners.aegis.desc'),
role: t('partners.aegis.role'),
delay: '.5s'
},
{
id: 'furi',
name: 'FuriLabs',
logo: 'logo/furi.png',
logo: '/logo/furi.png',
logoHeight: '54px',
url: 'https://furilabs.com/',
desc: 'Privacy-first Linux phones that ship SecureBit as a default secure channel.',
role: 'Technology partner',
desc: t('partners.furilabs.desc'),
role: t('partners.furilabs.role'),
delay: '.56s'
}
];
@@ -107,8 +109,8 @@ const BecomePartner = () => {
key: 'icon',
style: { width: '48px', height: '48px', borderRadius: '13px', display: 'grid', placeItems: 'center', background: 'rgba(240,137,42,0.12)', border: '1px solid rgba(240,137,42,0.28)', marginBottom: '24px' }
}, svg('<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M19 8v6M22 11h-6"/>', 23, ACCENT, 1.9)),
React.createElement('h3', { key: 'title', style: { margin: '0 0 8px', fontSize: '21px', fontWeight: 800, letterSpacing: '-0.4px', color: '#f4f4f6' } }, 'Become a partner'),
React.createElement('p', { key: 'desc', style: { margin: 0, fontSize: '14.5px', lineHeight: 1.6, color: '#8a8a92' } }, "Building privacy hardware or infrastructure? Let's integrate SecureBit.")
React.createElement('h3', { key: 'title', style: { margin: '0 0 8px', fontSize: '21px', fontWeight: 800, letterSpacing: '-0.4px', color: '#f4f4f6' } }, t('partners.inviteTitle')),
React.createElement('p', { key: 'desc', style: { margin: 0, fontSize: '14.5px', lineHeight: 1.6, color: '#8a8a92' } }, t('partners.inviteDesc'))
]),
React.createElement('span', {
key: 'btn',
@@ -120,7 +122,7 @@ const BecomePartner = () => {
transition: 'background .2s cubic-bezier(.2,.7,.3,1), transform .2s cubic-bezier(.2,.7,.3,1)'
}
}, [
'Start a conversation',
t('partners.inviteCta'),
svg('<path d="M5 12h14M13 6l6 6-6 6"/>', 17, 'currentColor', 2.2)
])
]);
@@ -134,20 +136,13 @@ const BecomePartner = () => {
React.createElement('div', {
key: 'eyebrow',
style: { fontFamily: MONO, fontSize: '11px', fontWeight: 600, color: '#6b6b73', textTransform: 'uppercase', letterSpacing: '1.6px', marginBottom: '14px' }
}, 'Partners & ecosystem'),
React.createElement('div', {
key: 'row',
style: { display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: '32px', flexWrap: 'wrap' }
}, [
React.createElement('h2', {
key: 'h2',
style: { margin: 0, fontSize: isMobile ? '30px' : '40px', fontWeight: 800, letterSpacing: '-1.1px', lineHeight: 1.04, color: '#f4f4f6' }
}, 'Trusted by our partners'),
React.createElement('p', {
key: 'sub',
style: { margin: '0 0 4px', fontSize: '15px', lineHeight: 1.55, color: '#8a8a92', maxWidth: '360px' }
}, "A small, vetted circle — no pay-to-list logos and no badges we can't stand behind.")
])
}, t('partners.eyebrow')),
// The heading used to share a flex row with a strapline; with that gone the
// row had one child and nothing left to arrange.
React.createElement('h2', {
key: 'h2',
style: { margin: 0, fontSize: isMobile ? '30px' : '40px', fontWeight: 800, letterSpacing: '-1.1px', lineHeight: 1.04, color: '#f4f4f6' }
}, t('partners.heading'))
]),
// Cards
+30 -29
View File
@@ -1,3 +1,4 @@
import { t } from '../../i18n/index.js';
// Encrypted voice / video call UI for SecureBit.chat.
//
// Faithful implementation of the "SecureBit Chat.dc.html" design: the voice-call
@@ -89,10 +90,10 @@ const CallUIComponent = ({ webrtcManager, peerTitle }) => {
const fmt = (s) => `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
const ringing = phase === 'outgoing' || phase === 'connecting';
const callStatus = phase === 'outgoing' ? 'Ringing'
: phase === 'connecting' ? 'Connecting'
: phase === 'active' ? fmt(seconds) : 'Ringing';
const name = peerTitle || 'Secure peer';
const callStatus = phase === 'outgoing' ? t('call.ringing')
: phase === 'connecting' ? t('call.connecting')
: phase === 'active' ? fmt(seconds) : t('call.ringing');
const name = peerTitle || t('call.peer');
// ── shared styles (from the design) ──────────────────────────────────────
const ctrlBase = {
@@ -114,15 +115,15 @@ const CallUIComponent = ({ webrtcManager, peerTitle }) => {
});
const encBadge = h('span', { key: 'enc', style: { display: 'inline-flex', alignItems: 'center', gap: '4px', fontSize: '11px', fontWeight: 600, color: '#3ecf8e' } },
[svg(ICON.lock, 11, 2), 'Encrypted']);
[svg(ICON.lock, 11, 2), t('call.encryptedShort')]);
// Connection-quality indicator — driven by the adaptation controller's
// getStats (loss + RTT). Signal bars + label; hidden until there's data.
const QUALITY = {
excellent: { bars: 4, color: '#3ecf8e', label: 'Excellent' },
good: { bars: 3, color: '#3ecf8e', label: 'Good' },
fair: { bars: 2, color: '#e3c84e', label: 'Fair' },
poor: { bars: 1, color: '#e5727a', label: 'Weak' },
excellent: { bars: 4, color: '#3ecf8e', label: t('call.qualityExcellent') },
good: { bars: 3, color: '#3ecf8e', label: t('call.qualityGood') },
fair: { bars: 2, color: '#e3c84e', label: t('call.qualityFair') },
poor: { bars: 1, color: '#e5727a', label: t('call.qualityWeak') },
};
const qualityIndicator = (compact) => {
const q = QUALITY[call.quality];
@@ -132,7 +133,7 @@ const CallUIComponent = ({ webrtcManager, peerTitle }) => {
key: i, style: { width: '3px', height: (5 + i * 3) + 'px', borderRadius: '1px', background: i < q.bars ? q.color : 'rgba(255,255,255,0.18)' }
})));
if (compact) return bars;
return h('span', { key: 'q', title: 'Connection quality', style: { display: 'inline-flex', alignItems: 'center', gap: '6px', fontSize: '11.5px', fontWeight: 600, color: q.color } }, [bars, q.label]);
return h('span', { key: 'q', title: t('call.quality'), style: { display: 'inline-flex', alignItems: 'center', gap: '6px', fontSize: '11.5px', fontWeight: 600, color: q.color } }, [bars, q.label]);
};
// ── actions ──────────────────────────────────────────────────────────────
@@ -161,15 +162,15 @@ const CallUIComponent = ({ webrtcManager, peerTitle }) => {
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' } }, [
hiddenAudio,
h('div', { key: 'top', style: { flex: 'none', display: 'flex', alignItems: 'center', justifyContent: 'flex-start', padding: '16px 18px' } },
h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: '7px', fontSize: '12px', fontWeight: 600, color: '#3ecf8e' } }, [svg(ICON.lock, 13, 2), 'Encrypted call'])),
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' } }, [
avatarDisc(46, true),
h('div', { key: 'nm', style: { fontSize: '24px', fontWeight: 800, letterSpacing: '-0.5px', color: '#f4f4f6' } }, name),
h('div', { key: 'st', style: { fontFamily: MONO, fontSize: '14px', fontWeight: 500, color: '#9a9aa2', marginTop: '8px' } }, call.withVideo ? 'Incoming video call' : 'Incoming call')
h('div', { key: 'st', style: { fontFamily: MONO, fontSize: '14px', fontWeight: 500, color: '#9a9aa2', marginTop: '8px' } }, call.withVideo ? t('call.incomingVideo') : t('call.incoming'))
]),
h('div', { key: 'ctrls', style: { flex: 'none', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', gap: '48px', padding: '28px 24px 40px' } }, [
labeled('dec', h('button', { onClick: doDecline, title: 'Decline', style: { ...endBtn, width: '62px', height: '62px' } }, svg(ICON.phoneHangup, 24, 1.9)), 'Decline'),
labeled('acc', h('button', { onClick: doAccept, title: 'Accept', 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)), 'Accept')
labeled('dec', h('button', { onClick: doDecline, title: t('call.decline'), style: { ...endBtn, width: '62px', height: '62px' } }, svg(ICON.phoneHangup, 24, 1.9)), t('call.decline')),
labeled('acc', h('button', { onClick: doAccept, title: t('call.accept'), 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)), t('call.accept'))
])
]);
}
@@ -188,12 +189,12 @@ const CallUIComponent = ({ webrtcManager, peerTitle }) => {
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' } }, name),
h('div', { key: 's', style: { display: 'flex', alignItems: 'center', gap: '7px', fontFamily: MONO, fontSize: '11px', color: '#9a9aa2' } }, [
(isVideo ? 'Video · ' : 'Voice · ') + callStatus,
(isVideo ? t('call.videoPrefix') : t('call.voicePrefix')) + callStatus,
phase === 'active' && qualityIndicator(true)
])
]),
h('button', { key: 'exp', onClick: () => setMinimized(false), title: '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', transition: 'all .15s' } }, svg(ICON.expand, 15, 2)),
h('button', { key: 'end', onClick: doEnd, title: 'End call', style: { flex: 'none', width: '32px', height: '32px', borderRadius: '8px', display: 'grid', placeItems: 'center', border: 'none', background: '#e5484d', color: '#fff', cursor: 'pointer', transition: 'transform .15s' } }, svg(ICON.phoneHangup, 15, 2))
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', transition: 'all .15s' } }, svg(ICON.expand, 15, 2)),
h('button', { key: 'end', onClick: doEnd, title: t('call.end'), style: { flex: 'none', width: '32px', height: '32px', borderRadius: '8px', display: 'grid', placeItems: 'center', border: 'none', background: '#e5484d', color: '#fff', cursor: 'pointer', transition: 'transform .15s' } }, svg(ICON.phoneHangup, 15, 2))
])
]);
}
@@ -206,7 +207,7 @@ const CallUIComponent = ({ webrtcManager, peerTitle }) => {
? h('video', { key: 'rv', ref: remoteVideoRef, autoPlay: true, muted: true, playsInline: true, style: { position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', background: '#0a0a0c' } })
: h('div', { key: 'ph', style: { position: 'absolute', inset: 0, background: 'linear-gradient(120deg, #15151b, #1d1a24, #161620)', backgroundSize: '200% 200%', animation: 'sbLiveBg 9s ease-in-out infinite', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '18px' } }, [
h('div', { key: 'a', style: { width: '120px', height: '120px', 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: '#9a9aa2' } }, svg(ICON.user, 54, 1.5)),
h('div', { key: 't', style: { fontSize: '15px', fontWeight: 600, color: '#8a8a92' } }, "Peer's camera is off")
h('div', { key: 't', style: { fontSize: '15px', fontWeight: 600, color: '#8a8a92' } }, t('call.peerCameraOff'))
]),
// Top bar
h('div', { key: 'top', style: { position: 'absolute', top: 0, left: 0, right: 0, display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '14px', padding: '18px 20px', background: 'linear-gradient(180deg, rgba(0,0,0,0.55), transparent)' } }, [
@@ -218,22 +219,22 @@ const CallUIComponent = ({ webrtcManager, peerTitle }) => {
phase === 'active' && qualityIndicator(false)
])
]),
h('button', { key: 'min', onClick: () => setMinimized(true), title: 'Minimize', style: { flex: 'none', ...minimizeBtn(true) } }, svg(ICON.minimize, 16, 2))
h('button', { key: 'min', onClick: () => setMinimized(true), title: t('call.minimize'), style: { flex: 'none', ...minimizeBtn(true) } }, svg(ICON.minimize, 16, 2))
]),
// Self-cam PiP
h('div', { key: 'self', style: { position: 'absolute', bottom: '108px', right: '18px', width: '132px', height: '176px', borderRadius: '14px', overflow: 'hidden', border: '1px solid rgba(255,255,255,0.16)', boxShadow: '0 12px 30px rgba(0,0,0,0.5)', background: '#111' } }, [
h('video', { key: 'sv', ref: selfVideoRef, autoPlay: true, muted: true, playsInline: true, style: { width: '100%', height: '100%', objectFit: 'cover', transform: 'scaleX(-1)', display: 'block' } }),
!call.cameraEnabled && h('div', { key: 'off', style: { position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '8px', background: '#161618', color: '#6b6b73' } }, [
svg(ICON.camOff, 24, 1.8),
h('span', { key: 't', style: { fontSize: '10.5px', color: '#6b6b73', fontFamily: MONO } }, 'Camera off')
h('span', { key: 't', style: { fontSize: '10.5px', color: '#6b6b73', fontFamily: MONO } }, t('call.cameraOff'))
])
]),
// Control bar
h('div', { key: 'ctrls', style: { position: 'absolute', bottom: 0, left: 0, right: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '18px', padding: '22px 24px 28px', background: 'linear-gradient(0deg, rgba(0,0,0,0.6), transparent)' } }, [
h('button', { key: 'mute', onClick: doMute, title: 'Mute', style: call.micEnabled ? ctrlBase : dangerCtrl }, svg(call.micEnabled ? ICON.micOn : ICON.micOff, 21, 1.9)),
h('button', { key: 'cam', onClick: doCamera, title: 'Camera', style: call.cameraEnabled ? ctrlBase : dangerCtrl }, svg(call.cameraEnabled ? ICON.camOn : ICON.camOff, 21, 1.8)),
h('button', { key: 'flip', onClick: doFlip, title: 'Flip camera', style: ctrlBase }, svg(ICON.flip, 21, 1.8)),
h('button', { key: 'end', onClick: doEnd, title: 'End call', style: endBtn }, svg(ICON.phoneHangup, 22, 1.9))
h('button', { key: 'mute', onClick: doMute, title: t('call.mute'), style: call.micEnabled ? ctrlBase : dangerCtrl }, svg(call.micEnabled ? ICON.micOn : ICON.micOff, 21, 1.9)),
h('button', { key: 'cam', onClick: doCamera, title: t('call.camera'), style: call.cameraEnabled ? ctrlBase : dangerCtrl }, svg(call.cameraEnabled ? ICON.camOn : ICON.camOff, 21, 1.8)),
h('button', { key: 'flip', onClick: doFlip, title: t('call.flipCamera'), style: ctrlBase }, svg(ICON.flip, 21, 1.8)),
h('button', { key: 'end', onClick: doEnd, title: t('call.end'), style: endBtn }, svg(ICON.phoneHangup, 22, 1.9))
])
]);
}
@@ -242,8 +243,8 @@ const CallUIComponent = ({ webrtcManager, peerTitle }) => {
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' } }, [
hiddenAudio,
h('div', { key: 'top', style: { flex: 'none', display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '16px 18px' } }, [
h('span', { key: 'enc', style: { display: 'inline-flex', alignItems: 'center', gap: '7px', fontSize: '12px', fontWeight: 600, color: '#3ecf8e' } }, [svg(ICON.lock, 13, 2), 'Encrypted call']),
h('button', { key: 'min', onClick: () => setMinimized(true), title: 'Minimize', style: minimizeBtn(false) }, svg(ICON.minimize, 16, 2))
h('span', { key: 'enc', style: { display: 'inline-flex', alignItems: 'center', gap: '7px', fontSize: '12px', fontWeight: 600, color: '#3ecf8e' } }, [svg(ICON.lock, 13, 2), t('call.encrypted')]),
h('button', { key: 'min', onClick: () => setMinimized(true), title: t('call.minimize'), style: minimizeBtn(false) }, svg(ICON.minimize, 16, 2))
]),
h('div', { key: 'mid', style: { flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' } }, [
avatarDisc(46, ringing),
@@ -252,9 +253,9 @@ const CallUIComponent = ({ webrtcManager, peerTitle }) => {
phase === 'active' && h('div', { key: 'q', style: { marginTop: '12px' } }, qualityIndicator(false))
]),
h('div', { key: 'ctrls', style: { flex: 'none', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', gap: '26px', padding: '28px 24px 34px' } }, [
labeled('mute', h('button', { onClick: doMute, title: 'Mute', style: call.micEnabled ? ctrlBase : dangerCtrl }, svg(call.micEnabled ? ICON.micOn : ICON.micOff, 22, 1.9)), call.micEnabled ? 'Mute' : 'Muted'),
labeled('video', h('button', { onClick: doUpgrade, title: 'Add video', style: ctrlBase }, svg(ICON.camOn, 22, 1.8)), 'Video'),
labeled('end', h('button', { onClick: doEnd, title: 'End call', style: endBtn }, svg(ICON.phoneHangup, 22, 1.9)), 'End')
labeled('mute', h('button', { onClick: doMute, title: t('call.mute'), style: call.micEnabled ? ctrlBase : dangerCtrl }, svg(call.micEnabled ? ICON.micOn : ICON.micOff, 22, 1.9)), call.micEnabled ? 'Mute' : t('call.muted')),
labeled('video', h('button', { onClick: doUpgrade, title: t('call.addVideo'), style: ctrlBase }, svg(ICON.camOn, 22, 1.8)), t('call.video')),
labeled('end', h('button', { onClick: doEnd, title: t('call.end'), style: endBtn }, svg(ICON.phoneHangup, 22, 1.9)), t('call.endShort'))
])
]);
};
+7 -24
View File
@@ -1,3 +1,5 @@
import { t } from '../../i18n/index.js';
// "Join the future of privacy" — community / open-source call-to-action.
// Translated from the Claude Design component (Community CTA.dc.html): a centered
// glowing card with GitHub + Feedback actions on a full-bleed dark band.
@@ -16,7 +18,6 @@ const CommunityCTA = () => {
}, []);
const ACCENT = '#f0892a';
const MONO = "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace";
const SANS = "'Manrope', system-ui, -apple-system, sans-serif";
const githubUrl = 'https://github.com/SecureBitChat/securebit-chat/';
@@ -41,7 +42,7 @@ const CommunityCTA = () => {
key: 'i', width: 20, height: 20, viewBox: '0 0 24 24', fill: 'currentColor',
dangerouslySetInnerHTML: { __html: '<path d="M12 2C6.48 2 2 6.58 2 12.26c0 4.5 2.87 8.32 6.84 9.67.5.09.68-.22.68-.49 0-.24-.01-.87-.01-1.71-2.78.62-3.37-1.36-3.37-1.36-.46-1.18-1.11-1.5-1.11-1.5-.91-.63.07-.62.07-.62 1 .07 1.53 1.05 1.53 1.05.89 1.56 2.34 1.11 2.91.85.09-.66.35-1.11.63-1.36-2.22-.26-4.55-1.14-4.55-5.07 0-1.12.39-2.03 1.03-2.75-.1-.26-.45-1.3.1-2.71 0 0 .84-.27 2.75 1.05a9.3 9.3 0 0 1 5 0c1.91-1.32 2.75-1.05 2.75-1.05.55 1.41.2 2.45.1 2.71.64.72 1.03 1.63 1.03 2.75 0 3.94-2.34 4.81-4.57 5.06.36.32.68.94.68 1.9 0 1.37-.01 2.47-.01 2.81 0 .27.18.59.69.49A10.02 10.02 0 0 0 22 12.26C22 6.58 17.52 2 12 2z"/>' }
}),
'GitHub Repository'
t('community.github')
]);
const feedbackBtn = React.createElement('a', {
@@ -63,15 +64,7 @@ const CommunityCTA = () => {
strokeWidth: 1.9, strokeLinecap: 'round', strokeLinejoin: 'round',
dangerouslySetInnerHTML: { __html: '<path d="M21 11.5a8 8 0 0 1-11.6 7.1L4 20l1.4-5.3A8 8 0 1 1 21 11.5z"/><path d="M8.5 11h7M8.5 14h4.5"/>' }
}),
'Feedback'
]);
const chip = (label) => React.createElement('span', {
key: label,
style: { display: 'inline-flex', alignItems: 'center', gap: '7px' }
}, [
React.createElement('span', { key: 'd', style: { width: '5px', height: '5px', borderRadius: '50%', background: '#3ecf8e' } }),
label
t('community.feedback')
]);
const card = React.createElement('div', {
@@ -97,31 +90,21 @@ const CommunityCTA = () => {
alt: 'SecureBit',
style: { display: 'inline-block', width: '64px', height: '64px', objectFit: 'contain', marginBottom: '22px', animation: 'ccUp .4s cubic-bezier(.2,.7,.3,1)' }
}),
// eyebrow
React.createElement('div', {
key: 'eyebrow',
style: { fontFamily: MONO, fontSize: '11px', fontWeight: 600, color: '#6b6b73', textTransform: 'uppercase', letterSpacing: '1.8px', marginBottom: '14px' }
}, 'Open source · community-driven'),
// title
React.createElement('h2', {
key: 'title',
style: { margin: '0 0 16px', fontSize: isMobile ? '28px' : '36px', fontWeight: 800, letterSpacing: '-1px', lineHeight: 1.05, color: '#f4f4f6' }
}, 'Join the future of privacy'),
}, t('community.title')),
// description
React.createElement('p', {
key: 'desc',
style: { margin: '0 auto 32px', maxWidth: '560px', fontSize: '16px', lineHeight: 1.65, color: '#9a9aa2' }
}, '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 endtoend.'),
}, t('community.description')),
// buttons
React.createElement('div', {
key: 'btns',
style: { display: 'flex', gap: '14px', justifyContent: 'center', flexWrap: 'wrap' }
}, [githubBtn, feedbackBtn]),
// trust chips
React.createElement('div', {
key: 'chips',
style: { display: 'flex', gap: '10px 22px', justifyContent: 'center', flexWrap: 'wrap', marginTop: '30px', fontFamily: MONO, fontSize: '11px', fontWeight: 500, color: '#56565e', textTransform: 'uppercase', letterSpacing: '1px' }
}, [chip('MIT licensed'), chip('No tracking'), chip('Auditable cryptography')])
}, [githubBtn, feedbackBtn])
]);
return React.createElement('section', {
+16 -15
View File
@@ -1,3 +1,4 @@
import { t } from '../../i18n/index.js';
// File Transfer Component for Chat Interface - Fixed Version
const FileTransferComponent = ({ webrtcManager, isConnected, pendingIncomingFiles = [], onIncomingDecision, showDropzone = true }) => {
const [dragOver, setDragOver] = React.useState(false);
@@ -50,13 +51,13 @@ const FileTransferComponent = ({ webrtcManager, isConnected, pendingIncomingFile
// Более мягкая обработка ошибок - не закрываем сессию
// Показываем пользователю ошибку, но не закрываем соединение
if (error.message.includes('Connection not ready')) {
if (error.message.includes(t('file.notReady'))) {
alert(`Файл ${file.name} не может быть отправлен сейчас. Проверьте соединение и попробуйте снова.`);
} else if (error.message.includes('File too large') || error.message.includes('exceeds maximum')) {
} else if (error.message.includes(t('file.tooLarge')) || error.message.includes('exceeds maximum')) {
alert(`Файл ${file.name} слишком большой: ${error.message}`);
} else if (error.message.includes('Maximum concurrent transfers')) {
} else if (error.message.includes(t('file.maxConcurrent'))) {
alert(`Достигнут лимит одновременных передач. Дождитесь завершения текущих передач.`);
} else if (error.message.includes('File type not allowed')) {
} else if (error.message.includes(t('file.typeNotAllowed'))) {
alert(`Тип файла ${file.name} не поддерживается: ${error.message}`);
} else {
alert(`Ошибка отправки файла ${file.name}: ${error.message}`);
@@ -190,7 +191,7 @@ const FileTransferComponent = ({ webrtcManager, isConnected, pendingIncomingFile
if (!isConnected) {
return React.createElement('div', {
className: "p-4 text-center text-muted"
}, 'Передача файлов доступна только при установленном соединении');
}, t('file.needConnection'));
}
// Проверяем дополнительное состояние соединения
@@ -232,8 +233,8 @@ const FileTransferComponent = ({ webrtcManager, isConnected, pendingIncomingFile
key: 'icon-box',
style: { width: '42px', height: '42px', margin: '0 auto 10px', borderRadius: '12px', display: 'grid', placeItems: 'center', background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)' }
}, React.createElement('i', { className: 'fas fa-arrow-up-from-bracket', style: { color: '#9a9aa2', fontSize: '18px' } })),
React.createElement('div', { key: 'title', style: { fontSize: '14px', fontWeight: 700, color: '#e8e8eb' } }, 'Drag & drop files here'),
React.createElement('div', { key: 'sub', style: { fontSize: '12px', color: '#7b7b83', marginTop: '4px' } }, 'Encrypted end-to-end before transfer · up to 100 MB'),
React.createElement('div', { key: 'title', style: { fontSize: '14px', fontWeight: 700, color: '#e8e8eb' } }, t('file.drop')),
React.createElement('div', { key: 'sub', style: { fontSize: '12px', color: '#7b7b83', marginTop: '4px' } }, t('file.dropHint')),
React.createElement('button', {
key: 'browse',
type: 'button',
@@ -242,7 +243,7 @@ const FileTransferComponent = ({ webrtcManager, isConnected, pendingIncomingFile
style: { marginTop: '14px', display: 'inline-flex', alignItems: 'center', gap: '7px', padding: '9px 16px', borderRadius: '9px', border: 'none', background: '#f0892a', color: '#1a0f04', fontFamily: 'inherit', fontSize: '13px', fontWeight: 700, cursor: 'pointer' }
}, [
React.createElement('i', { key: 'i', className: 'fas fa-folder-open', style: { fontSize: '13px' } }),
'Browse device'
t('file.browse')
])
]),
@@ -274,7 +275,7 @@ const FileTransferComponent = ({ webrtcManager, isConnected, pendingIncomingFile
React.createElement('div', {
key: 'title',
style: { fontSize: '13px', fontWeight: 600, color: '#e8e8eb' }
}, 'Incoming file request'),
}, t('file.incoming')),
React.createElement('div', {
key: 'meta',
style: { fontSize: '11.5px', color: '#7b7b83', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }
@@ -289,12 +290,12 @@ const FileTransferComponent = ({ webrtcManager, isConnected, pendingIncomingFile
key: 'accept',
onClick: () => handleIncomingDecision(file.fileId, true),
style: { display: 'inline-flex', alignItems: 'center', gap: '6px', borderRadius: '8px', border: 'none', background: '#f0892a', color: '#1a0f04', padding: '8px 14px', fontSize: '13px', fontWeight: 700, cursor: 'pointer' }
}, [React.createElement('i', { key: 'i', className: 'fas fa-check', style: { fontSize: '12px' } }), 'Accept']),
}, [React.createElement('i', { key: 'i', className: 'fas fa-check', style: { fontSize: '12px' } }), t('file.accept')]),
React.createElement('button', {
key: 'reject',
onClick: () => handleIncomingDecision(file.fileId, false),
style: { display: 'inline-flex', alignItems: 'center', gap: '6px', borderRadius: '8px', border: '1px solid rgba(229,114,122,0.3)', background: 'rgba(229,114,122,0.08)', color: '#e5727a', padding: '8px 14px', fontSize: '13px', fontWeight: 600, cursor: 'pointer' }
}, [React.createElement('i', { key: 'i', className: 'fas fa-xmark', style: { fontSize: '12px' } }), 'Reject'])
}, [React.createElement('i', { key: 'i', className: 'fas fa-xmark', style: { fontSize: '12px' } }), t('file.reject')])
])
]))),
@@ -312,7 +313,7 @@ const FileTransferComponent = ({ webrtcManager, isConnected, pendingIncomingFile
className: 'fas fa-right-left',
style: { fontSize: '12px' }
}),
'File transfers'
t('file.title')
]),
// Sending files
@@ -396,19 +397,19 @@ const FileTransferComponent = ({ webrtcManager, isConnected, pendingIncomingFile
onClick: async () => {
try {
const url = await webrtcManager.getReceivedFileObjectURL(transfer.fileId);
if (!url) { alert('This file is no longer available for download.'); return; }
if (!url) { alert(t('file.gone')); return; }
const a = document.createElement('a');
a.href = url;
a.download = transfer.fileName || 'file';
a.click();
setTimeout(() => webrtcManager.revokeReceivedFileObjectURL(url), 10000);
} catch (e) {
alert(e.message || 'This file is no longer available for download.');
alert(e.message || t('file.gone'));
}
}
}, [
React.createElement('i', { key: 'i', className: 'fas fa-download mr-1' }),
'Download'
t('file.download')
]) : null,
React.createElement('button', {
key: 'cancel',
+64 -63
View File
@@ -1,3 +1,4 @@
import { t } from '../../i18n/index.js';
// Group chat surfaces: the conversation view, the safety-code ceremony, the
// create dialog and the inbound invitation.
//
@@ -92,7 +93,7 @@ const label = {
/**
* What to say while there is no code yet.
*
* This used to collapse to "Exchanging nonces…" for every phase that was not
* This used to collapse to t('group.exchangingNonces') for every phase that was not
* COMMITTING — which included FAILED. A group that had actually died therefore
* looked identical to one still working, the confirm button stayed disabled, and
* the only visible symptom was a dialog that never finished. Naming the real
@@ -100,30 +101,30 @@ const label = {
*/
function waitingWord(group) {
switch (group.phase) {
case GROUP_PHASE.FORMING: return 'Waiting for the other members to join';
case GROUP_PHASE.COMMITTING: return 'Waiting for every member to commit';
case GROUP_PHASE.REVEALING: return 'Exchanging nonces';
case GROUP_PHASE.FAILED: return GROUP_ERROR_WORD[group.error] || 'This group could not be formed.';
default: return 'Working';
case GROUP_PHASE.FORMING: return t('group.waitingJoin');
case GROUP_PHASE.COMMITTING: return t('group.waitingCommit');
case GROUP_PHASE.REVEALING: return t('group.exchangingNonces');
case GROUP_PHASE.FAILED: return GROUP_ERROR_WORD[group.error] || t('group.errNotFormed');
default: return t('group.working');
}
}
/** Failure codes from GroupSession, in words a person can act on. */
const GROUP_ERROR_WORD = {
invitations_could_not_be_sent: 'The invitation could not be sent — that chat is not connected.',
invitees_did_not_respond: 'Nobody accepted the invitation in time.',
roster_never_arrived: 'The group owner never sent the member list.',
ceremony_timed_out: 'A member stopped responding before the code was ready.',
bad_signature: 'The member list was not signed by the group owner. Do not retry — tell them.',
wrong_admin: 'Someone other than the group owner tried to change the members.',
fingerprint_mismatch: 'A members key did not match the identity claimed for it.',
commitment_mismatch: 'A members revealed value did not match what they committed to.',
commitment_changed: 'A member changed their commitment part-way through.',
missing_member_key: 'A member was listed whose key never arrived.',
not_a_member: 'A frame arrived from someone outside the group.',
bad_name: 'The group name is too long.',
too_many_members: 'A group is limited to eight members.',
frame_too_large: 'A message was too large to send to the group.',
invitations_could_not_be_sent: t('group.errInviteFailed'),
invitees_did_not_respond: t('group.errNobodyAccepted'),
roster_never_arrived: t('group.errNoMemberList'),
ceremony_timed_out: t('groupErr.timeout'),
bad_signature: t('group.errUnsignedList'),
wrong_admin: t('group.errNotOwner'),
fingerprint_mismatch: t('groupErr.keyMismatch'),
commitment_mismatch: t('groupErr.revealMismatch'),
commitment_changed: t('groupErr.commitChanged'),
missing_member_key: t('groupErr.missingKey'),
not_a_member: t('groupErr.outsider'),
bad_name: t('group.nameTooLong'),
too_many_members: t('groupErr.limit'),
frame_too_large: t('groupErr.tooLarge'),
};
export function GroupSasModal({ group, onConfirm, onCancel }) {
@@ -134,7 +135,7 @@ export function GroupSasModal({ group, onConfirm, onCancel }) {
return h('div', { style: overlay, role: 'dialog', 'aria-modal': 'true' },
h('div', { style: card }, [
h('div', { key: 'h', style: { display: 'flex', flexDirection: 'column', gap: '6px' } }, [
h('span', { key: 'l', style: label }, 'Group safety code'),
h('span', { key: 'l', style: label }, t('group.sasTitle')),
h('h3', { key: 't', style: { margin: 0, fontSize: '19px', fontWeight: 700, color: C.ink } }, group.name),
]),
@@ -176,7 +177,7 @@ export function GroupSasModal({ group, onConfirm, onCancel }) {
key: 'why',
style: { margin: 0, fontSize: '13.5px', lineHeight: 1.62, color: C.ink2 },
}, [
'Read these digits aloud to ', h('b', { key: 'b', style: { color: C.ink } }, `all ${group.members.length - 1} other members`),
t('group.sasReadAloud'), h('b', { key: 'b', style: { color: C.ink } }, `all ${group.members.length - 1} other members`),
' — in person, or on a call where you recognise every voice. Everyone must see the same code.',
]),
@@ -187,16 +188,16 @@ export function GroupSasModal({ group, onConfirm, onCancel }) {
background: 'rgba(229,114,122,0.09)', border: '1px solid rgba(229,114,122,0.26)', color: '#f0a6ab',
},
}, failed
? 'Nothing was sent and nothing was verified. Close this and try again once everyone is connected.'
: 'If even one member reads a different code, someone is sitting between you. Cancel the group — do not confirm.'),
? t('group.errNothingSent')
: t('group.sasWarning')),
h('div', { key: 'actions', style: { display: 'flex', gap: '10px' } }, [
h('button', { key: 'c', onClick: onCancel, style: { ...btn(failed), flex: failed ? 2 : 1 } },
failed ? 'Close' : 'Cancel group'),
failed ? t('group.close') : t('group.cancel')),
!failed && h('button', {
key: 'ok', onClick: onConfirm, disabled: waiting,
style: { ...btn(true), flex: 2, opacity: waiting ? 0.4 : 1, cursor: waiting ? 'not-allowed' : 'pointer' },
}, [svg(ICON.shield, { key: 'i' }), 'Everyone sees this code']),
}, [svg(ICON.shield, { key: 'i' }), t('group.sasEveryone')]),
]),
]));
}
@@ -227,11 +228,11 @@ export function CreateGroupModal({ candidates, relayOnly, onCreate, onCancel })
return h('div', { style: overlay, role: 'dialog', 'aria-modal': 'true' },
h('div', { style: { ...card, maxWidth: '470px' } }, [
h('div', { key: 'h', style: { display: 'flex', flexDirection: 'column', gap: '6px' } }, [
h('span', { key: 'l', style: label }, 'New group'),
h('span', { key: 'l', style: label }, t('group.new')),
h('p', {
key: 'p',
style: { margin: 0, fontSize: '13.5px', lineHeight: 1.6, color: C.ink2 },
}, `Up to ${GROUP_LIMITS.MAX_MEMBERS} people, peer to peer. Everyone will compare one safety code before the group opens.`),
}, t('group.capacity', { max: GROUP_LIMITS.MAX_MEMBERS })),
]),
h('input', {
@@ -241,7 +242,7 @@ export function CreateGroupModal({ candidates, relayOnly, onCreate, onCancel })
// enforces. Counting characters here let a Cyrillic name through
// the dialog that the admin's roster signing then rejected.
onChange: (e) => setName(clampToBytes(e.target.value, GROUP_LIMITS.MAX_NAME_BYTES)),
placeholder: 'Group name',
placeholder: t('group.name'),
style: {
width: '100%', padding: '12px 14px', borderRadius: '10px', outline: 'none',
background: C.panel2, border: `1px solid ${C.line2}`, color: C.ink,
@@ -251,7 +252,7 @@ export function CreateGroupModal({ candidates, relayOnly, onCreate, onCancel })
h('div', { key: 'pick', style: { display: 'flex', flexDirection: 'column', gap: '9px' } }, [
h('div', { key: 'l', style: { display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' } }, [
h('span', { key: 'a', style: label }, 'Members'),
h('span', { key: 'a', style: label }, t('group.members')),
h('span', { key: 'b', style: { ...label, color: picked.length >= max ? C.warn : C.ink3 } },
`${picked.length} / ${max}`),
]),
@@ -262,7 +263,7 @@ export function CreateGroupModal({ candidates, relayOnly, onCreate, onCancel })
padding: '18px 14px', borderRadius: '10px', textAlign: 'center',
background: C.panel2, border: `1px dashed ${C.line2}`, color: C.ink3, fontSize: '13px', lineHeight: 1.55,
},
}, 'No verified chats yet. Open a 1:1 chat and compare its safety code first — a group is built out of connections you have already checked.')
}, t('group.noVerified'))
: h('div', {
key: 'list',
className: 'msc-scroll',
@@ -313,16 +314,16 @@ export function CreateGroupModal({ candidates, relayOnly, onCreate, onCancel })
margin: 0, padding: '11px 13px', borderRadius: '9px', fontSize: '12.5px', lineHeight: 1.55,
background: 'rgba(227,179,65,0.08)', border: '1px solid rgba(227,179,65,0.26)', color: '#e3b341',
},
}, 'Relay-only mode is off, so each member connects to you directly and learns your IP address — including members somebody else invited. Turn it on in network settings if that matters here.'),
}, t('group.relayOnlyOff')),
h('div', { key: 'actions', style: { display: 'flex', gap: '10px' } }, [
h('button', { key: 'c', onClick: onCancel, style: { ...btn(false), flex: 1 } }, 'Cancel'),
h('button', { key: 'c', onClick: onCancel, style: { ...btn(false), flex: 1 } }, t('group.cancelBtn')),
h('button', {
key: 'ok',
onClick: () => ready && onCreate({ name: name.trim(), sessionIds: picked }),
disabled: !ready,
style: { ...btn(true), flex: 2, opacity: ready ? 1 : 0.4, cursor: ready ? 'pointer' : 'not-allowed' },
}, 'Create group'),
}, t('group.create')),
]),
]));
}
@@ -343,12 +344,12 @@ export function GroupErrorModal({ message, onDismiss }) {
if (!message) return null;
return h('div', { style: overlay, role: 'alertdialog', 'aria-modal': 'true' },
h('div', { style: { ...card, maxWidth: '400px' } }, [
h('span', { key: 'l', style: label }, 'Group not created'),
h('span', { key: 'l', style: label }, t('group.errNotCreated')),
h('p', {
key: 'm',
style: { margin: 0, fontSize: '14px', lineHeight: 1.6, color: C.ink2 },
}, message),
h('button', { key: 'ok', onClick: onDismiss, style: btn(true) }, 'Close'),
h('button', { key: 'ok', onClick: onDismiss, style: btn(true) }, t('group.close')),
]));
}
@@ -374,13 +375,13 @@ export function AddMembersModal({ candidates, remaining, onAdd, onCancel }) {
return h('div', { style: overlay, role: 'dialog', 'aria-modal': 'true' },
h('div', { style: { ...card, maxWidth: '440px' } }, [
h('div', { key: 'h', style: { display: 'flex', flexDirection: 'column', gap: '6px' } }, [
h('span', { key: 'l', style: label }, 'Add members'),
h('span', { key: 'l', style: label }, t('group.addMembers')),
h('p', {
key: 'p',
style: { margin: 0, fontSize: '13.5px', lineHeight: 1.6, color: C.ink2 },
}, remaining > 0
? `Room for ${remaining} more. Everyone will compare a new group code once they join.`
: 'This group is full.'),
? t('group.roomFor', { remaining })
: t('group.errFull')),
]),
candidates.length === 0
@@ -390,7 +391,7 @@ export function AddMembersModal({ candidates, remaining, onAdd, onCancel }) {
padding: '18px 14px', borderRadius: '10px', textAlign: 'center',
background: C.panel2, border: `1px dashed ${C.line2}`, color: C.ink3, fontSize: '13px', lineHeight: 1.55,
},
}, 'No other verified chats to add. Open a 1:1 chat and compare its safety code first.')
}, t('group.noMoreToAdd'))
: h('div', {
key: 'list',
className: 'msc-scroll',
@@ -436,16 +437,16 @@ export function AddMembersModal({ candidates, remaining, onAdd, onCancel }) {
margin: 0, padding: '11px 13px', borderRadius: '9px', fontSize: '12.5px', lineHeight: 1.55,
background: C.panel2, border: `1px solid ${C.line}`, color: C.ink3,
},
}, 'The group keeps working until they accept. There is no history for them to catch up on — they will only see what is sent from now on.'),
}, t('group.inviteSentNote')),
h('div', { key: 'actions', style: { display: 'flex', gap: '10px' } }, [
h('button', { key: 'c', onClick: onCancel, style: { ...btn(false), flex: 1 } }, 'Cancel'),
h('button', { key: 'c', onClick: onCancel, style: { ...btn(false), flex: 1 } }, t('group.cancelBtn')),
h('button', {
key: 'ok',
onClick: () => picked.length && onAdd(picked),
disabled: picked.length === 0,
style: { ...btn(true), flex: 2, opacity: picked.length ? 1 : 0.4, cursor: picked.length ? 'pointer' : 'not-allowed' },
}, picked.length > 1 ? `Invite ${picked.length} people` : 'Invite'),
}, picked.length > 1 ? t('group.invitePeople', { count: picked.length }) : t('group.invite')),
]),
]));
}
@@ -459,7 +460,7 @@ export function GroupInviteModal({ invite, onAccept, onDecline }) {
return h('div', { style: overlay, role: 'dialog', 'aria-modal': 'true' },
h('div', { style: card }, [
h('div', { key: 'h', style: { display: 'flex', flexDirection: 'column', gap: '6px' } }, [
h('span', { key: 'l', style: label }, 'Group invitation'),
h('span', { key: 'l', style: label }, t('group.invitation')),
h('h3', { key: 't', style: { margin: 0, fontSize: '19px', fontWeight: 700, color: C.ink } }, invite.name),
]),
h('p', {
@@ -475,10 +476,10 @@ export function GroupInviteModal({ invite, onAccept, onDecline }) {
margin: 0, padding: '11px 13px', borderRadius: '9px', fontSize: '12.5px', lineHeight: 1.55,
background: C.panel2, border: `1px solid ${C.line}`, color: C.ink3,
},
}, 'Other members will learn your presence in this group. There is no message history to catch up on — a group starts empty.'),
}, t('group.joinNote')),
h('div', { key: 'actions', style: { display: 'flex', gap: '10px' } }, [
h('button', { key: 'd', onClick: onDecline, style: { ...btn(false), flex: 1 } }, 'Decline'),
h('button', { key: 'a', onClick: onAccept, style: { ...btn(true), flex: 2 } }, 'Join group'),
h('button', { key: 'd', onClick: onDecline, style: { ...btn(false), flex: 1 } }, t('group.decline')),
h('button', { key: 'a', onClick: onAccept, style: { ...btn(true), flex: 2 } }, t('group.join')),
]),
]));
}
@@ -503,9 +504,9 @@ function MemberStrip({ group, onRemove, isAdmin }) {
return h('span', {
key: m.fp,
title: self ? 'You'
: m.state === MEMBER_STATE.LINKED ? 'Direct peer-to-peer link'
: m.state === MEMBER_STATE.PENDING ? 'No direct link yet — messages are relayed by another member while one is being built'
: `${m.name} is offline and will not receive messages. They are still a member — removing them re-keys the group.`,
: m.state === MEMBER_STATE.LINKED ? t('group.directLink')
: m.state === MEMBER_STATE.PENDING ? t('group.noDirectLink')
: t('group.memberOffline', { name: m.name }),
style: {
flex: 'none', display: 'inline-flex', alignItems: 'center', gap: '6px',
padding: '5px 10px', borderRadius: '20px',
@@ -529,7 +530,7 @@ function MemberStrip({ group, onRemove, isAdmin }) {
(isAdmin && !self && onRemove) && h('button', {
key: 'x',
onClick: () => onRemove(m.fp),
title: `Remove ${m.name}`,
title: t('group.remove', { name: m.name }),
style: { border: 'none', background: 'transparent', color: C.ink3, cursor: 'pointer', display: 'grid', padding: 0 },
dangerouslySetInnerHTML: { __html: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>' },
}),
@@ -558,7 +559,7 @@ function Bubble({ msg }) {
!mine && h('span', {
key: 'who',
style: { fontSize: '11.5px', fontWeight: 600, color: C.accent, paddingLeft: '3px' },
}, msg.senderName || 'Member'),
}, msg.senderName || t('group.member')),
h('div', {
key: 'b',
style: {
@@ -624,18 +625,18 @@ export function GroupChatView({
style: { fontSize: '11.5px', color: degraded ? C.warn : C.ink3, display: 'flex', alignItems: 'center', gap: '5px' },
}, [
svg(ICON.users, { key: 'i', width: '13px', height: '13px' }),
`${group.members.length} members`,
ready && group.sasCode ? ` · code ${group.sasCode}` : '',
t('group.membersCount', { count: group.members.length }),
ready && group.sasCode ? t('group.codeSuffix', { code: group.sasCode }) : '',
]),
]),
(isAdmin && onAddMembers && group.members.length < GROUP_LIMITS.MAX_MEMBERS) && h('button', {
key: 'add', onClick: onAddMembers, title: 'Invite more members',
key: 'add', onClick: onAddMembers, title: t('group.inviteMore'),
style: { ...btn(false), padding: '8px 12px', fontSize: '12.5px' },
}, [svg(ICON.plus, { key: 'i' }), 'Add']),
}, [svg(ICON.plus, { key: 'i' }), t('group.add')]),
h('button', {
key: 'leave', onClick: onLeave, title: 'Leave this group',
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)' },
}, 'Leave'),
}, t('group.leave')),
]),
h(MemberStrip, { key: 'strip', group, onRemove: onRemoveMember, isAdmin }),
@@ -646,7 +647,7 @@ export function GroupChatView({
flex: 'none', padding: '8px 16px', fontSize: '12px', lineHeight: 1.5, color: C.warn,
background: 'rgba(227,179,65,0.08)', borderBottom: `1px solid ${C.line}`,
},
}, 'Some members have no direct link to you yet. Their messages travel through another member, who can see that you are talking but cannot read past the signature or change what you said. The group keeps trying to connect them directly.'),
}, t('group.relayNote')),
// transcript
h('div', {
@@ -662,8 +663,8 @@ export function GroupChatView({
key: 'empty',
style: { margin: 'auto', textAlign: 'center', color: C.ink3, fontSize: '13.5px', lineHeight: 1.6, maxWidth: '320px' },
}, ready
? 'Nothing here yet. Messages are signed by their sender and travel over each members own encrypted link.'
: 'Compare the group code with every member to open this group.')]
? t('group.emptyChat')
: t('group.sasCompare'))]
: group.messages.map((m) => h(Bubble, { key: m.id, msg: m }))),
// composer
@@ -679,7 +680,7 @@ export function GroupChatView({
key: 'in',
value: input,
onChange: (e) => setInput(e.target.value),
placeholder: ready ? `Message ${group.name}` : 'Confirm the group code first',
placeholder: ready ? t('group.message', { name: group.name }) : t('group.confirmFirst'),
disabled: !ready,
maxLength: GROUP_LIMITS.MAX_BODY_BYTES,
style: {
@@ -689,7 +690,7 @@ export function GroupChatView({
},
}),
h('button', {
key: 'send', type: 'submit', disabled: !ready || !input.trim(), title: 'Send',
key: 'send', type: 'submit', disabled: !ready || !input.trim(), title: t('group.send'),
style: {
...btn(true), flex: 'none', width: '44px', height: '44px', padding: 0, borderRadius: '11px',
opacity: (!ready || !input.trim()) ? 0.4 : 1,
+30 -24
View File
@@ -1,8 +1,10 @@
import { t } from '../../i18n/index.js';
// The version shown in the header comes from package.json rather than a literal,
// so a release cannot ship a header advertising the previous one. It was
// hard-coded and drifted. A named import lets the bundler inline just this field
// instead of embedding the whole manifest.
import { version as packageVersion } from '../../../package.json';
import { LanguageSwitcher } from './LanguageSwitcher.jsx';
const APP_VERSION = `v${packageVersion}`;
@@ -250,7 +252,7 @@ const EnhancedMinimalHeader = ({
// If no real test results and no existing security level, show progress message
if (!realTestResults && !realSecurityLevel) {
alert('Security verification in progress...\nPlease wait for real-time cryptographic verification to complete.');
alert(t('sec.verificationWait'));
return;
}
@@ -265,7 +267,7 @@ const EnhancedMinimalHeader = ({
color: 'gray',
verificationResults: {},
timestamp: Date.now(),
details: 'Security verification not available',
details: t('sec.verificationUnavailable'),
isRealData: false,
passedChecks: 0,
totalChecks: 0
@@ -277,7 +279,7 @@ const EnhancedMinimalHeader = ({
let message = `REAL-TIME SECURITY VERIFICATION\n\n`;
message += `Security Level: ${securityData.level} (${securityData.score}%)\n`;
message += `Verification Time: ${new Date(securityData.timestamp).toLocaleTimeString()}\n`;
message += `Data Source: ${securityData.isRealData ? 'Real Cryptographic Tests' : 'Simulated Data'}\n\n`;
message += `Data Source: ${securityData.isRealData ? t('sec.realTests') : t('sec.simulatedData')}\n\n`;
if (securityData.verificationResults) {
message += 'DETAILED CRYPTOGRAPHIC TESTS:\n';
@@ -290,7 +292,7 @@ const EnhancedMinimalHeader = ({
message += 'PASSED TESTS:\n';
passedTests.forEach(([key, result]) => {
const testName = key.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase());
message += ` ${testName}: ${result.details || 'Test passed'}\n`;
message += ` ${testName}: ${result.details || t('sec.testPassed')}\n`;
});
message += '\n';
}
@@ -299,7 +301,7 @@ const EnhancedMinimalHeader = ({
message += 'FAILED/UNAVAILABLE TESTS:\n';
failedTests.forEach(([key, result]) => {
const testName = key.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase());
message += ` ${testName}: ${result.details || 'Test failed or unavailable'}\n`;
message += ` ${testName}: ${result.details || t('sec.testFailed')}\n`;
});
message += '\n';
}
@@ -318,13 +320,13 @@ const EnhancedMinimalHeader = ({
'ECDSA Digital Signatures': securityData.verificationResults.verifyECDSASignatures?.passed || false,
'ECDH Key Exchange': securityData.verificationResults.verifyECDHKeyExchange?.passed || false,
'AES-GCM Encryption': securityData.verificationResults.verifyEncryption?.passed || false,
'Message Integrity (HMAC)': securityData.verificationResults.verifyMessageIntegrity?.passed || false,
'Perfect Forward Secrecy': securityData.verificationResults.verifyPerfectForwardSecrecy?.passed || false,
'Replay Protection': securityData.verificationResults.verifyReplayProtection?.passed || false,
[t('sec.messageIntegrity')]: securityData.verificationResults.verifyMessageIntegrity?.passed || false,
[t('sec.forwardSecrecy')]: securityData.verificationResults.verifyPerfectForwardSecrecy?.passed || false,
[t('sec.replayProtection')]: securityData.verificationResults.verifyReplayProtection?.passed || false,
'DTLS Fingerprint': securityData.verificationResults.verifyDTLSFingerprint?.passed || false,
'SAS Verification': securityData.verificationResults.verifySASVerification?.passed || false,
'Metadata Protection': securityData.verificationResults.verifyMetadataProtection?.passed || false,
'Traffic Obfuscation': securityData.verificationResults.verifyTrafficObfuscation?.passed || false
[t('sec.metadataProtection')]: securityData.verificationResults.verifyMetadataProtection?.passed || false,
[t('sec.trafficObfuscation')]: securityData.verificationResults.verifyTrafficObfuscation?.passed || false
};
Object.entries(features).forEach(([feature, isEnabled]) => {
@@ -344,7 +346,7 @@ const EnhancedMinimalHeader = ({
message += `✅ Traffic Obfuscation\n`;
}
message += `\n${securityData.details || 'Real cryptographic verification completed'}`;
message += `\n${securityData.details || t('sec.verificationDone')}`;
if (securityData.isRealData) {
message += '\n\n✅ This is REAL-TIME verification using actual cryptographic functions.';
@@ -411,49 +413,49 @@ const EnhancedMinimalHeader = ({
switch (status) {
case 'connected':
return {
text: 'Connected',
text: t('status.connected'),
className: 'status-connected',
badgeClass: 'bg-green-500/10 text-green-400 border-green-500/20'
};
case 'verifying':
return {
text: 'Verifying...',
text: t('status.verifying'),
className: 'status-verifying',
badgeClass: 'bg-purple-500/10 text-purple-400 border-purple-500/20'
};
case 'connecting':
return {
text: 'Connecting...',
text: t('status.connecting'),
className: 'status-connecting',
badgeClass: 'bg-blue-500/10 text-blue-400 border-blue-500/20'
};
case 'retrying':
return {
text: 'Retrying...',
text: t('status.retrying'),
className: 'status-connecting',
badgeClass: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20'
};
case 'failed':
return {
text: 'Error',
text: t('status.error'),
className: 'status-failed',
badgeClass: 'bg-red-500/10 text-red-400 border-red-500/20'
};
case 'reconnecting':
return {
text: 'Reconnecting...',
text: t('status.reconnecting'),
className: 'status-connecting',
badgeClass: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20'
};
case 'peer_disconnected':
return {
text: 'Peer disconnected',
text: t('status.peerDisconnected'),
className: 'status-failed',
badgeClass: 'bg-orange-500/10 text-orange-400 border-orange-500/20'
};
default:
return {
text: 'Not connected',
text: t('status.notConnected'),
className: 'status-disconnected',
badgeClass: 'bg-gray-500/10 text-gray-400 border-gray-500/20'
};
@@ -471,7 +473,7 @@ const EnhancedMinimalHeader = ({
const getSecurityIndicatorDetails = () => {
if (!displaySecurityLevel) {
return {
tooltip: 'Security verification in progress...',
tooltip: t('sec.verificationInProgress'),
isVerified: false,
dataSource: 'loading'
};
@@ -575,15 +577,19 @@ const EnhancedMinimalHeader = ({
React.createElement('span', { key: 'n', style: { fontSize: '16px', fontWeight: 800, letterSpacing: '-0.3px', color: '#e8e8eb' } }, 'SecureBit'),
React.createElement('span', { key: 'v', style: { fontFamily: MONO, fontSize: '10px', fontWeight: 500, color: '#56565e' } }, APP_VERSION)
]),
React.createElement('div', { key: 'r2', className: 'hidden sm:block', style: { fontSize: '11px', color: '#6b6b73', fontWeight: 500 } }, 'End-to-end encrypted')
React.createElement('div', { key: 'r2', className: 'hidden sm:block', style: { fontSize: '11px', color: '#6b6b73', fontWeight: 500 } }, t('hdr.tagline'))
])
]),
// Right: controls
React.createElement('div', { key: 'right', style: { display: 'flex', alignItems: 'center', gap: '9px' } }, [
// Landing only: switching locale reloads the document, which would
// tear down an live peer connection if offered inside the chat.
onLanding && React.createElement(LanguageSwitcher, { key: 'lang' }),
!onLanding && React.createElement('button', {
key: 'net', type: 'button',
onClick: () => window.dispatchEvent(new CustomEvent('securebit:open-network-settings')),
title: 'Advanced network settings (STUN/TURN)', 'aria-label': 'Advanced network settings',
title: t('hdr.netSettingsTitle'), 'aria-label': t('hdr.netSettings'),
className: 'sb-disconnect',
style: { display: 'grid', placeItems: 'center', width: '38px', height: '38px', borderRadius: '9px', border: '1px solid rgba(255,255,255,0.07)', background: 'rgba(255,255,255,0.02)', color: '#9a9aa2', cursor: 'pointer', transition: 'all .15s' }
}, React.createElement('i', { className: 'fas fa-network-wired', style: { fontSize: '13px' } })),
@@ -595,7 +601,7 @@ const EnhancedMinimalHeader = ({
style: { display: 'flex', alignItems: 'center', gap: '8px', padding: '7px 12px', borderRadius: '9px', border: '1px solid rgba(255,255,255,0.07)', background: 'rgba(255,255,255,0.02)', cursor: 'pointer' }
}, [
React.createElement('i', { key: 'i', className: 'fas fa-shield-halved', style: { fontSize: '13px', color: secColor } }),
React.createElement('span', { key: 'l', className: 'hidden sm:inline', style: { fontSize: '12.5px', fontWeight: 600, color: '#e8e8eb' } }, String(displaySecurityLevel.level)),
React.createElement('span', { key: 'l', className: 'hidden sm:inline', style: { fontSize: '12.5px', fontWeight: 600, color: '#e8e8eb' } }, (t(`secLevel.${displaySecurityLevel.level}`) === `secLevel.${displaySecurityLevel.level}` ? String(displaySecurityLevel.level) : t(`secLevel.${displaySecurityLevel.level}`))),
React.createElement('span', { key: 's', style: { fontFamily: MONO, fontSize: '11.5px', color: '#8a8a92' } }, displaySecurityLevel.score + '%')
]),
@@ -609,7 +615,7 @@ const EnhancedMinimalHeader = ({
style: { display: 'flex', alignItems: 'center', gap: '7px', padding: '8px 14px', borderRadius: '9px', border: '1px solid rgba(255,255,255,0.08)', background: 'transparent', color: '#9a9aa2', fontFamily: 'inherit', fontSize: '13px', fontWeight: 600, cursor: 'pointer', transition: 'all .15s' }
}, [
React.createElement('i', { key: 'i', className: 'fas fa-power-off', style: { fontSize: '12px' } }),
React.createElement('span', { key: 't', className: 'sb-hide-sm' }, 'Disconnect')
React.createElement('span', { key: 't', className: 'sb-hide-sm' }, t('hdr.disconnect'))
])
])
])
+19 -18
View File
@@ -1,3 +1,4 @@
import { t } from '../../i18n/index.js';
// Advanced network settings: lets a user supply their own STUN/TURN servers
// instead of the bundled public defaults, and toggle relay-only privacy mode.
// Free / power-user feature, hidden behind an explicit "Advanced" entry point.
@@ -25,13 +26,13 @@ const PLACEHOLDER = [
async function testIceServers(servers, timeoutMs = 6000) {
const found = { host: 0, srflx: 0, relay: 0 };
if (typeof RTCPeerConnection === 'undefined') {
return { ...found, error: 'WebRTC is not available in this browser' };
return { ...found, error: t('ice.errUnavailable') };
}
let pc;
try {
pc = new RTCPeerConnection({ iceServers: servers });
} catch (error) {
return { ...found, error: error.message || 'Invalid server configuration' };
return { ...found, error: error.message || t('ice.errInvalid') };
}
return new Promise((resolve) => {
@@ -154,9 +155,9 @@ const IceServerSettings = ({ isOpen, onClose, initial, hasSaved, onApply, onForg
// scrollable body
const body = [];
body.push(h('p', { key: 'intro', style: { margin: '0 0 18px', fontSize: '13.5px', lineHeight: 1.6, color: '#9a9aa2' } },
'SecureBit uses public STUN servers by default to negotiate the peer-to-peer link. Point it at your own STUN/TURN if you self-host.'));
body.push(radioCard(!useCustom, () => setUseCustom(false), 'Public servers (default)', 'Zero-config. Good for most users.'));
body.push(radioCard(useCustom, () => setUseCustom(true), 'My own STUN/TURN servers', `Up to ${ICE_LIMITS.MAX_SERVERS} servers.`, useCustom ? { marginBottom: '14px' } : null));
t('ice.intro')));
body.push(radioCard(!useCustom, () => setUseCustom(false), t('ice.publicTitle'), t('ice.publicDesc')));
body.push(radioCard(useCustom, () => setUseCustom(true), t('ice.customTitle'), t('ice.customDesc', { max: ICE_LIMITS.MAX_SERVERS }), useCustom ? { marginBottom: '14px' } : null));
if (useCustom) {
const custom = [];
@@ -181,8 +182,8 @@ const IceServerSettings = ({ isOpen, onClose, initial, hasSaved, onApply, onForg
custom.push(h('div', { key: 'note', style: { display: 'flex', alignItems: 'flex-start', gap: '9px', padding: '12px 13px', borderRadius: '11px', border: '1px solid rgba(62,207,142,0.18)', background: 'rgba(62,207,142,0.05)', marginBottom: '12px' } }, [
h('i', { key: 'i', className: 'fas fa-info-circle', style: { color: C_GREEN, fontSize: '13px', marginTop: '2px', flex: 'none' } }),
h('span', { key: 't', style: { fontSize: '12px', lineHeight: 1.55, color: '#a8b8ae' } }, [
'A TURN relay sees both peers IP and traffic timing — but never message contents, which stay end-to-end encrypted. Prefer ',
h('span', { key: 'm', style: { fontFamily: MONO, color: C_GREEN } }, 'turns:'), ' (TLS).'
t('ice.turnNote'),
h('span', { key: 'm', style: { fontFamily: MONO, color: C_GREEN } }, 'turns:'), t('ice.turnNoteTls')
])
]));
const testColor = testState === 'done' && testResult && !testResult.error ? C_GREEN : '#cfcfd4';
@@ -192,7 +193,7 @@ const IceServerSettings = ({ isOpen, onClose, initial, hasSaved, onApply, onForg
style: { display: 'inline-flex', alignItems: 'center', gap: '8px', padding: '10px 15px', borderRadius: '10px', border: `1px solid ${testState === 'done' && testResult && !testResult.error ? 'rgba(62,207,142,0.4)' : 'rgba(255,255,255,0.1)'}`, background: testState === 'done' && testResult && !testResult.error ? 'rgba(62,207,142,0.08)' : 'rgba(255,255,255,0.04)', color: testColor, fontFamily: 'inherit', fontSize: '13px', fontWeight: 600, cursor: (!canApply || testState === 'running') ? 'not-allowed' : 'pointer', opacity: (!canApply || testState === 'running') ? 0.6 : 1 }
}, [
h('i', { key: 'i', className: testState === 'running' ? 'fas fa-circle-notch' : 'fas fa-play-circle', style: testState === 'running' ? { animation: 'sbSpin 1s linear infinite' } : null }),
testState === 'running' ? 'Testing' : 'Test servers'
testState === 'running' ? t('ice.testing') : t('ice.test')
]),
(testState === 'done' && testResult) ? h('span', { key: 'res', style: { fontSize: '12px', color: testResult.error ? '#e5727a' : '#8a8a92' } },
testResult.error
@@ -205,26 +206,26 @@ const IceServerSettings = ({ isOpen, onClose, initial, hasSaved, onApply, onForg
body.push(h('div', { key: 'custom', style: { marginBottom: '16px' } }, custom));
}
body.push(toggleRow(relayOnly, () => setRelayOnly(!relayOnly), 'Relay-only mode',
'Routes all traffic through TURN so your IP is never exposed to the peer. Requires a TURN server.', C_GREEN, 'MAX PRIVACY'));
body.push(toggleRow(relayOnly, () => setRelayOnly(!relayOnly), t('ice.relayTitle'),
t('ice.relayDesc'), C_GREEN, t('ice.relayBadge')));
if (relayOnly && useCustom && !hasTurn) {
body.push(h('p', { key: 'relaywarn', style: { margin: '-4px 0 10px', fontSize: '12.5px', color: '#e3c84e' } },
'Relay-only is enabled but no TURN server is configured. The connection will not be able to start.'));
t('ice.relayWarning')));
}
body.push(toggleRow(persist, () => setPersist(!persist), 'Save on this device',
'Stored encrypted in this browser. Leave off to use only for this session.', C_ORANGE));
body.push(toggleRow(persist, () => setPersist(!persist), t('ice.persist'),
t('ice.persistDesc'), C_ORANGE));
// footer actions
const footerBtns = [];
if (hasSaved) {
footerBtns.push(h('button', { key: 'forget', type: 'button', onClick: handleForget,
style: { marginRight: 'auto', padding: '11px 18px', borderRadius: '11px', border: '1px solid rgba(229,114,122,0.3)', background: 'transparent', color: '#e5727a', fontFamily: 'inherit', fontSize: '13.5px', fontWeight: 600, cursor: 'pointer' } }, 'Forget saved'));
style: { marginRight: 'auto', padding: '11px 18px', borderRadius: '11px', border: '1px solid rgba(229,114,122,0.3)', background: 'transparent', color: '#e5727a', fontFamily: 'inherit', fontSize: '13.5px', fontWeight: 600, cursor: 'pointer' } }, t('ice.forget')));
}
footerBtns.push(h('button', { key: 'cancel', type: 'button', onClick: onClose,
style: { padding: '11px 18px', borderRadius: '11px', border: '1px solid rgba(255,255,255,0.1)', background: 'transparent', color: '#b3b3ba', fontFamily: 'inherit', fontSize: '13.5px', fontWeight: 600, cursor: 'pointer' } }, 'Cancel'));
style: { padding: '11px 18px', borderRadius: '11px', border: '1px solid rgba(255,255,255,0.1)', background: 'transparent', color: '#b3b3ba', fontFamily: 'inherit', fontSize: '13.5px', fontWeight: 600, cursor: 'pointer' } }, t('ice.cancel')));
footerBtns.push(h('button', { key: 'apply', type: 'button', onClick: handleApply, disabled: !canApply,
style: { display: 'inline-flex', alignItems: 'center', gap: '8px', padding: '11px 20px', borderRadius: '11px', border: 'none', background: C_ORANGE, color: '#1a0f04', fontFamily: 'inherit', fontSize: '13.5px', fontWeight: 700, cursor: canApply ? 'pointer' : 'not-allowed', opacity: canApply ? 1 : 0.5, boxShadow: '0 6px 18px rgba(240,137,42,0.28)' } }, [
h('i', { key: 'i', className: 'fas fa-check' }), 'Apply'
h('i', { key: 'i', className: 'fas fa-check' }), t('ice.apply')
]));
// Embedded mode (default for the new design): fill the connection screen's
@@ -240,8 +241,8 @@ const IceServerSettings = ({ isOpen, onClose, initial, hasSaved, onApply, onForg
h('div', { key: 'ic', style: { width: '38px', height: '38px', flex: 'none', display: 'grid', placeItems: 'center', borderRadius: '10px', background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.06)' } },
h('i', { className: 'fas fa-sliders-h', style: { color: '#cfcfd4', fontSize: '15px' } })),
h('div', { key: 'tx', style: { flex: 1, lineHeight: 1.25 } }, [
h('div', { key: 't', style: { fontSize: '16.5px', fontWeight: 800, letterSpacing: '-0.3px', color: '#f4f4f6' } }, 'Network settings'),
h('div', { key: 's', style: { fontSize: '12px', color: '#7b7b83' } }, 'Configured locally — never shared with your peer')
h('div', { key: 't', style: { fontSize: '16.5px', fontWeight: 800, letterSpacing: '-0.3px', color: '#f4f4f6' } }, t('ice.title')),
h('div', { key: 's', style: { fontSize: '12px', color: '#7b7b83' } }, t('ice.subtitle'))
]),
h('button', { key: 'x', type: 'button', onClick: onClose, style: { width: '32px', height: '32px', flex: 'none', display: 'grid', placeItems: 'center', borderRadius: '9px', border: 'none', background: 'rgba(255,255,255,0.04)', color: '#8a8a92', cursor: 'pointer' } },
h('i', { className: 'fas fa-times' }))
+118
View File
@@ -0,0 +1,118 @@
// Switching language is a navigation, not a state change: every locale is its own
// document at its own URL, generated at build time. So the entries are ordinary links
// they work before React has mounted, they open in a new tab, they can be copied and
// shared, and a crawler can follow them. A <button> that re-rendered the page in another
// language would leave every language sharing one URL, which is the one thing this
// arrangement exists to prevent.
//
// The list is a dropdown showing short codes: with nine languages, a row of full native
// names does not fit a phone header. Every link stays in the DOM whether the menu is open
// or shut hiding is visual only, so nothing depends on the menu having been opened.
//
// Because it navigates, the switcher belongs on the landing page only. Offering it during
// a call or a chat would invite someone to reload the document and drop the peer
// connection mid-conversation.
import { SUPPORTED_LOCALES, currentLocale, languageLinks, rememberLocale, t } from '../../i18n/index.js';
const LanguageSwitcher = () => {
if (SUPPORTED_LOCALES.length < 2) return null;
const [open, setOpen] = React.useState(false);
const rootRef = React.useRef(null);
const pathname = typeof window !== 'undefined' ? window.location.pathname : '/';
const links = languageLinks({ pathname, active: currentLocale() });
const active = links.find((l) => l.isCurrent) || links[0];
// Close on an outside press or Escape the two ways people expect a menu to go away.
React.useEffect(() => {
if (!open) return undefined;
const onDown = (e) => {
if (rootRef.current && !rootRef.current.contains(e.target)) setOpen(false);
};
const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
document.addEventListener('pointerdown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('pointerdown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [open]);
const trigger = React.createElement('button', {
key: 'trigger',
type: 'button',
onClick: () => setOpen((v) => !v),
'aria-haspopup': 'menu',
'aria-expanded': open ? 'true' : 'false',
'aria-label': t('language.label'),
style: {
display: 'flex', alignItems: 'center', gap: '6px',
padding: '7px 10px', borderRadius: '9px',
border: '1px solid rgba(255,255,255,0.07)',
background: open ? 'rgba(255,255,255,0.06)' : 'rgba(255,255,255,0.02)',
color: '#cfcfd4', font: 'inherit', fontSize: '12.5px', fontWeight: 600,
cursor: 'pointer', transition: 'background .15s, color .15s',
},
}, [
React.createElement('span', { key: 'c' }, active ? active.abbr : ''),
React.createElement('svg', {
key: 'v', width: 11, height: 11, viewBox: '0 0 24 24', fill: 'none',
stroke: 'currentColor', strokeWidth: 2.4, strokeLinecap: 'round', strokeLinejoin: 'round',
style: { transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .18s' },
dangerouslySetInnerHTML: { __html: '<path d="M6 9l6 6 6-6"/>' },
}),
]);
const menu = React.createElement('div', {
key: 'menu',
role: 'menu',
// Kept in the DOM when shut: the links stay followable and the menu costs nothing
// to reopen.
style: {
position: 'absolute', top: 'calc(100% + 6px)', right: 0, zIndex: 60,
display: open ? 'block' : 'none',
minWidth: '170px', padding: '5px', borderRadius: '11px',
border: '1px solid rgba(255,255,255,0.08)', background: '#161618',
boxShadow: '0 14px 34px rgba(0,0,0,0.45)',
},
}, links.map((link) => React.createElement('a', {
key: link.code,
href: link.href,
hrefLang: link.hrefLang,
lang: link.hrefLang,
role: 'menuitem',
// aria-current names the page you are on for a screen reader; the weight and
// contrast below say the same thing to everyone else.
'aria-current': link.isCurrent ? 'page' : undefined,
// Remember the choice, then let the browser navigate. The click is deliberately
// not intercepted the next locale is a different document.
onClick: () => rememberLocale(link.code),
style: {
display: 'flex', alignItems: 'center', gap: '10px',
padding: '8px 10px', borderRadius: '8px',
fontSize: '13px',
fontWeight: link.isCurrent ? 600 : 500,
color: link.isCurrent ? '#e8e8eb' : '#9a9aa2',
background: link.isCurrent ? 'rgba(255,255,255,0.06)' : 'transparent',
textDecoration: 'none', whiteSpace: 'nowrap',
},
}, [
React.createElement('span', {
key: 'a',
style: { fontSize: '11px', fontWeight: 700, letterSpacing: '0.4px', color: '#6b6b73', width: '22px' },
}, link.abbr),
React.createElement('span', { key: 'n' }, link.label),
])));
return React.createElement('nav', {
ref: rootRef,
'aria-label': t('language.label'),
style: { position: 'relative', display: 'inline-flex' },
}, [trigger, menu]);
};
window.LanguageSwitcher = LanguageSwitcher;
export { LanguageSwitcher };
+5 -4
View File
@@ -1,3 +1,4 @@
import { t } from '../../i18n/index.js';
const React = window.React;
const PasswordModal = ({ isOpen, onClose, onSubmit, action, password, setPassword }) => {
@@ -37,7 +38,7 @@ const PasswordModal = ({ isOpen, onClose, onSubmit, action, password, setPasswor
React.createElement('h3', {
key: 'title',
className: 'text-lg font-medium text-primary'
}, 'Password input')
}, t('pw.label'))
]),
React.createElement('form', {
key: 'form',
@@ -53,7 +54,7 @@ const PasswordModal = ({ isOpen, onClose, onSubmit, action, password, setPasswor
type: 'password',
value: password,
onChange: (e) => setPassword(e.target.value),
placeholder: 'Enter password...',
placeholder: t('pw.placeholder'),
className: 'w-full p-3 bg-gray-900/30 border border-gray-500/20 rounded-lg text-primary placeholder-gray-500 focus:border-purple-500/40 focus:outline-none transition-all',
autoFocus: true
}),
@@ -69,7 +70,7 @@ const PasswordModal = ({ isOpen, onClose, onSubmit, action, password, setPasswor
React.createElement('i', {
className: 'fas fa-unlock-alt mr-2'
}),
'Decrypt'
t('pw.decrypt')
]),
React.createElement('button', {
key: 'cancel',
@@ -80,7 +81,7 @@ const PasswordModal = ({ isOpen, onClose, onSubmit, action, password, setPasswor
React.createElement('i', {
className: 'fas fa-times mr-2'
}),
'Cancel'
t('pw.cancel')
])
])
])
+41 -38
View File
@@ -1,3 +1,5 @@
import { t, tList } from '../../i18n/index.js';
// "Development Roadmap" milestone timeline section.
// Translated from the Claude Design component (Roadmap.dc.html): a full-bleed
// dark band with a shipped-progress bar and an expandable, status-coded timeline.
@@ -19,40 +21,35 @@ function Roadmap() {
const SANS = "'Manrope', system-ui, -apple-system, sans-serif";
const DATA = [
{ v: "v1.0", title: "Start of Development", sub: "Idea, prototype, and infrastructure setup", status: "released", date: "Early 2025",
features: ["Concept and requirements formation", "Stack selection: WebRTC, P2P, cryptography", "First messaging prototypes", "Repository creation and CI", "Basic encryption architecture", "UX/UI design"] },
{ v: "v1.5", title: "Alpha Release", sub: "First public alpha: basic chat and key exchange", status: "released", date: "Spring 2025",
features: ["Basic P2P messaging via WebRTC", "Simple E2E encryption (demo scheme)", "Stable signaling and reconnection", "Minimal UX for testing", "Feedback collection from early testers"] },
{ v: "v2.0", title: "Security Hardened", sub: "Security strengthening and stable branch release", status: "released", date: "Summer 2025",
features: ["ECDH/ECDSA implementation in production", "Perfect Forward Secrecy and key rotation", "Improved authentication checks", "File encryption and large payload transfers", "Audit of basic cryptoprocesses"] },
{ v: "v3.0", title: "Scaling & Stability", sub: "Network scaling and stability improvements", status: "released", date: "Fall 2025",
features: ["Optimization of P2P connections and NAT traversal", "Reconnection mechanisms and message queues", "Reduced battery consumption on mobile", "Multi-device synchronization support", "Monitoring and logging tools for developers"] },
{ v: "v3.5", title: "Privacy-first Release", sub: "Focus on privacy: minimizing metadata", status: "released", date: "Winter 2025",
features: ["Metadata protection and fingerprint reduction", "Experiments with onion routing and DHT", "Options for anonymous connections", "Preparation for open code audit", "Improved user verification processes"] },
{ v: "v4.5", title: "Enhanced Security Edition", sub: "18-layer military-grade cryptography with complete ASN.1 validation", status: "released", date: "Late 2025",
features: ["ECDH + DTLS + SAS triple-layer security", "ECDH P-384 + AES-GCM 256-bit encryption", "DTLS fingerprint verification", "SAS (Short Authentication String) verification", "Perfect Forward Secrecy with key rotation", "Enhanced MITM attack prevention", "Complete ASN.1 DER validation", "OID and EC point verification", "SPKI structure validation", "P2P WebRTC architecture", "Metadata protection", "100% open source code"] },
{ v: "v5.0", title: "Desktop Edition", sub: "Native desktop apps for Windows, macOS, and Linux", status: "released", date: "Early 2026",
features: ["Windows desktop app (Tauri v2)", "macOS desktop app (Tauri v2)", "Linux AppImage support (Tauri v2)", "Real-time notifications", "Automatic reconnection", "Cross-device synchronization", "Improved UX/UI", "Support for files up to 100MB"] },
{ v: "v5.5", title: "Secure Voice & Calls", sub: "Encrypted voice messages, audio calls, and video calls", status: "released", date: "Early 2026",
features: ["End-to-end encrypted voice messages", "1:1 encrypted audio calls (WebRTC)", "1:1 encrypted video calls (WebRTC)", "Perfect Forward Secrecy for live media", "SRTP/DTLS-protected media streams", "In-call SAS verification", "Call notifications and auto-reconnection", "Low-latency P2P media"] },
{ v: "v6.0", title: "Group Communications", sub: "Group chats with preserved privacy", status: "current", date: "Now",
features: ["P2P group chats up to 8 participants", "Mesh delivery with signed relay fallback", "One group safety code, compared by everyone", "Commit-then-reveal ceremony against code grinding", "Per-group identity keys, ephemeral by design", "Signed membership with epoch ordering", "Signed messages, so a split transcript is provable", "No server, no shared group key, no history"] },
{ v: "v6.5", title: "Mobile Edition", sub: "Native mobile apps for iOS and Android", status: "dev", date: "Q2 2027",
features: ["iOS native app (Swift/SwiftUI)", "Android native app (Kotlin/Jetpack Compose)", "PWA support for mobile browsers", "Real-time push notifications", "Battery optimization", "Mobile-optimized UX/UI", "Offline message queuing", "Biometric authentication"] },
{ v: "v7.0", title: "Quantum-Resistant Edition", sub: "Protection against quantum computers", status: "planned", date: "Q4 2027",
features: ["Post-quantum cryptography CRYSTALS-Kyber", "SPHINCS+ digital signatures", "Hybrid scheme: classic + PQ", "Quantum-safe key exchange", "Updated hashing algorithms", "Migration of existing sessions", "Compatibility with v5.x", "Quantum-resistant protocols"] },
{ v: "v7.5", title: "Decentralized Network", sub: "Fully decentralized network", status: "research", date: "2028",
features: ["Node mesh network", "DHT for peer discovery", "Built-in onion routing", "Tokenomics and node incentives", "Governance via DAO", "Interoperability with other networks", "Cross-platform compatibility", "Self-healing network"] },
{ v: "v8.0", title: "AI Privacy Assistant", sub: "AI for privacy and security", status: "research", date: "2028+",
features: ["Local AI threat analysis", "Automatic MITM detection", "Adaptive cryptography", "Personalized security recommendations", "Zero-knowledge machine learning", "Private AI assistant", "Predictive security", "Autonomous attack protection"] }
];
{ v: "v1.0", k: 'r1', status: "released" },
{ v: "v1.5", k: 'r2', status: "released" },
{ v: "v2.0", k: 'r3', status: "released" },
{ v: "v3.0", k: 'r4', status: "released" },
{ v: "v3.5", k: 'r5', status: "released" },
{ v: "v4.5", k: 'r6', status: "released" },
{ v: "v5.0", k: 'r7', status: "released" },
{ v: "v5.5", k: 'r8', status: "released" },
{ v: "v6.0", k: 'r9', status: "current" },
{ v: "v6.5", k: 'r10', status: "dev" },
{ v: "v7.0", k: 'r11', status: "planned" },
{ v: "v7.5", k: 'r12', status: "research" },
{ v: "v8.0", k: 'r13', status: "research" }
].map((d) => ({
...d,
// Version tag and status are identifiers, not copy; everything a reader
// actually reads comes from the locale file.
title: t(`roadmap.${d.k}.title`),
sub: t(`roadmap.${d.k}.sub`),
date: t(`roadmap.${d.k}.date`),
features: tList(`roadmap.${d.k}.features`)
}));
const META = {
released: { word: "Released", color: "#3ecf8e", line: "rgba(62,207,142,0.32)" },
current: { word: "Current", color: "#f0892a", line: "rgba(240,137,42,0.32)" },
dev: { word: "In development", color: "#e3b341", line: "rgba(255,255,255,0.08)" },
planned: { word: "Planned", color: "#8a8a92", line: "rgba(255,255,255,0.08)" },
research: { word: "Research", color: "#6b6b73", line: "rgba(255,255,255,0.08)" }
released: { word: t('roadmap.status.released'), color: "#3ecf8e", line: "rgba(62,207,142,0.32)" },
current: { word: t('roadmap.status.current'), color: "#f0892a", line: "rgba(240,137,42,0.32)" },
dev: { word: t('roadmap.status.dev'), color: "#e3b341", line: "rgba(255,255,255,0.08)" },
planned: { word: t('roadmap.status.planned'), color: "#8a8a92", line: "rgba(255,255,255,0.08)" },
research: { word: t('roadmap.status.research'), color: "#6b6b73", line: "rgba(255,255,255,0.08)" }
};
const [open, setOpen] = React.useState({});
@@ -69,6 +66,12 @@ function Roadmap() {
const upcoming = total - shipped;
const shippedPct = (shipped / total * 100).toFixed(1) + '%';
// The shipped count is highlighted, so the sentence is split around it rather than
// interpolated whole. Passing only `total` leaves the {shipped} placeholder in the
// string, which is where the highlighted number goes a translation is free to put
// it anywhere in the sentence, and the highlight follows it there.
const [progressBefore, progressAfter] = t('roadmap.progress', { total }).split('{shipped}');
const renderNode = (status) => {
if (status === 'released') {
return (
@@ -107,18 +110,18 @@ function Roadmap() {
{/* header */}
<div style={{ marginBottom: '30px' }}>
<div style={{ fontFamily: MONO, fontSize: '11px', fontWeight: 600, color: '#6b6b73', textTransform: 'uppercase', letterSpacing: '1.6px', marginBottom: '13px' }}>Development Roadmap</div>
<h2 style={{ margin: '0 0 14px', fontSize: isMobile ? '27px' : '34px', fontWeight: 800, letterSpacing: '-1px', lineHeight: 1.08, color: '#f4f4f6' }}>The evolution of SecureBit</h2>
<p style={{ margin: 0, fontSize: '15.5px', lineHeight: 1.6, color: '#8a8a92', maxWidth: '660px' }}>From the first prototype to a quantum-resistant, decentralized network with complete ASN.1 validation at every layer.</p>
<div style={{ fontFamily: MONO, fontSize: '11px', fontWeight: 600, color: '#6b6b73', textTransform: 'uppercase', letterSpacing: '1.6px', marginBottom: '13px' }}>{t('roadmap.eyebrow')}</div>
<h2 style={{ margin: '0 0 14px', fontSize: isMobile ? '27px' : '34px', fontWeight: 800, letterSpacing: '-1px', lineHeight: 1.08, color: '#f4f4f6' }}>{t('roadmap.heading')}</h2>
<p style={{ margin: 0, fontSize: '15.5px', lineHeight: 1.6, color: '#8a8a92', maxWidth: '660px' }}>{t('roadmap.subheading')}</p>
</div>
{/* progress */}
<div style={{ display: 'flex', alignItems: 'center', gap: '18px', flexWrap: 'wrap', padding: '18px 22px', borderRadius: '14px', background: '#141416', border: '1px solid rgba(255,255,255,0.06)', marginBottom: '36px' }}>
<div style={{ fontFamily: MONO, fontSize: '12px', fontWeight: 600, color: '#e8e8eb', whiteSpace: 'nowrap' }}><span style={{ color: '#3ecf8e' }}>{shipped}</span> of {total} milestones shipped</div>
<div style={{ fontFamily: MONO, fontSize: '12px', fontWeight: 600, color: '#e8e8eb', whiteSpace: 'nowrap' }}>{progressBefore}<span style={{ color: '#3ecf8e' }}>{shipped}</span>{progressAfter}</div>
<div style={{ flex: '1 1 240px', minWidth: '200px', height: '8px', borderRadius: '99px', background: '#0c0c0e', border: '1px solid rgba(255,255,255,0.06)', overflow: 'hidden' }}>
<div style={{ height: '100%', width: shippedPct, background: 'linear-gradient(90deg, #3ecf8e, #f0892a)' }} />
</div>
<div style={{ fontFamily: MONO, fontSize: '11px', fontWeight: 600, color: '#6b6b73', textTransform: 'uppercase', letterSpacing: '0.8px', whiteSpace: 'nowrap' }}>{upcoming} on the way</div>
<div style={{ fontFamily: MONO, fontSize: '11px', fontWeight: 600, color: '#6b6b73', textTransform: 'uppercase', letterSpacing: '0.8px', whiteSpace: 'nowrap' }}>{t('roadmap.upcoming', { upcoming })}</div>
</div>
{/* timeline */}
@@ -161,7 +164,7 @@ function Roadmap() {
</div>
{opened && (
<div style={{ padding: '4px 22px 22px 22px', animation: 'rmExp .24s cubic-bezier(.2,.7,.3,1)' }}>
<div style={{ fontFamily: MONO, fontSize: '10px', fontWeight: 600, color: '#56565e', textTransform: 'uppercase', letterSpacing: '1.2px', marginBottom: '14px', paddingTop: '14px', borderTop: '1px solid rgba(255,255,255,0.05)' }}>Key features</div>
<div style={{ fontFamily: MONO, fontSize: '10px', fontWeight: 600, color: '#56565e', textTransform: 'uppercase', letterSpacing: '1.2px', marginBottom: '14px', paddingTop: '14px', borderTop: '1px solid rgba(255,255,255,0.05)' }}>{t('roadmap.keyFeatures')}</div>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: '11px 28px' }}>
{d.features.map((f, fi) => (
<div key={fi} style={{ display: 'flex', alignItems: 'flex-start', gap: '10px' }}>
+24 -22
View File
@@ -1,3 +1,5 @@
import { t, tList } from '../../i18n/index.js';
// "Why SecureBit is unique" interactive accordion section.
// Translated from the Claude Design component (Why Unique.dc.html) into the
// project's React.createElement style. Five horizontal panels; the active one
@@ -28,42 +30,42 @@ const UniqueFeatureSlider = () => {
const slides = [
{
num: '01',
title: ['Layered', 'encryption core'],
collapsed: 'Encryption core',
desc: 'ECDH P-384 key exchange, AES-256-GCM payloads, ECDSA signatures and full ASN.1 validation — composed into one hardened pipeline.',
tags: ['ECDH P-384', 'AES-256-GCM', 'ECDSA', 'ASN.1'],
title: [t('unique.s1.titleTop'), t('unique.s1.titleBottom')],
collapsed: t('unique.s1.collapsed'),
desc: t('unique.s1.desc'),
tags: tList('unique.s1.tags'),
icon: '<path d="M12 3l8 4v5c0 4.5-3.2 7.8-8 9-4.8-1.2-8-4.5-8-9V7l8-4z"/><path d="M9.2 12.2l2 2 3.6-3.8"/>'
},
{
num: '02',
title: ['Pure P2P', 'WebRTC'],
collapsed: 'Pure P2P WebRTC',
desc: 'Messages travel directly between devices over WebRTC. No relay holds your data — the server only helps two peers find each other.',
tags: ['DTLS 1.3', 'No relay'],
title: [t('unique.s2.titleTop'), t('unique.s2.titleBottom')],
collapsed: t('unique.s2.collapsed'),
desc: t('unique.s2.desc'),
tags: tList('unique.s2.tags'),
icon: '<circle cx="5.5" cy="12" r="2.5"/><circle cx="18.5" cy="6" r="2.5"/><circle cx="18.5" cy="18" r="2.5"/><path d="M7.8 10.8l8.4-3.6M7.8 13.2l8.4 3.6"/>'
},
{
num: '03',
title: ['Perfect', 'forward secrecy'],
collapsed: 'Forward secrecy',
desc: 'Session keys rotate continuously and are discarded after use, so a single compromised key can never unlock past conversations.',
tags: ['Ephemeral keys', 'Auto-rotate'],
title: [t('unique.s3.titleTop'), t('unique.s3.titleBottom')],
collapsed: t('unique.s3.collapsed'),
desc: t('unique.s3.desc'),
tags: tList('unique.s3.tags'),
icon: '<path d="M21 8a8.5 8.5 0 0 0-15.6-2.5M3 4v4h4"/><path d="M3 16a8.5 8.5 0 0 0 15.6 2.5M21 20v-4h-4"/>'
},
{
num: '04',
title: ['Traffic', 'obfuscation'],
collapsed: 'Traffic obfuscation',
desc: 'Packet sizes and timing are padded and randomized, hiding metadata patterns from anyone watching the wire.',
tags: ['Packet padding', 'Timing jitter'],
title: [t('unique.s4.titleTop'), t('unique.s4.titleBottom')],
collapsed: t('unique.s4.collapsed'),
desc: t('unique.s4.desc'),
tags: tList('unique.s4.tags'),
icon: '<path d="M3 7h4l3 10h4M14 7h3l3 0"/><path d="M17 4l3 3-3 3"/><path d="M3 17h4l2-6"/>'
},
{
num: '05',
title: ['Zero data', 'collection'],
collapsed: 'Zero data collection',
desc: 'No accounts, no logs, no message storage. There is nothing on a server to leak, subpoena, or sell.',
tags: ['No accounts', 'No logs'],
title: [t('unique.s5.titleTop'), t('unique.s5.titleBottom')],
collapsed: t('unique.s5.collapsed'),
desc: t('unique.s5.desc'),
tags: tList('unique.s5.tags'),
icon: '<path d="M9.9 5.1A9.6 9.6 0 0 1 12 5c5.5 0 9 5 9 7a11 11 0 0 1-2.2 3M6.3 7.3C3.6 8.9 2 11.2 2 12c0 1.4 3.5 7 10 7 1.6 0 3-.3 4.2-.8"/><path d="M9.9 9.9a3 3 0 0 0 4.2 4.2M3 3l18 18"/>'
}
];
@@ -198,11 +200,11 @@ const UniqueFeatureSlider = () => {
React.createElement('div', {
key: 'eyebrow',
style: { fontFamily: MONO, fontSize: '11px', fontWeight: 600, color: '#6b6b73', textTransform: 'uppercase', letterSpacing: '1.4px', marginBottom: '12px' }
}, 'What sets us apart'),
}, t('unique.eyebrow')),
React.createElement('h2', {
key: 'h2',
style: { margin: 0, fontSize: isMobile ? '28px' : '38px', fontWeight: 800, letterSpacing: '-1.1px', lineHeight: 1.05, color: '#f4f4f6' }
}, 'Why SecureBit is unique')
}, t('unique.heading'))
]),
React.createElement('div', { key: 'nav', style: { display: 'flex', alignItems: 'center', gap: '10px', flex: 'none' } }, [
navBtn('prev', () => go(-1), '<path d="M15 6l-6 6 6 6"/>'),