release: v4.9.0 — full redesign + reworked offline mode
Ground-up visual redesign across the entire surface (landing, connection setup, chat header, security verification report, file transfer, PWA install/update/offline dialogs). Offline reworked: store-and-forward queue (send while offline → queued, delivered on reconnect), WhatsApp-style per-message delivery status (sending/sent/delivered/not-sent) via delivery receipts, offline buffering for messages to an offline peer, and offline state no longer leaking into the connection indicator. Resilient chunked file transfer with retransmission and auto-save. README + screenshots added.
@@ -1,5 +1,31 @@
|
||||
# Changelog
|
||||
|
||||
## v4.9.0 — Full redesign + reworked offline mode
|
||||
|
||||
A ground-up visual redesign of the whole application surface — landing page, "Why unique" / partners / roadmap / community sections, connection setup, in-chat header, real-time security verification report, file transfer, and the PWA install / update / offline / install-guide dialogs.
|
||||
|
||||
Offline experience reworked with store-and-forward over the live P2P channel:
|
||||
|
||||
- Messages sent while offline are queued (single ✓) and transmitted on reconnect, preserving their original send time.
|
||||
- Messages to an offline peer stay at one check until that peer returns; the offline client holds them back and surfaces them on reconnect with a notice.
|
||||
- WhatsApp-style per-message delivery status (sending → sent → delivered, plus a "not sent" state) via an authenticated delivery-receipt control message.
|
||||
- Browser offline state no longer leaks into the P2P connection indicator.
|
||||
|
||||
Resilient file transfer: per-chunk segmented progress, receiver-driven retransmission of missing chunks with auto-resume after a connection blip, corrected receive rate limits, and automatic save on completion.
|
||||
|
||||
## v4.8.21 — Redesigned chat surface
|
||||
|
||||
A full visual refresh of the connected chat experience, ported from the SecureBit Chat design. No protocol, crypto or message-handling changes — only the presentation layer of the chat screen.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Message bubbles** redesigned: tighter dark surface (`#0f0f11` canvas, `#26262b` sent / `#161618` received), asymmetric corner radii, monospace timestamps, and a compact per-message status row showing **Encrypted** / **Decrypted** with a lock glyph.
|
||||
- **View-once** now uses a Telegram-style blurred cover with an SVG grain overlay and a centered "View once · tap to reveal" prompt; after reveal it shows a "Viewed once" tag and still burns after the sender-chosen window.
|
||||
- **Disappearing timers** render a live `mm:ss` countdown in the message meta in brand orange.
|
||||
- **Composer** rebuilt: inline `Send files` / `Code` / `View once` / `Timer` chips with active states, inline time-picker rows (view-once: Off/5s/10s/30s/1m, timer: Off/5s/30s/1m/1h/24h), an auto-growing message field, an "Encrypted on your device" affordance, a live character counter, and an orange send button.
|
||||
- **Handshake summary** card at the top of a verified chat (collapsible): transport / cipher / key-exchange / integrity facts plus the safety number (key fingerprint).
|
||||
- Fonts are mapped to the self-hosted **Inter** + system monospace stack rather than loading Google Fonts, preserving the look without an external request from a privacy-focused client.
|
||||
|
||||
## v4.8.20 — Secure chat tools: completed, fixed and polished
|
||||
|
||||
Completes the messaging controls introduced in v4.8.14 and fixes the bug that made them appear broken for recipients. All per-message options travel inside the encrypted message envelope (never in the sanitized text), so message content cannot spoof or corrupt them.
|
||||
|
||||
@@ -1,7 +1,37 @@
|
||||
# SecureBit.chat v4.8.20
|
||||
<div align="center">
|
||||
|
||||
<img src="logo/securebit-logo.png" width="120" alt="SecureBit.chat" />
|
||||
|
||||
# SecureBit.chat
|
||||
|
||||
**End-to-end encrypted, peer-to-peer chat — fully redesigned in v4.9.0**
|
||||
|
||||
ECDH P-384 · AES-256-GCM · DTLS · SAS verification · no accounts, no servers
|
||||
|
||||
</div>
|
||||
|
||||
SecureBit.chat is a browser-based peer-to-peer chat application built on WebRTC and Web Crypto APIs. It is designed for direct encrypted communication, explicit peer verification, and a small operational footprint without account registration or server-side message storage.
|
||||
|
||||
## Screenshots
|
||||
|
||||
**Open a secure channel**
|
||||
|
||||

|
||||
|
||||
**Encrypted conversation**
|
||||
|
||||

|
||||
|
||||
## What's new in v4.9.0 — full redesign
|
||||
|
||||
A ground-up visual redesign of the entire surface — landing page, connection setup, in-chat header, security verification report, file transfer, and the PWA install / update / offline dialogs — together with a reworked offline experience and WhatsApp-style delivery status.
|
||||
|
||||
**Offline mode, reworked.** SecureBit now does proper store-and-forward over the live P2P channel:
|
||||
|
||||
- Sending while offline shows a single ✓ and **queues** the message; it transmits automatically once you reconnect, keeping its original send time.
|
||||
- A message addressed to an **offline peer** stays at one check until they come back — their client holds it back (not shown, not acknowledged) and surfaces it on reconnect with a "Connection restored" notice.
|
||||
- **Delivery status on every message:** sending → ✓ sent → ✓✓ delivered (peer-acknowledged), with a clear "not sent" state — and offline status no longer leaks into the connection indicator.
|
||||
|
||||
## Security model
|
||||
|
||||
SecureBit.chat uses:
|
||||
|
||||
@@ -22,6 +22,6 @@ SecureBit.chat is intended for legitimate private communication, journalism, res
|
||||
|
||||
## Current release
|
||||
|
||||
- Product release: `v4.8.20`
|
||||
- Product release: `v4.8.21`
|
||||
- Protocol version: `4.1`
|
||||
- Last updated: May 17, 2026
|
||||
|
||||
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 760 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 4.9 KiB |
@@ -23,7 +23,7 @@
|
||||
|
||||
<!-- PWA Manifest -->
|
||||
<link rel="manifest" href="./manifest.json">
|
||||
<link rel="icon" type="image/x-icon" href="./logo/favicon.ico">
|
||||
<link rel="icon" type="image/x-icon" href="./logo/favicon.ico?v=1782247921955">
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
@@ -89,7 +89,7 @@
|
||||
<link rel="apple-touch-startup-image" media="screen and (device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="./logo/splash/splash_screens/8.3__iPad_Mini_portrait.png">
|
||||
|
||||
<!-- Apple Touch Icons -->
|
||||
<link rel="apple-touch-icon" href="./logo/icon-180x180.png">
|
||||
<link rel="apple-touch-icon" href="./logo/icon-180x180.png?v=1782247921955">
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="./logo/icon-57x57.png">
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="./logo/icon-60x60.png">
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="./logo/icon-72x72.png">
|
||||
@@ -98,7 +98,7 @@
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="./logo/icon-120x120.png">
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="./logo/icon-144x144.png">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="./logo/icon-152x152.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="./logo/icon-180x180.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="./logo/icon-180x180.png?v=1782247921955">
|
||||
|
||||
<!-- Microsoft Tiles -->
|
||||
<meta name="msapplication-TileColor" content="#ff6b35">
|
||||
@@ -113,7 +113,7 @@
|
||||
|
||||
|
||||
<!-- GitHub Pages SEO -->
|
||||
<meta name="description" content="SecureBit.chat v4.8.20 — P2P messenger with ECDH + DTLS + SAS security and 18-layer military-grade cryptography">
|
||||
<meta name="description" content="SecureBit.chat v4.9.0 — P2P messenger with ECDH + DTLS + SAS security and 18-layer military-grade cryptography">
|
||||
<meta name="keywords" content="P2P messenger, ECDH, DTLS, SAS, encryption, WebRTC, privacy, ASN.1 validation, military-grade security, 18-layer defense, MITM protection, PFS">
|
||||
<meta name="author" content="Volodymyr">
|
||||
<link rel="canonical" href="https://github.com/SecureBitChat/securebit-chat/">
|
||||
@@ -134,32 +134,32 @@
|
||||
<script src="config/ice-servers.js"></script>
|
||||
<script src="libs/react/react.production.min.js"></script>
|
||||
<script src="libs/react-dom/react-dom.production.min.js"></script>
|
||||
<link rel="stylesheet" href="assets/tailwind.css">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico">
|
||||
<link rel="stylesheet" href="assets/tailwind.css?v=1782247921955">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1782247921955">
|
||||
<link rel="stylesheet" href="/assets/fontawesome/css/all.min.css">
|
||||
<link rel="preload" href="/assets/fontawesome/webfonts/fa-solid-900.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="/assets/fontawesome/webfonts/fa-regular-400.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="/assets/fontawesome/webfonts/fa-brands-400.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="stylesheet" href="/assets/fonts/inter/inter.css">
|
||||
<link rel="stylesheet" href="src/styles/main.css">
|
||||
<link rel="stylesheet" href="src/styles/animations.css">
|
||||
<link rel="stylesheet" href="src/styles/components.css">
|
||||
<script src="src/scripts/fa-check.js"></script>
|
||||
<link rel="stylesheet" href="src/styles/main.css?v=1782247921955">
|
||||
<link rel="stylesheet" href="src/styles/animations.css?v=1782247921955">
|
||||
<link rel="stylesheet" href="src/styles/components.css?v=1782247921955">
|
||||
<script src="src/scripts/fa-check.js?v=1782247921955"></script>
|
||||
<!-- Update Manager - система принудительного обновления -->
|
||||
<script src="src/utils/updateManager.js"></script>
|
||||
<script type="module" src="src/components/UpdateChecker.jsx"></script>
|
||||
<script type="module" src="dist/qr-local.js?v=1781851206401"></script>
|
||||
<script type="module" src="src/components/QRScanner.js?v=1781851206401"></script>
|
||||
<script src="src/utils/updateManager.js?v=1782247921955"></script>
|
||||
<script type="module" src="src/components/UpdateChecker.jsx?v=1782247921955"></script>
|
||||
<script type="module" src="dist/qr-local.js?v=1782247921955"></script>
|
||||
<script type="module" src="src/components/QRScanner.js?v=1782247921955"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="dist/app-boot.js?v=1781851206401"></script>
|
||||
<script type="module" src="dist/app.js?v=1781851206401"></script>
|
||||
<script type="module" src="dist/app-boot.js?v=1782247921955"></script>
|
||||
<script type="module" src="dist/app.js?v=1782247921955"></script>
|
||||
|
||||
<script src="src/scripts/pwa-register.js"></script>
|
||||
<script src="./src/pwa/install-prompt.js" type="module"></script>
|
||||
<script src="./src/pwa/pwa-manager.js" type="module"></script>
|
||||
<script src="./src/scripts/pwa-offline-test.js"></script>
|
||||
<link rel="stylesheet" href="./src/styles/pwa.css">
|
||||
<script src="src/scripts/pwa-register.js?v=1782247921955"></script>
|
||||
<script src="./src/pwa/install-prompt.js?v=1782247921955" type="module"></script>
|
||||
<script src="./src/pwa/pwa-manager.js?v=1782247921955" type="module"></script>
|
||||
<script src="./src/scripts/pwa-offline-test.js?v=1782247921955"></script>
|
||||
<link rel="stylesheet" href="./src/styles/pwa.css?v=1782247921955">
|
||||
</body>
|
||||
</html>
|
||||
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg version="1.1" width="700" height="760" viewBox="276 240 700 760"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<!-- Brushed-metal silver for the upper "S" hook -->
|
||||
<linearGradient id="sbSilver" x1="0" y1="0" x2="0.35" y2="1">
|
||||
<stop offset="0" stop-color="#fdfdff"/>
|
||||
<stop offset="0.20" stop-color="#e7e7ec"/>
|
||||
<stop offset="0.46" stop-color="#c4c4cb"/>
|
||||
<stop offset="0.72" stop-color="#a4a4ac"/>
|
||||
<stop offset="1" stop-color="#86868d"/>
|
||||
</linearGradient>
|
||||
<!-- Glossy orange for the body, tail and dots -->
|
||||
<linearGradient id="sbOrange" x1="0" y1="0" x2="0.25" y2="1">
|
||||
<stop offset="0" stop-color="#ffb84d"/>
|
||||
<stop offset="0.27" stop-color="#ff9a33"/>
|
||||
<stop offset="0.58" stop-color="#fb7d16"/>
|
||||
<stop offset="1" stop-color="#db5d04"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Upper silver hook -->
|
||||
<path fill="url(#sbSilver)" fill-rule="nonzero"
|
||||
d="m 835.26446,352.56633 102.39051,-103.90366 -418.64101,1.00877 c 0,0 -171.69323,1.22309 -222.43455,167.96079 -52.34251,171.99925 77.67556,253.20215 77.67556,253.20215 0,0 35.54922,23.82856 79.77792,31.68982 15.73869,2.39372 79.16695,1.09532 79.16695,1.09532 54.47377,-10.08773 41.40629,-81.22528 -10.65516,-77.67557 C 492.06451,630.5166 372.5156,615.45079 386.86464,469.07968 415.02639,353.31661 520.52712,353.57511 520.52712,353.57511 Z"/>
|
||||
|
||||
<!-- Orange body + chat tail -->
|
||||
<path fill="url(#sbOrange)" fill-rule="nonzero"
|
||||
d="m 289.24744,881.29522 95.22696,-95.94027 369.13823,1.06997 C 873.15229,774.31964 863.51011,647.63259 863.51011,647.63259 846.7608,546.1216 749.51871,545.49427 749.51871,545.49427 l -232.01791,0.25219 c -37.80546,-8.91638 -37.85435,-49.4299 -37.85435,-49.4299 0,0 -1.56131,-38.07813 40.52401,-46.63785 l 260.62023,-0.7745 c 170.83788,24.60922 185.61432,187.63187 185.61432,187.63187 0,0 18.85523,117.07655 -90.63794,200.89054 l -0.62454,154.4184 -144.79052,-110.68137 z"/>
|
||||
|
||||
<!-- Two chat dots -->
|
||||
<path fill="url(#sbOrange)"
|
||||
d="m 658.38568,658.74237 a 27.462458,27.462458 0 0 1 -27.43073,27.46244 27.462458,27.462458 0 0 1 -27.49412,-27.39898 27.462458,27.462458 0 0 1 27.36719,-27.52575 27.462458,27.462458 0 0 1 27.55736,27.33536 z"/>
|
||||
<path fill="url(#sbOrange)"
|
||||
d="m 748.42871,659.07971 a 27.462458,27.462458 0 0 1 -27.43073,27.46244 27.462458,27.462458 0 0 1 -27.49412,-27.39898 27.462458,27.462458 0 0 1 27.36719,-27.52575 27.462458,27.462458 0 0 1 27.55736,27.33537 z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "SecureBit.chat v4.8.20 - ECDH + DTLS + SAS",
|
||||
"name": "SecureBit.chat v4.9.0 - ECDH + DTLS + SAS",
|
||||
"short_name": "SecureBit",
|
||||
"description": "P2P messenger with ECDH + DTLS + SAS security, military-grade cryptography and Lightning Network payments",
|
||||
"start_url": "./",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"version": "1781851206401",
|
||||
"buildVersion": "1781851206401",
|
||||
"appVersion": "4.8.20",
|
||||
"buildTime": "2026-06-19T06:40:06.446Z",
|
||||
"buildId": "1781851206401-cb72b9c",
|
||||
"gitHash": "cb72b9c",
|
||||
"version": "1782247921955",
|
||||
"buildVersion": "1782247921955",
|
||||
"appVersion": "4.9.0",
|
||||
"buildTime": "2026-06-23T20:52:01.997Z",
|
||||
"buildId": "1782247921955-b39f9ec",
|
||||
"gitHash": "b39f9ec",
|
||||
"generated": true,
|
||||
"generatedAt": "2026-06-19T06:40:06.447Z"
|
||||
"generatedAt": "2026-06-23T20:52:01.998Z"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "securebit-chat",
|
||||
"version": "4.8.20",
|
||||
"version": "4.9.0",
|
||||
"description": "Secure P2P Communication Application with End-to-End Encryption",
|
||||
"main": "index.html",
|
||||
"scripts": {
|
||||
|
||||
@@ -135,147 +135,98 @@ const UpdateChecker = ({ children, onUpdateAvailable, debug = false }) => {
|
||||
return version;
|
||||
};
|
||||
|
||||
const MONO = "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace";
|
||||
const SANS = "'Manrope', system-ui, -apple-system, sans-serif";
|
||||
|
||||
// Update modal — translated from the Claude Design component
|
||||
// (Update Notification.dc.html). Styling is inline so it tracks the design.
|
||||
return React.createElement(React.Fragment, null, [
|
||||
// Main application content
|
||||
children,
|
||||
|
||||
// Update modal window
|
||||
|
||||
updateState.showModal && React.createElement('div', {
|
||||
key: 'update-modal',
|
||||
className: 'fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 backdrop-blur-sm',
|
||||
style: {
|
||||
animation: 'fadeIn 0.3s ease-in-out'
|
||||
position: 'fixed', inset: 0, zIndex: 9999, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '24px', background: 'rgba(8,8,10,0.55)', backdropFilter: 'blur(3px)', WebkitBackdropFilter: 'blur(3px)',
|
||||
animation: 'unFade .3s ease', fontFamily: SANS
|
||||
}
|
||||
}, [
|
||||
React.createElement('style', { key: 'kf', dangerouslySetInnerHTML: { __html:
|
||||
'@keyframes unPop{from{opacity:0;transform:scale(.96) translateY(10px)}to{opacity:1;transform:scale(1) translateY(0)}}' +
|
||||
'@keyframes unFade{from{opacity:0}to{opacity:1}}' +
|
||||
'@keyframes unSpin{to{transform:rotate(360deg)}}'
|
||||
} }),
|
||||
|
||||
React.createElement('div', {
|
||||
key: 'modal-content',
|
||||
className: 'bg-white dark:bg-gray-800 rounded-2xl shadow-2xl p-8 max-w-md w-full mx-4 border border-gray-200 dark:border-gray-700',
|
||||
key: 'card',
|
||||
style: {
|
||||
animation: 'slideUp 0.3s ease-out'
|
||||
position: 'relative', width: '440px', maxWidth: 'calc(100vw - 48px)', borderRadius: '22px',
|
||||
background: '#121214', border: '1px solid rgba(255,255,255,0.08)', padding: '36px 32px 28px',
|
||||
textAlign: 'center', boxShadow: '0 30px 70px rgba(0,0,0,0.6)', animation: 'unPop .32s cubic-bezier(.2,.7,.3,1)'
|
||||
}
|
||||
}, [
|
||||
// Header
|
||||
// spinning update icon
|
||||
React.createElement('div', {
|
||||
key: 'header',
|
||||
className: 'text-center mb-6'
|
||||
}, [
|
||||
React.createElement('div', {
|
||||
key: 'icon',
|
||||
className: 'w-16 h-16 mx-auto mb-4 bg-blue-500/10 rounded-full flex items-center justify-center'
|
||||
}, [
|
||||
React.createElement('i', {
|
||||
key: 'icon-fa',
|
||||
className: 'fas fa-sync-alt text-blue-500 text-2xl animate-spin'
|
||||
})
|
||||
]),
|
||||
React.createElement('h2', {
|
||||
key: 'title',
|
||||
className: 'text-2xl font-bold text-gray-900 dark:text-white mb-2'
|
||||
}, 'Update Available'),
|
||||
React.createElement('p', {
|
||||
key: 'subtitle',
|
||||
className: 'text-gray-600 dark:text-gray-300 text-sm'
|
||||
}, 'A new version of the application has been detected')
|
||||
]),
|
||||
|
||||
// Version information
|
||||
key: 'icon',
|
||||
style: { display: 'inline-flex', width: '64px', height: '64px', borderRadius: '50%', alignItems: 'center', justifyContent: 'center', background: 'rgba(240,137,42,0.12)', border: '1px solid rgba(240,137,42,0.3)', marginBottom: '20px' }
|
||||
}, React.createElement('svg', {
|
||||
width: 28, height: 28, viewBox: '0 0 24 24', fill: 'none', stroke: '#f0892a', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round',
|
||||
style: { animation: 'unSpin 6s linear infinite' },
|
||||
dangerouslySetInnerHTML: { __html: '<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"/>' }
|
||||
})),
|
||||
|
||||
React.createElement('h2', { key: 'title', style: { margin: '0 0 9px', fontSize: '26px', fontWeight: 800, letterSpacing: '-0.7px', color: '#f4f4f6' } }, 'Update available'),
|
||||
React.createElement('p', { key: 'sub', style: { margin: '0 0 24px', fontSize: '14.5px', lineHeight: 1.55, color: '#9a9aa2' } }, 'A newer version of SecureBit has been detected.'),
|
||||
|
||||
// version comparison
|
||||
React.createElement('div', {
|
||||
key: 'version-info',
|
||||
className: 'bg-gray-50 dark:bg-gray-900 rounded-lg p-4 mb-6 space-y-2'
|
||||
key: 'vbox',
|
||||
style: { borderRadius: '14px', background: '#0c0c0e', border: '1px solid rgba(255,255,255,0.06)', padding: '16px 18px', marginBottom: '24px', textAlign: 'left' }
|
||||
}, [
|
||||
React.createElement('div', {
|
||||
key: 'current',
|
||||
className: 'flex justify-between items-center'
|
||||
}, [
|
||||
React.createElement('span', {
|
||||
key: 'current-label',
|
||||
className: 'text-sm text-gray-600 dark:text-gray-400'
|
||||
}, 'Current version:'),
|
||||
React.createElement('span', {
|
||||
key: 'current-value',
|
||||
className: 'text-sm font-mono text-gray-900 dark:text-white'
|
||||
}, formatVersion(updateState.currentVersion))
|
||||
React.createElement('div', { key: 'cur', style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '14px', padding: '5px 0' } }, [
|
||||
React.createElement('span', { key: 'l', style: { fontSize: '13.5px', fontWeight: 500, color: '#8a8a92' } }, 'Current version'),
|
||||
React.createElement('span', { key: 'v', style: { fontFamily: MONO, fontSize: '13px', fontWeight: 500, color: '#9a9aa2', whiteSpace: 'nowrap' } }, formatVersion(updateState.currentVersion))
|
||||
]),
|
||||
React.createElement('div', {
|
||||
key: 'new',
|
||||
className: 'flex justify-between items-center'
|
||||
}, [
|
||||
React.createElement('span', {
|
||||
key: 'new-label',
|
||||
className: 'text-sm text-gray-600 dark:text-gray-400'
|
||||
}, 'New version:'),
|
||||
React.createElement('span', {
|
||||
key: 'new-value',
|
||||
className: 'text-sm font-mono text-blue-600 dark:text-blue-400 font-semibold'
|
||||
}, formatVersion(updateState.newVersion))
|
||||
React.createElement('div', { key: 'sep', style: { height: '1px', background: 'rgba(255,255,255,0.05)', margin: '4px 0' } }),
|
||||
React.createElement('div', { key: 'new', style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '14px', padding: '5px 0' } }, [
|
||||
React.createElement('span', { key: 'l', style: { display: 'inline-flex', alignItems: 'center', gap: '8px', fontSize: '13.5px', fontWeight: 600, color: '#e8e8eb' } }, [
|
||||
React.createElement('span', { key: 'd', style: { width: '6px', height: '6px', borderRadius: '50%', background: '#f0892a' } }),
|
||||
'New version'
|
||||
]),
|
||||
React.createElement('span', { key: 'v', style: { fontFamily: MONO, fontSize: '13px', fontWeight: 700, color: '#f0892a', whiteSpace: 'nowrap' } }, formatVersion(updateState.newVersion))
|
||||
])
|
||||
]),
|
||||
|
||||
// Update progress
|
||||
updateState.isUpdating && React.createElement('div', {
|
||||
key: 'progress',
|
||||
className: 'mb-6'
|
||||
}, [
|
||||
React.createElement('div', {
|
||||
key: 'progress-bar',
|
||||
className: 'w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2.5 mb-2'
|
||||
}, [
|
||||
|
||||
// progress while updating, otherwise the action buttons
|
||||
updateState.isUpdating
|
||||
? React.createElement('div', { key: 'progress' }, [
|
||||
React.createElement('div', {
|
||||
key: 'progress-fill',
|
||||
className: 'bg-blue-500 h-2.5 rounded-full transition-all duration-300',
|
||||
style: {
|
||||
width: `${updateState.progress}%`
|
||||
}
|
||||
})
|
||||
]),
|
||||
React.createElement('p', {
|
||||
key: 'progress-text',
|
||||
className: 'text-center text-sm text-gray-600 dark:text-gray-400'
|
||||
}, `${updateState.progress}%`)
|
||||
]),
|
||||
|
||||
// Action buttons
|
||||
!updateState.isUpdating && React.createElement('div', {
|
||||
key: 'actions',
|
||||
className: 'flex gap-3'
|
||||
}, [
|
||||
React.createElement('button', {
|
||||
key: 'update-btn',
|
||||
onClick: handleForceUpdate,
|
||||
className: 'flex-1 bg-blue-500 hover:bg-blue-600 text-white font-semibold py-3 px-6 rounded-lg transition-colors duration-200 flex items-center justify-center gap-2',
|
||||
disabled: updateState.isUpdating
|
||||
}, [
|
||||
React.createElement('i', {
|
||||
key: 'update-icon',
|
||||
className: 'fas fa-download'
|
||||
}),
|
||||
React.createElement('span', {
|
||||
key: 'update-text'
|
||||
}, 'Update Now')
|
||||
]),
|
||||
React.createElement('button', {
|
||||
key: 'close-btn',
|
||||
onClick: handleCloseModal,
|
||||
className: 'px-4 py-3 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors duration-200',
|
||||
disabled: updateState.isUpdating
|
||||
}, [
|
||||
React.createElement('i', {
|
||||
key: 'close-icon',
|
||||
className: 'fas fa-times'
|
||||
})
|
||||
key: 'bar',
|
||||
style: { width: '100%', height: '8px', borderRadius: '99px', background: '#0c0c0e', border: '1px solid rgba(255,255,255,0.06)', overflow: 'hidden', marginBottom: '10px' }
|
||||
}, React.createElement('div', { key: 'fill', style: { height: '100%', width: `${updateState.progress}%`, background: 'linear-gradient(90deg,#3ecf8e,#f0892a)', transition: 'width .3s ease' } })),
|
||||
React.createElement('p', { key: 't', style: { margin: 0, fontFamily: MONO, fontSize: '12px', color: '#8a8a92' } }, `Updating… ${updateState.progress}%`)
|
||||
])
|
||||
: React.createElement('div', { key: 'actions', style: { display: 'flex', alignItems: 'center', gap: '12px' } }, [
|
||||
React.createElement('button', {
|
||||
key: 'update',
|
||||
onClick: handleForceUpdate,
|
||||
style: { flex: 1, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: '10px', padding: '15px 20px', borderRadius: '13px', border: 'none', background: '#f0892a', color: '#1a0f04', fontFamily: 'inherit', fontSize: '15.5px', fontWeight: 700, letterSpacing: '-0.2px', cursor: 'pointer', boxShadow: '0 8px 24px rgba(240,137,42,0.28)', transition: 'all .2s cubic-bezier(.2,.7,.3,1)' },
|
||||
onMouseEnter: (e) => { e.currentTarget.style.background = '#ff9637'; e.currentTarget.style.transform = 'translateY(-2px)'; },
|
||||
onMouseLeave: (e) => { e.currentTarget.style.background = '#f0892a'; e.currentTarget.style.transform = 'none'; }
|
||||
}, [
|
||||
React.createElement('svg', { key: 'i', width: 18, height: 18, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2.1, strokeLinecap: 'round', strokeLinejoin: 'round', dangerouslySetInnerHTML: { __html: '<path d="M12 3v11"/><path d="M7.5 10.5L12 15l4.5-4.5"/><path d="M5 20h14"/>' } }),
|
||||
'Update now'
|
||||
]),
|
||||
React.createElement('button', {
|
||||
key: 'later',
|
||||
onClick: handleCloseModal,
|
||||
title: 'Later',
|
||||
style: { flex: 'none', width: '50px', height: '50px', borderRadius: '13px', display: 'grid', placeItems: 'center', border: '1px solid rgba(255,255,255,0.1)', background: 'rgba(255,255,255,0.025)', color: '#9a9aa2', cursor: 'pointer', transition: 'all .18s cubic-bezier(.2,.7,.3,1)' },
|
||||
onMouseEnter: (e) => { e.currentTarget.style.color = '#e5727a'; e.currentTarget.style.borderColor = 'rgba(229,114,122,0.4)'; },
|
||||
onMouseLeave: (e) => { e.currentTarget.style.color = '#9a9aa2'; e.currentTarget.style.borderColor = 'rgba(255,255,255,0.1)'; }
|
||||
}, React.createElement('svg', { width: 17, height: 17, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2.1, strokeLinecap: 'round', strokeLinejoin: 'round', dangerouslySetInnerHTML: { __html: '<path d="M6 6l12 12M18 6L6 18"/>' } }))
|
||||
])
|
||||
]),
|
||||
|
||||
// Update indicator
|
||||
updateState.isUpdating && React.createElement('div', {
|
||||
key: 'updating',
|
||||
className: 'text-center'
|
||||
}, [
|
||||
React.createElement('p', {
|
||||
key: 'updating-text',
|
||||
className: 'text-sm text-gray-600 dark:text-gray-400'
|
||||
}, 'Update in progress...')
|
||||
])
|
||||
])
|
||||
])
|
||||
]);
|
||||
|
||||
@@ -1,86 +1,175 @@
|
||||
// "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
|
||||
// cards plus a dashed "Become a partner" invite card.
|
||||
const BecomePartner = () => {
|
||||
const partners = [
|
||||
{ id: 'aegis', name: 'Aegis', logo: 'logo/aegis.png', isColor: true, url: 'https://aegis-investment.com/' },
|
||||
{ id: 'furi', name: 'Furi Labs', logo: 'logo/furi.png', isColor: true, url: 'https://furilabs.com/' }
|
||||
];
|
||||
const [isMobile, setIsMobile] = React.useState(
|
||||
typeof window !== 'undefined' && window.matchMedia('(max-width:767px)').matches
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const mq = window.matchMedia('(max-width:767px)');
|
||||
const onChange = () => setIsMobile(mq.matches);
|
||||
mq.addEventListener ? mq.addEventListener('change', onChange) : mq.addListener(onChange);
|
||||
return () => {
|
||||
mq.removeEventListener ? mq.removeEventListener('change', onChange) : mq.removeListener(onChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const ACCENT = '#f0892a';
|
||||
const MONO = "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace";
|
||||
const SANS = "'Manrope', system-ui, -apple-system, sans-serif";
|
||||
|
||||
const formUrl = 'https://docs.google.com/forms/d/e/1FAIpQLSc9ijV9PCoyXkus6vEx1OWwvwAsLq8fKS6-H5BmX-c-bvia6w/viewform?usp=dialog';
|
||||
|
||||
return React.createElement('div', { className: "mt-20 px-6" }, [
|
||||
// Header "Trusted by our partners"
|
||||
React.createElement('div', { key: 'header', className: "text-center max-w-3xl mx-auto mb-8" }, [
|
||||
React.createElement('h3', { key: 'title', className: "text-3xl font-bold text-primary mb-3" }, 'Trusted by our partners')
|
||||
const partners = [
|
||||
{
|
||||
id: 'aegis',
|
||||
name: 'Aegis Investment',
|
||||
logo: 'logo/aegis.png',
|
||||
logoHeight: '42px',
|
||||
url: 'https://aegis-investment.com/',
|
||||
desc: 'Capital partner securing confidential financial communications across its portfolio.',
|
||||
role: 'Strategic backer',
|
||||
delay: '.5s'
|
||||
},
|
||||
{
|
||||
id: 'furi',
|
||||
name: 'FuriLabs',
|
||||
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',
|
||||
delay: '.56s'
|
||||
}
|
||||
];
|
||||
|
||||
const svg = (inner, size, stroke, sw) =>
|
||||
React.createElement('svg', {
|
||||
width: size, height: size, viewBox: '0 0 24 24', fill: 'none',
|
||||
stroke, strokeWidth: sw, strokeLinecap: 'round', strokeLinejoin: 'round',
|
||||
dangerouslySetInnerHTML: { __html: inner }
|
||||
});
|
||||
|
||||
const roleTag = (role) => React.createElement('span', {
|
||||
key: 'role',
|
||||
style: { fontFamily: MONO, fontSize: '10.5px', fontWeight: 600, color: '#6b6b73', textTransform: 'uppercase', letterSpacing: '1.2px', padding: '6px 11px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.07)', background: 'rgba(255,255,255,0.025)', whiteSpace: 'nowrap' }
|
||||
}, role);
|
||||
|
||||
const partnerCard = (p) => React.createElement('a', {
|
||||
key: p.id,
|
||||
href: p.url,
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
style: {
|
||||
flex: '1 1 320px', minWidth: isMobile ? 'auto' : '300px',
|
||||
borderRadius: '18px', background: '#141416', border: '1px solid rgba(255,255,255,0.06)',
|
||||
padding: '30px 30px 26px', display: 'flex', flexDirection: 'column',
|
||||
textDecoration: 'none', color: 'inherit',
|
||||
transition: 'transform .28s cubic-bezier(.2,.7,.3,1), border-color .28s cubic-bezier(.2,.7,.3,1)',
|
||||
animation: `ptUp ${p.delay} cubic-bezier(.2,.7,.3,1)`
|
||||
},
|
||||
onMouseEnter: (e) => { e.currentTarget.style.transform = 'translateY(-4px)'; e.currentTarget.style.borderColor = 'rgba(255,255,255,0.13)'; },
|
||||
onMouseLeave: (e) => { e.currentTarget.style.transform = 'none'; e.currentTarget.style.borderColor = 'rgba(255,255,255,0.06)'; }
|
||||
}, [
|
||||
React.createElement('div', { key: 'logo', style: { display: 'flex', alignItems: 'center', marginBottom: '30px', height: '54px' } },
|
||||
React.createElement('img', {
|
||||
src: p.logo, alt: p.name,
|
||||
style: { height: p.logoHeight, width: 'auto', maxWidth: '190px', objectFit: 'contain', display: 'block' }
|
||||
})
|
||||
),
|
||||
React.createElement('h3', { key: 'name', style: { margin: '0 0 9px', fontSize: '21px', fontWeight: 800, letterSpacing: '-0.4px', color: '#f4f4f6' } }, p.name),
|
||||
React.createElement('p', { key: 'desc', style: { margin: '0 0 22px', fontSize: '14.5px', lineHeight: 1.6, color: '#9a9aa2' } }, p.desc),
|
||||
React.createElement('div', { key: 'foot', style: { marginTop: 'auto', paddingTop: '6px', display: 'flex', alignItems: 'center', gap: '12px' } }, [
|
||||
roleTag(p.role)
|
||||
])
|
||||
]);
|
||||
|
||||
const inviteCard = React.createElement('a', {
|
||||
key: 'invite',
|
||||
href: formUrl,
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
style: {
|
||||
flex: '1 1 320px', minWidth: isMobile ? 'auto' : '300px',
|
||||
borderRadius: '18px', background: '#111113', border: '1px dashed rgba(255,255,255,0.12)',
|
||||
padding: '30px', display: 'flex', flexDirection: 'column', justifyContent: 'space-between',
|
||||
textDecoration: 'none', color: 'inherit',
|
||||
transition: 'border-color .28s cubic-bezier(.2,.7,.3,1)',
|
||||
animation: 'ptUp .62s cubic-bezier(.2,.7,.3,1)'
|
||||
},
|
||||
onMouseEnter: (e) => { e.currentTarget.style.borderColor = 'rgba(240,137,42,0.4)'; },
|
||||
onMouseLeave: (e) => { e.currentTarget.style.borderColor = 'rgba(255,255,255,0.12)'; }
|
||||
}, [
|
||||
React.createElement('div', { key: 'top' }, [
|
||||
React.createElement('div', {
|
||||
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('span', {
|
||||
key: 'btn',
|
||||
style: {
|
||||
marginTop: '26px', width: '100%', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: '10px',
|
||||
padding: '15px 20px', borderRadius: '12px', border: 'none', background: ACCENT, color: '#1a0f04',
|
||||
fontFamily: SANS, fontSize: '15px', fontWeight: 700, cursor: 'pointer',
|
||||
boxShadow: '0 8px 24px rgba(240,137,42,0.28)', boxSizing: 'border-box',
|
||||
transition: 'background .2s cubic-bezier(.2,.7,.3,1), transform .2s cubic-bezier(.2,.7,.3,1)'
|
||||
}
|
||||
}, [
|
||||
'Start a conversation',
|
||||
svg('<path d="M5 12h14M13 6l6 6-6 6"/>', 17, 'currentColor', 2.2)
|
||||
])
|
||||
]);
|
||||
|
||||
const inner = React.createElement('div', {
|
||||
key: 'inner',
|
||||
style: { maxWidth: '1240px', margin: '0 auto', padding: isMobile ? '0 18px' : '0 40px' }
|
||||
}, [
|
||||
// Header
|
||||
React.createElement('div', { key: 'head', style: { marginBottom: '44px' } }, [
|
||||
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.")
|
||||
])
|
||||
]),
|
||||
|
||||
// First divider line with fade
|
||||
// Cards
|
||||
React.createElement('div', {
|
||||
key: 'divider-1',
|
||||
className: "h-px w-full max-w-3xl mx-auto mb-8 bg-gradient-to-r from-transparent via-zinc-700 to-transparent"
|
||||
}),
|
||||
|
||||
// Partner Logos
|
||||
React.createElement('div', {
|
||||
key: 'partners-row',
|
||||
className: "flex justify-center items-center flex-wrap gap-12 mb-8"
|
||||
},
|
||||
partners.map(partner =>
|
||||
React.createElement('a', {
|
||||
key: partner.id,
|
||||
href: partner.url,
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
className: "flex items-center justify-center cursor-pointer hover:opacity-100 transition-opacity duration-300"
|
||||
}, [
|
||||
React.createElement('img', {
|
||||
key: 'logo',
|
||||
src: partner.logo,
|
||||
alt: partner.name,
|
||||
className: "h-12 sm:h-16 opacity-80 hover:opacity-100 transition-opacity duration-300",
|
||||
style: partner.isColor ? {
|
||||
filter: 'grayscale(100%) brightness(1.2) contrast(1.1)',
|
||||
WebkitFilter: 'grayscale(100%) brightness(1.2) contrast(1.1)'
|
||||
} : {}
|
||||
})
|
||||
])
|
||||
)
|
||||
),
|
||||
|
||||
// Second divider line with fade
|
||||
React.createElement('div', {
|
||||
key: 'divider-2',
|
||||
className: "h-px w-full max-w-3xl mx-auto mb-8 bg-gradient-to-r from-transparent via-zinc-700 to-transparent"
|
||||
}),
|
||||
|
||||
// Section with subtitle and text
|
||||
React.createElement('div', { key: 'cta-section', className: "text-center max-w-3xl mx-auto" }, [
|
||||
React.createElement('h4', {
|
||||
key: 'subtitle',
|
||||
className: "text-base font-semibold text-primary mb-4"
|
||||
}, 'Technology & Community Partners'),
|
||||
|
||||
React.createElement('p', {
|
||||
key: 'description',
|
||||
className: "text-secondary text-sm mb-6"
|
||||
}, 'Interested in partnering with us?'),
|
||||
|
||||
// CTA Button with 3D glass effect
|
||||
React.createElement('div', {
|
||||
key: 'button-wrapper',
|
||||
className: "button-container flex justify-center"
|
||||
}, [
|
||||
React.createElement('a', {
|
||||
key: 'button-link',
|
||||
href: formUrl,
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
className: "button"
|
||||
}, [
|
||||
React.createElement('span', { key: 'text' }, 'Become a Partner')
|
||||
])
|
||||
])
|
||||
key: 'cards',
|
||||
style: { display: 'flex', gap: '18px', alignItems: 'stretch', flexWrap: 'wrap' }
|
||||
}, [
|
||||
...partners.map(partnerCard),
|
||||
inviteCard
|
||||
])
|
||||
]);
|
||||
|
||||
return React.createElement('section', {
|
||||
style: {
|
||||
width: '100%', color: '#e8e8eb', fontFamily: SANS,
|
||||
padding: isMobile ? '48px 0' : '72px 0',
|
||||
background: 'radial-gradient(1100px 640px at 50% -6%, rgba(240,137,42,0.055), transparent 62%), #0f0f11'
|
||||
}
|
||||
}, [
|
||||
React.createElement('style', { key: 'kf', dangerouslySetInnerHTML: { __html: '@keyframes ptUp{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}' } }),
|
||||
inner
|
||||
]);
|
||||
};
|
||||
|
||||
window.BecomePartner = BecomePartner;
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
// "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.
|
||||
const CommunityCTA = () => {
|
||||
const [isMobile, setIsMobile] = React.useState(
|
||||
typeof window !== 'undefined' && window.matchMedia('(max-width:767px)').matches
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const mq = window.matchMedia('(max-width:767px)');
|
||||
const onChange = () => setIsMobile(mq.matches);
|
||||
mq.addEventListener ? mq.addEventListener('change', onChange) : mq.addListener(onChange);
|
||||
return () => {
|
||||
mq.removeEventListener ? mq.removeEventListener('change', onChange) : mq.removeListener(onChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
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/';
|
||||
const feedbackUrl = 'mailto:lockbitchat@tutanota.com';
|
||||
|
||||
const githubBtn = React.createElement('a', {
|
||||
key: 'gh',
|
||||
href: githubUrl,
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
style: {
|
||||
display: 'inline-flex', alignItems: 'center', gap: '11px', padding: '15px 26px',
|
||||
borderRadius: '13px', background: ACCENT, color: '#1a0f04', textDecoration: 'none',
|
||||
fontSize: '15.5px', fontWeight: 700, letterSpacing: '-0.2px',
|
||||
boxShadow: '0 8px 24px rgba(240,137,42,0.28)', whiteSpace: 'nowrap',
|
||||
transition: 'all .2s cubic-bezier(.2,.7,.3,1)'
|
||||
},
|
||||
onMouseEnter: (e) => { e.currentTarget.style.background = '#ff9637'; e.currentTarget.style.transform = 'translateY(-2px)'; },
|
||||
onMouseLeave: (e) => { e.currentTarget.style.background = ACCENT; e.currentTarget.style.transform = 'none'; }
|
||||
}, [
|
||||
React.createElement('svg', {
|
||||
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'
|
||||
]);
|
||||
|
||||
const feedbackBtn = React.createElement('a', {
|
||||
key: 'fb',
|
||||
href: feedbackUrl,
|
||||
rel: 'noopener noreferrer',
|
||||
style: {
|
||||
display: 'inline-flex', alignItems: 'center', gap: '11px', padding: '15px 26px',
|
||||
borderRadius: '13px', background: 'rgba(255,255,255,0.03)', color: '#e8e8eb', textDecoration: 'none',
|
||||
fontSize: '15.5px', fontWeight: 700, letterSpacing: '-0.2px',
|
||||
border: '1px solid rgba(255,255,255,0.1)', whiteSpace: 'nowrap',
|
||||
transition: 'all .2s cubic-bezier(.2,.7,.3,1)'
|
||||
},
|
||||
onMouseEnter: (e) => { e.currentTarget.style.borderColor = 'rgba(255,255,255,0.24)'; e.currentTarget.style.background = 'rgba(255,255,255,0.06)'; },
|
||||
onMouseLeave: (e) => { e.currentTarget.style.borderColor = 'rgba(255,255,255,0.1)'; e.currentTarget.style.background = 'rgba(255,255,255,0.03)'; }
|
||||
}, [
|
||||
React.createElement('svg', {
|
||||
key: 'i', width: 20, height: 20, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor',
|
||||
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
|
||||
]);
|
||||
|
||||
const card = React.createElement('div', {
|
||||
key: 'card',
|
||||
style: {
|
||||
position: 'relative', overflow: 'hidden', maxWidth: '860px', width: '100%',
|
||||
borderRadius: '24px',
|
||||
background: 'radial-gradient(700px 360px at 50% 0%, rgba(240,137,42,0.1), transparent 65%), #121214',
|
||||
border: '1px solid rgba(255,255,255,0.07)',
|
||||
padding: isMobile ? '40px 24px 36px' : '56px 56px 48px',
|
||||
textAlign: 'center', boxShadow: '0 24px 60px rgba(0,0,0,0.4)'
|
||||
}
|
||||
}, [
|
||||
// hairline accent
|
||||
React.createElement('div', {
|
||||
key: 'hairline',
|
||||
style: { position: 'absolute', top: 0, left: '50%', transform: 'translateX(-50%)', width: '180px', height: '1px', background: 'linear-gradient(90deg, transparent, rgba(240,137,42,0.7), transparent)' }
|
||||
}),
|
||||
// brand mark (same SVG as the header — no border or background)
|
||||
React.createElement('img', {
|
||||
key: 'icon',
|
||||
src: '/logo/securebit-mark.svg',
|
||||
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'),
|
||||
// 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 end‑to‑end.'),
|
||||
// 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')])
|
||||
]);
|
||||
|
||||
return React.createElement('section', {
|
||||
style: {
|
||||
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: '#0f0f11', fontFamily: SANS,
|
||||
padding: isMobile ? '48px 18px' : '64px 48px'
|
||||
}
|
||||
}, [
|
||||
React.createElement('style', { key: 'kf', dangerouslySetInnerHTML: { __html: '@keyframes ccUp{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}' } }),
|
||||
card
|
||||
]);
|
||||
};
|
||||
|
||||
window.CommunityCTA = CommunityCTA;
|
||||
@@ -1,5 +1,5 @@
|
||||
// File Transfer Component for Chat Interface - Fixed Version
|
||||
const FileTransferComponent = ({ webrtcManager, isConnected, pendingIncomingFiles = [], onIncomingDecision }) => {
|
||||
const FileTransferComponent = ({ webrtcManager, isConnected, pendingIncomingFiles = [], onIncomingDecision, showDropzone = true }) => {
|
||||
const [dragOver, setDragOver] = React.useState(false);
|
||||
const [transfers, setTransfers] = React.useState({ sending: [], receiving: [] });
|
||||
const fileInputRef = React.useRef(null);
|
||||
@@ -135,6 +135,51 @@ const FileTransferComponent = ({ webrtcManager, isConnected, pendingIncomingFile
|
||||
}
|
||||
};
|
||||
|
||||
// Segmented (per-chunk) progress — squares fill as chunks transfer, like a
|
||||
// download manager. For large files chunks are grouped into a fixed grid;
|
||||
// for small files it's literally one square per chunk.
|
||||
const renderProgress = (transfer, color) => {
|
||||
const total = transfer.totalChunks || 0;
|
||||
const done = transfer.transferredChunks || 0;
|
||||
const isDone = transfer.status === 'completed';
|
||||
const squares = total > 0 ? Math.min(total, 32) : 24;
|
||||
let filled;
|
||||
if (isDone) filled = squares;
|
||||
else if (total > 0) filled = Math.floor((done / total) * squares);
|
||||
else filled = Math.floor(((transfer.progress || 0) / 100) * squares);
|
||||
filled = Math.max(0, Math.min(squares, filled));
|
||||
|
||||
return React.createElement('div', { key: 'progress' }, [
|
||||
React.createElement('div', {
|
||||
key: 'squares',
|
||||
style: { display: 'flex', flexWrap: 'wrap', gap: '3px', marginBottom: '7px' }
|
||||
}, Array.from({ length: squares }, (_, i) => React.createElement('div', {
|
||||
key: i,
|
||||
style: {
|
||||
width: '11px', height: '11px', borderRadius: '2px',
|
||||
background: i < filled ? color : 'rgba(255,255,255,0.07)',
|
||||
border: '1px solid ' + (i < filled ? 'transparent' : 'rgba(255,255,255,0.05)'),
|
||||
boxShadow: i < filled ? `0 0 5px ${color}55` : 'none',
|
||||
transition: 'background .2s ease, box-shadow .2s ease'
|
||||
}
|
||||
}))),
|
||||
React.createElement('div', {
|
||||
key: 'text',
|
||||
style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', fontSize: '11.5px', color: '#8a8a92' }
|
||||
}, [
|
||||
React.createElement('span', { key: 'status', style: { display: 'inline-flex', alignItems: 'center', gap: '5px' } }, [
|
||||
React.createElement('i', { key: 'icon', className: getStatusIcon(transfer.status) }),
|
||||
getStatusText(transfer.status)
|
||||
]),
|
||||
React.createElement('span', {
|
||||
key: 'count',
|
||||
style: { fontFamily: "'JetBrains Mono', ui-monospace, monospace", color: i_done(transfer) ? color : '#8a8a92' }
|
||||
}, total > 0 ? `${Math.min(done, total)} / ${total} chunks` : `${(transfer.progress || 0).toFixed(0)}%`)
|
||||
])
|
||||
]);
|
||||
};
|
||||
const i_done = (t) => t.status === 'completed';
|
||||
|
||||
const handleIncomingDecision = async (fileId, accepted) => {
|
||||
if (typeof onIncomingDecision === 'function') {
|
||||
await onIncomingDecision(fileId, accepted);
|
||||
@@ -166,36 +211,43 @@ const FileTransferComponent = ({ webrtcManager, isConnected, pendingIncomingFile
|
||||
return React.createElement('div', {
|
||||
className: "file-transfer-component"
|
||||
}, [
|
||||
// File Drop Zone
|
||||
React.createElement('div', {
|
||||
// File Drop Zone (SecureBit Chat design) — only when the panel is opened to SEND,
|
||||
// so a receiver never sees the "send attachments" UI.
|
||||
showDropzone && React.createElement('div', {
|
||||
key: 'drop-zone',
|
||||
className: `file-drop-zone ${dragOver ? 'drag-over' : ''}`,
|
||||
onDrop: handleDrop,
|
||||
onDragOver: handleDragOver,
|
||||
onDragLeave: handleDragLeave,
|
||||
onClick: () => fileInputRef.current?.click()
|
||||
style: {
|
||||
position: 'relative',
|
||||
border: '1.5px dashed ' + (dragOver ? 'rgba(240,137,42,0.7)' : 'rgba(255,255,255,0.14)'),
|
||||
borderRadius: '14px',
|
||||
background: dragOver ? 'rgba(240,137,42,0.07)' : '#141416',
|
||||
padding: '24px 22px',
|
||||
textAlign: 'center',
|
||||
transition: 'all .15s'
|
||||
}
|
||||
}, [
|
||||
React.createElement('div', {
|
||||
key: 'drop-content',
|
||||
className: "drop-content"
|
||||
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('button', {
|
||||
key: 'browse',
|
||||
type: 'button',
|
||||
onClick: () => fileInputRef.current?.click(),
|
||||
className: 'sb-send',
|
||||
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: 'icon',
|
||||
className: 'fas fa-cloud-upload-alt text-2xl mb-2 text-blue-400'
|
||||
}),
|
||||
React.createElement('p', {
|
||||
key: 'text',
|
||||
className: "text-primary font-medium"
|
||||
}, 'Drag files here or click to select'),
|
||||
React.createElement('p', {
|
||||
key: 'subtext',
|
||||
className: "text-muted text-sm"
|
||||
}, 'Maximum size: 100 MB per file')
|
||||
React.createElement('i', { key: 'i', className: 'fas fa-folder-open', style: { fontSize: '13px' } }),
|
||||
'Browse device'
|
||||
])
|
||||
]),
|
||||
|
||||
// Hidden file input
|
||||
React.createElement('input', {
|
||||
showDropzone && React.createElement('input', {
|
||||
key: 'file-input',
|
||||
ref: fileInputRef,
|
||||
type: 'file',
|
||||
@@ -209,37 +261,40 @@ const FileTransferComponent = ({ webrtcManager, isConnected, pendingIncomingFile
|
||||
className: "mt-4 space-y-2"
|
||||
}, pendingIncomingFiles.map(file => React.createElement('div', {
|
||||
key: file.fileId,
|
||||
className: "rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-3"
|
||||
style: { borderRadius: '12px', border: '1px solid rgba(255,255,255,0.08)', background: '#161618', padding: '12px 14px' }
|
||||
}, [
|
||||
React.createElement('div', {
|
||||
key: 'info',
|
||||
className: "mb-3 flex items-center justify-between gap-3"
|
||||
style: { marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '11px' }
|
||||
}, [
|
||||
React.createElement('div', { key: 'text' }, [
|
||||
React.createElement('div', { key: 'ic', style: { flex: 'none', width: '34px', height: '34px', borderRadius: '9px', display: 'grid', placeItems: 'center', background: 'rgba(240,137,42,0.12)', border: '1px solid rgba(240,137,42,0.22)' } },
|
||||
React.createElement('i', { className: 'fas fa-file-arrow-down', style: { color: '#f0892a', fontSize: '15px' } })
|
||||
),
|
||||
React.createElement('div', { key: 'text', style: { minWidth: 0 } }, [
|
||||
React.createElement('div', {
|
||||
key: 'title',
|
||||
className: "text-sm font-medium text-primary"
|
||||
style: { fontSize: '13px', fontWeight: 600, color: '#e8e8eb' }
|
||||
}, 'Incoming file request'),
|
||||
React.createElement('div', {
|
||||
key: 'meta',
|
||||
className: "text-xs text-secondary"
|
||||
style: { fontSize: '11.5px', color: '#7b7b83', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }
|
||||
}, `${file.fileName} · ${formatFileSize(file.fileSize)} · ${file.mimeType}`)
|
||||
])
|
||||
]),
|
||||
React.createElement('div', {
|
||||
key: 'actions',
|
||||
className: "flex gap-2"
|
||||
style: { display: 'flex', gap: '8px' }
|
||||
}, [
|
||||
React.createElement('button', {
|
||||
key: 'accept',
|
||||
onClick: () => handleIncomingDecision(file.fileId, true),
|
||||
className: "rounded-md bg-green-500/20 px-3 py-2 text-sm text-green-300 hover:bg-green-500/30"
|
||||
}, 'Accept'),
|
||||
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('button', {
|
||||
key: 'reject',
|
||||
onClick: () => handleIncomingDecision(file.fileId, false),
|
||||
className: "rounded-md bg-red-500/20 px-3 py-2 text-sm text-red-300 hover:bg-red-500/30"
|
||||
}, 'Reject')
|
||||
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'])
|
||||
])
|
||||
]))),
|
||||
|
||||
@@ -250,20 +305,21 @@ const FileTransferComponent = ({ webrtcManager, isConnected, pendingIncomingFile
|
||||
}, [
|
||||
React.createElement('h4', {
|
||||
key: 'title',
|
||||
className: "text-primary font-medium mb-3 flex items-center"
|
||||
style: { display: 'flex', alignItems: 'center', gap: '8px', fontSize: '12.5px', fontWeight: 600, color: '#8a8a92', marginBottom: '10px' }
|
||||
}, [
|
||||
React.createElement('i', {
|
||||
key: 'icon',
|
||||
className: 'fas fa-exchange-alt mr-2'
|
||||
className: 'fas fa-right-left',
|
||||
style: { fontSize: '12px' }
|
||||
}),
|
||||
'Передача файлов'
|
||||
'File transfers'
|
||||
]),
|
||||
|
||||
// Sending files
|
||||
...transfers.sending.map(transfer =>
|
||||
...transfers.sending.map(transfer =>
|
||||
React.createElement('div', {
|
||||
key: `send-${transfer.fileId}`,
|
||||
className: "transfer-item bg-blue-500/10 border border-blue-500/20 rounded-lg p-3 mb-2"
|
||||
style: { borderRadius: '11px', border: '1px solid rgba(255,255,255,0.07)', background: '#161618', padding: '12px', marginBottom: '8px' }
|
||||
}, [
|
||||
React.createElement('div', {
|
||||
key: 'header',
|
||||
@@ -275,15 +331,18 @@ const FileTransferComponent = ({ webrtcManager, isConnected, pendingIncomingFile
|
||||
}, [
|
||||
React.createElement('i', {
|
||||
key: 'icon',
|
||||
className: 'fas fa-upload text-blue-400 mr-2'
|
||||
className: 'fas fa-arrow-up',
|
||||
style: { color: '#f0892a', fontSize: '13px', marginRight: '8px' }
|
||||
}),
|
||||
React.createElement('span', {
|
||||
key: 'name',
|
||||
className: "text-primary font-medium text-sm"
|
||||
className: "font-medium text-sm",
|
||||
style: { color: '#e8e8eb' }
|
||||
}, transfer.fileName),
|
||||
React.createElement('span', {
|
||||
key: 'size',
|
||||
className: "text-muted text-xs ml-2"
|
||||
className: "text-xs ml-2",
|
||||
style: { color: '#7b7b83' }
|
||||
}, formatFileSize(transfer.fileSize))
|
||||
]),
|
||||
React.createElement('button', {
|
||||
@@ -296,34 +355,7 @@ const FileTransferComponent = ({ webrtcManager, isConnected, pendingIncomingFile
|
||||
})
|
||||
])
|
||||
]),
|
||||
React.createElement('div', {
|
||||
key: 'progress',
|
||||
className: "progress-bar"
|
||||
}, [
|
||||
React.createElement('div', {
|
||||
key: 'fill',
|
||||
className: "progress-fill bg-blue-400",
|
||||
style: { width: `${transfer.progress}%` }
|
||||
}),
|
||||
React.createElement('div', {
|
||||
key: 'text',
|
||||
className: "progress-text text-xs flex items-center justify-between"
|
||||
}, [
|
||||
React.createElement('span', {
|
||||
key: 'status',
|
||||
className: "flex items-center"
|
||||
}, [
|
||||
React.createElement('i', {
|
||||
key: 'icon',
|
||||
className: `${getStatusIcon(transfer.status)} mr-1`
|
||||
}),
|
||||
getStatusText(transfer.status)
|
||||
]),
|
||||
React.createElement('span', {
|
||||
key: 'percent'
|
||||
}, `${transfer.progress.toFixed(1)}%`)
|
||||
])
|
||||
])
|
||||
renderProgress(transfer, '#f0892a')
|
||||
])
|
||||
),
|
||||
|
||||
@@ -331,7 +363,7 @@ const FileTransferComponent = ({ webrtcManager, isConnected, pendingIncomingFile
|
||||
...transfers.receiving.map(transfer =>
|
||||
React.createElement('div', {
|
||||
key: `recv-${transfer.fileId}`,
|
||||
className: "transfer-item bg-green-500/10 border border-green-500/20 rounded-lg p-3 mb-2"
|
||||
style: { borderRadius: '11px', border: '1px solid rgba(255,255,255,0.07)', background: '#161618', padding: '12px', marginBottom: '8px' }
|
||||
}, [
|
||||
React.createElement('div', {
|
||||
key: 'header',
|
||||
@@ -343,15 +375,18 @@ const FileTransferComponent = ({ webrtcManager, isConnected, pendingIncomingFile
|
||||
}, [
|
||||
React.createElement('i', {
|
||||
key: 'icon',
|
||||
className: 'fas fa-download text-green-400 mr-2'
|
||||
className: 'fas fa-arrow-down',
|
||||
style: { color: '#3ecf8e', fontSize: '13px', marginRight: '8px' }
|
||||
}),
|
||||
React.createElement('span', {
|
||||
key: 'name',
|
||||
className: "text-primary font-medium text-sm"
|
||||
className: "font-medium text-sm",
|
||||
style: { color: '#e8e8eb' }
|
||||
}, transfer.fileName),
|
||||
React.createElement('span', {
|
||||
key: 'size',
|
||||
className: "text-muted text-xs ml-2"
|
||||
className: "text-xs ml-2",
|
||||
style: { color: '#7b7b83' }
|
||||
}, formatFileSize(transfer.fileSize))
|
||||
]),
|
||||
React.createElement('div', { key: 'actions', className: 'flex items-center space-x-2' }, [
|
||||
@@ -386,34 +421,7 @@ const FileTransferComponent = ({ webrtcManager, isConnected, pendingIncomingFile
|
||||
])
|
||||
])
|
||||
]),
|
||||
React.createElement('div', {
|
||||
key: 'progress',
|
||||
className: "progress-bar"
|
||||
}, [
|
||||
React.createElement('div', {
|
||||
key: 'fill',
|
||||
className: "progress-fill bg-green-400",
|
||||
style: { width: `${transfer.progress}%` }
|
||||
}),
|
||||
React.createElement('div', {
|
||||
key: 'text',
|
||||
className: "progress-text text-xs flex items-center justify-between"
|
||||
}, [
|
||||
React.createElement('span', {
|
||||
key: 'status',
|
||||
className: "flex items-center"
|
||||
}, [
|
||||
React.createElement('i', {
|
||||
key: 'icon',
|
||||
className: `${getStatusIcon(transfer.status)} mr-1`
|
||||
}),
|
||||
getStatusText(transfer.status)
|
||||
]),
|
||||
React.createElement('span', {
|
||||
key: 'percent'
|
||||
}, `${transfer.progress.toFixed(1)}%`)
|
||||
])
|
||||
])
|
||||
renderProgress(transfer, '#3ecf8e')
|
||||
])
|
||||
)
|
||||
])
|
||||
|
||||
@@ -505,181 +505,97 @@ const EnhancedMinimalHeader = ({
|
||||
// RENDER
|
||||
// ============================================
|
||||
|
||||
const secColor = displaySecurityLevel
|
||||
? (displaySecurityLevel.color === 'green' ? '#3ecf8e' : displaySecurityLevel.color === 'orange' ? '#f0892a' : displaySecurityLevel.color === 'yellow' ? '#e3c84e' : '#e5727a')
|
||||
: '#3ecf8e';
|
||||
const dotColor = isConnected ? '#3ecf8e'
|
||||
: (['connecting', 'verifying', 'retrying', 'reconnecting'].includes(status) ? '#e3c84e'
|
||||
: (status === 'failed' ? '#e5727a' : '#6b6b73'));
|
||||
const dotGlow = dotColor === '#3ecf8e' ? 'rgba(62,207,142,0.16)' : dotColor === '#e3c84e' ? 'rgba(227,200,78,0.16)' : dotColor === '#e5727a' ? 'rgba(229,114,122,0.16)' : 'rgba(107,107,115,0.16)';
|
||||
const MONO = "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace";
|
||||
|
||||
// On the landing / setup view (no verified connection) the new Start Secure
|
||||
// screen owns the network settings and step status, so keep the header clean:
|
||||
// brand only — no status pill, no settings. The bar is transparent at the top
|
||||
// of the page and gains a blurred background once the user scrolls.
|
||||
const onLanding = !isConnected;
|
||||
const [scrolled, setScrolled] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
const onScroll = () => setScrolled((window.scrollY || window.pageYOffset || 0) > 8);
|
||||
onScroll();
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', onScroll);
|
||||
}, []);
|
||||
|
||||
// On the landing the header floats *over* the full-height hero (position
|
||||
// fixed), transparent at the top and blurred once scrolled. When connected it
|
||||
// falls back to the in-flow sticky bar.
|
||||
const overlay = { position: 'fixed', top: 0, left: 0, right: 0 };
|
||||
const headerStyle = onLanding
|
||||
? (scrolled
|
||||
? { ...overlay, background: 'rgba(15,15,17,0.72)', backdropFilter: 'blur(14px)', WebkitBackdropFilter: 'blur(14px)', borderBottom: '1px solid rgba(255,255,255,0.06)', transition: 'background .25s ease, backdrop-filter .25s ease, border-color .25s ease' }
|
||||
: { ...overlay, background: 'transparent', backdropFilter: 'none', WebkitBackdropFilter: 'none', borderBottom: '1px solid transparent', transition: 'background .25s ease, backdrop-filter .25s ease, border-color .25s ease' })
|
||||
: { background: 'rgba(18,18,20,0.72)', backdropFilter: 'blur(14px)', WebkitBackdropFilter: 'blur(14px)', borderBottom: '1px solid rgba(255,255,255,0.06)' };
|
||||
|
||||
return React.createElement('header', {
|
||||
className: 'header-minimal sticky top-0 z-50'
|
||||
className: onLanding ? 'header-minimal z-50' : 'header-minimal sticky top-0 z-50',
|
||||
style: headerStyle
|
||||
}, [
|
||||
React.createElement('div', {
|
||||
key: 'container',
|
||||
className: 'max-w-7xl mx-auto px-4 sm:px-6 lg:px-8'
|
||||
className: 'max-w-7xl mx-auto',
|
||||
style: { padding: '0 20px' }
|
||||
}, [
|
||||
React.createElement('div', {
|
||||
key: 'content',
|
||||
className: 'flex items-center justify-between h-16'
|
||||
className: 'flex items-center justify-between',
|
||||
style: { height: '64px', gap: '16px' }
|
||||
}, [
|
||||
// Logo and Title
|
||||
React.createElement('div', {
|
||||
key: 'logo-section',
|
||||
className: 'flex items-center space-x-2 sm:space-x-3'
|
||||
}, [
|
||||
React.createElement('div', {
|
||||
key: 'logo',
|
||||
className: 'icon-container w-8 h-8 sm:w-10 sm:h-10'
|
||||
}, [
|
||||
React.createElement('i', {
|
||||
className: 'fas fa-shield-halved accent-orange text-sm sm:text-base'
|
||||
})
|
||||
]),
|
||||
React.createElement('div', {
|
||||
key: 'title-section'
|
||||
}, [
|
||||
React.createElement('h1', {
|
||||
key: 'title',
|
||||
className: 'text-lg sm:text-xl font-semibold text-primary'
|
||||
}, 'SecureBit.chat'),
|
||||
React.createElement('p', {
|
||||
key: 'subtitle',
|
||||
className: 'text-xs sm:text-sm text-muted hidden sm:block'
|
||||
}, 'End-to-end freedom v4.8.20')
|
||||
// Left: logo + wordmark
|
||||
React.createElement('div', { key: 'left', style: { display: 'flex', alignItems: 'center', gap: '12px', minWidth: 0 } }, [
|
||||
React.createElement('div', { key: 'logo', style: { width: '36px', height: '36px', flex: 'none', display: 'grid', placeItems: 'center' } },
|
||||
React.createElement('img', { src: '/logo/securebit-mark.svg', alt: 'SecureBit', style: { width: '100%', height: '100%', objectFit: 'contain', display: 'block' } })
|
||||
),
|
||||
React.createElement('div', { key: 'txt', style: { lineHeight: 1.2, minWidth: 0 } }, [
|
||||
React.createElement('div', { key: 'r1', style: { display: 'flex', alignItems: 'baseline', gap: '7px' } }, [
|
||||
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' } }, 'v4.9.0')
|
||||
]),
|
||||
React.createElement('div', { key: 'r2', className: 'hidden sm:block', style: { fontSize: '11px', color: '#6b6b73', fontWeight: 500 } }, 'End-to-end encrypted')
|
||||
])
|
||||
]),
|
||||
|
||||
// Status and Controls - Responsive
|
||||
React.createElement('div', {
|
||||
key: 'status-section',
|
||||
className: 'flex items-center space-x-2 sm:space-x-3'
|
||||
}, [
|
||||
|
||||
React.createElement('button', {
|
||||
key: 'network-settings',
|
||||
type: 'button',
|
||||
// Right: controls
|
||||
React.createElement('div', { key: 'right', style: { display: 'flex', alignItems: 'center', gap: '9px' } }, [
|
||||
!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',
|
||||
className: 'w-8 h-8 rounded-full flex items-center justify-center text-muted hover:text-primary hover:bg-white/5 transition-colors duration-200'
|
||||
title: 'Advanced network settings (STUN/TURN)', 'aria-label': 'Advanced network settings',
|
||||
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' } })),
|
||||
|
||||
(!onLanding && displaySecurityLevel) && React.createElement('div', {
|
||||
key: 'sec', onClick: handleSecurityClick,
|
||||
onContextMenu: (e) => { e.preventDefault(); if (typeof onDisconnect === 'function') onDisconnect(); },
|
||||
title: securityDetails.tooltip, className: 'sb-secpill',
|
||||
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-network-wired text-sm' })
|
||||
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: 's', style: { fontFamily: MONO, fontSize: '11.5px', color: '#8a8a92' } }, displaySecurityLevel.score + '%')
|
||||
]),
|
||||
|
||||
displaySecurityLevel && React.createElement('div', {
|
||||
key: 'security-level',
|
||||
className: 'hidden md:flex items-center space-x-2 cursor-pointer hover:opacity-80 transition-opacity duration-200',
|
||||
onClick: handleSecurityClick,
|
||||
onContextMenu: (e) => {
|
||||
e.preventDefault();
|
||||
if (onDisconnect && typeof onDisconnect === 'function') {
|
||||
onDisconnect();
|
||||
}
|
||||
},
|
||||
title: securityDetails.tooltip
|
||||
}, [
|
||||
React.createElement('div', {
|
||||
key: 'security-icon',
|
||||
className: `w-6 h-6 rounded-full flex items-center justify-center relative ${
|
||||
displaySecurityLevel.color === 'green' ? 'bg-green-500/20' :
|
||||
displaySecurityLevel.color === 'orange' ? 'bg-orange-500/20' :
|
||||
displaySecurityLevel.color === 'yellow' ? 'bg-yellow-500/20' : 'bg-red-500/20'
|
||||
} ${securityDetails.isVerified ? '' : 'animate-pulse'}`
|
||||
}, [
|
||||
React.createElement('i', {
|
||||
className: `fas fa-shield-alt text-xs ${
|
||||
displaySecurityLevel.color === 'green' ? 'text-green-400' :
|
||||
displaySecurityLevel.color === 'orange' ? 'text-orange-400' :
|
||||
displaySecurityLevel.color === 'yellow' ? 'text-yellow-400' : 'text-red-400'
|
||||
}`
|
||||
})
|
||||
]),
|
||||
React.createElement('div', {
|
||||
key: 'security-info',
|
||||
className: 'flex flex-col'
|
||||
}, [
|
||||
React.createElement('div', {
|
||||
key: 'security-level-text',
|
||||
className: 'text-xs font-medium text-primary flex items-center space-x-1'
|
||||
}, [
|
||||
React.createElement('span', {}, `${displaySecurityLevel.level} (${displaySecurityLevel.score}%)`)
|
||||
]),
|
||||
React.createElement('div', {
|
||||
key: 'security-details',
|
||||
className: 'text-xs text-muted mt-1 hidden lg:block'
|
||||
}, securityDetails.dataSource === 'real' ?
|
||||
`${displaySecurityLevel.passedChecks || 0}/${displaySecurityLevel.totalChecks || 0} tests` :
|
||||
(displaySecurityLevel.details || `Stage ${displaySecurityLevel.stage || 1}`)
|
||||
),
|
||||
React.createElement('div', {
|
||||
key: 'security-progress',
|
||||
className: 'w-16 h-1 bg-gray-600 rounded-full overflow-hidden'
|
||||
}, [
|
||||
React.createElement('div', {
|
||||
key: 'progress-bar',
|
||||
className: `h-full transition-all duration-500 ${
|
||||
displaySecurityLevel.color === 'green' ? 'bg-green-400' :
|
||||
displaySecurityLevel.color === 'orange' ? 'bg-orange-400' :
|
||||
displaySecurityLevel.color === 'yellow' ? 'bg-yellow-400' : 'bg-red-400'
|
||||
}`,
|
||||
style: { width: `${displaySecurityLevel.score}%` }
|
||||
})
|
||||
])
|
||||
])
|
||||
!onLanding && React.createElement('div', { key: 'status', style: { display: 'flex', alignItems: 'center', gap: '8px', padding: '8px 13px', borderRadius: '9px', border: '1px solid rgba(255,255,255,0.07)', background: 'rgba(255,255,255,0.02)' } }, [
|
||||
React.createElement('span', { key: 'dot', style: { width: '7px', height: '7px', borderRadius: '50%', background: dotColor, boxShadow: '0 0 0 3px ' + dotGlow } }),
|
||||
React.createElement('span', { key: 't', className: 'hidden sm:inline', style: { fontSize: '13px', fontWeight: 600, color: '#cfcfd4' } }, config.text)
|
||||
]),
|
||||
|
||||
// Mobile Security Indicator
|
||||
displaySecurityLevel && React.createElement('div', {
|
||||
key: 'mobile-security',
|
||||
className: 'md:hidden flex items-center'
|
||||
}, [
|
||||
React.createElement('div', {
|
||||
key: 'mobile-security-icon',
|
||||
className: `w-8 h-8 rounded-full flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity duration-200 relative ${
|
||||
displaySecurityLevel.color === 'green' ? 'bg-green-500/20' :
|
||||
displaySecurityLevel.color === 'orange' ? 'bg-orange-500/20' :
|
||||
displaySecurityLevel.color === 'yellow' ? 'bg-yellow-500/20' : 'bg-red-500/20'
|
||||
} ${securityDetails.isVerified ? '' : 'animate-pulse'}`,
|
||||
title: securityDetails.tooltip,
|
||||
onClick: handleSecurityClick,
|
||||
onContextMenu: (e) => {
|
||||
e.preventDefault();
|
||||
if (onDisconnect && typeof onDisconnect === 'function') {
|
||||
onDisconnect();
|
||||
}
|
||||
}
|
||||
}, [
|
||||
React.createElement('i', {
|
||||
className: `fas fa-shield-alt text-sm ${
|
||||
displaySecurityLevel.color === 'green' ? 'text-green-400' :
|
||||
displaySecurityLevel.color === 'orange' ? 'text-orange-400' :
|
||||
displaySecurityLevel.color === 'yellow' ? 'text-yellow-400' : 'text-red-400'
|
||||
}`
|
||||
})
|
||||
])
|
||||
]),
|
||||
|
||||
// Status Badge
|
||||
React.createElement('div', {
|
||||
key: 'status-badge',
|
||||
className: `px-2 sm:px-3 py-1.5 rounded-lg border ${config.badgeClass} flex items-center space-x-1 sm:space-x-2`
|
||||
}, [
|
||||
React.createElement('span', {
|
||||
key: 'status-dot',
|
||||
className: `status-dot ${config.className}`
|
||||
}),
|
||||
React.createElement('span', {
|
||||
key: 'status-text',
|
||||
className: 'text-xs sm:text-sm font-medium'
|
||||
}, config.text),
|
||||
]),
|
||||
|
||||
// Disconnect Button
|
||||
isConnected && React.createElement('button', {
|
||||
key: 'disconnect-btn',
|
||||
onClick: onDisconnect,
|
||||
className: 'p-1.5 sm:px-3 sm:py-1.5 bg-red-500/10 hover:bg-red-500/20 text-red-400 border border-red-500/20 rounded-lg transition-all duration-200 text-sm'
|
||||
key: 'dc', onClick: onDisconnect, className: 'sb-disconnect',
|
||||
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', {
|
||||
className: 'fas fa-power-off sm:mr-2'
|
||||
}),
|
||||
React.createElement('span', {
|
||||
className: 'hidden sm:inline'
|
||||
}, 'Disconnect')
|
||||
React.createElement('i', { key: 'i', className: 'fas fa-power-off', style: { fontSize: '12px' } }),
|
||||
React.createElement('span', { key: 't', className: 'sb-hide-sm' }, 'Disconnect')
|
||||
])
|
||||
])
|
||||
])
|
||||
|
||||
@@ -64,7 +64,7 @@ async function testIceServers(servers, timeoutMs = 6000) {
|
||||
});
|
||||
}
|
||||
|
||||
const IceServerSettings = ({ isOpen, onClose, initial, hasSaved, onApply, onForget }) => {
|
||||
const IceServerSettings = ({ isOpen, onClose, initial, hasSaved, onApply, onForget, embedded }) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const [useCustom, setUseCustom] = React.useState(initial?.useCustom || false);
|
||||
@@ -104,159 +104,153 @@ const IceServerSettings = ({ isOpen, onClose, initial, hasSaved, onApply, onForg
|
||||
setPersist(false);
|
||||
};
|
||||
|
||||
const labelCls = 'block text-sm font-medium text-primary';
|
||||
const descCls = 'block text-sm text-secondary';
|
||||
// ── Design import: dark slide-up overlay (orange/green accents) ──────────
|
||||
const h = React.createElement;
|
||||
const C_ORANGE = '#f0892a';
|
||||
const C_GREEN = '#3ecf8e';
|
||||
const MONO = "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace";
|
||||
|
||||
const children = [];
|
||||
|
||||
// Header
|
||||
children.push(React.createElement('div', { key: 'header', className: 'flex items-center mb-4' }, [
|
||||
React.createElement('div', {
|
||||
key: 'icon',
|
||||
className: 'w-10 h-10 bg-purple-500/10 border border-purple-500/20 rounded-lg flex items-center justify-center mr-3'
|
||||
}, [React.createElement('i', { className: 'fas fa-network-wired accent-purple' })]),
|
||||
React.createElement('h3', { key: 'title', className: 'text-lg font-medium text-primary' }, 'Advanced network settings')
|
||||
]));
|
||||
|
||||
// Explainer
|
||||
children.push(React.createElement('p', { key: 'intro', className: 'text-sm text-secondary mb-4' },
|
||||
'By default SecureBit uses public STUN servers. You can supply your own STUN/TURN servers — useful if you self-host a TURN relay and do not want to rely on public infrastructure. Servers are configured locally on your side only; you do not need to share them with your peer.'
|
||||
));
|
||||
|
||||
// Mode radios
|
||||
children.push(React.createElement('div', { key: 'mode', className: 'space-y-2 mb-4' }, [
|
||||
React.createElement('label', { key: 'public', className: 'flex items-start gap-3' }, [
|
||||
React.createElement('input', {
|
||||
key: 'r', type: 'radio', name: 'ice-mode', checked: !useCustom,
|
||||
onChange: () => setUseCustom(false), className: 'mt-1'
|
||||
}),
|
||||
React.createElement('span', { key: 's' }, [
|
||||
React.createElement('span', { key: 't', className: labelCls }, 'Public servers (default)'),
|
||||
React.createElement('span', { key: 'd', className: descCls }, 'Zero-config. Good for most users.')
|
||||
])
|
||||
]),
|
||||
React.createElement('label', { key: 'custom', className: 'flex items-start gap-3' }, [
|
||||
React.createElement('input', {
|
||||
key: 'r', type: 'radio', name: 'ice-mode', checked: useCustom,
|
||||
onChange: () => setUseCustom(true), className: 'mt-1'
|
||||
}),
|
||||
React.createElement('span', { key: 's' }, [
|
||||
React.createElement('span', { key: 't', className: labelCls }, 'My own STUN/TURN servers'),
|
||||
React.createElement('span', { key: 'd', className: descCls }, `Up to ${ICE_LIMITS.MAX_SERVERS} servers.`)
|
||||
])
|
||||
// radio card (public / own servers)
|
||||
const radioCard = (selected, onClick, title, desc, extraStyle) => h('button', {
|
||||
type: 'button', onClick,
|
||||
style: Object.assign({
|
||||
width: '100%', textAlign: 'left', display: 'flex', alignItems: 'flex-start', gap: '12px',
|
||||
padding: '14px 15px', borderRadius: '13px',
|
||||
border: `1px solid ${selected ? 'rgba(240,137,42,0.45)' : 'rgba(255,255,255,0.07)'}`,
|
||||
background: selected ? 'rgba(240,137,42,0.06)' : '#141416',
|
||||
color: 'inherit', fontFamily: 'inherit', cursor: 'pointer', transition: 'all .15s', marginBottom: '10px'
|
||||
}, extraStyle || {})
|
||||
}, [
|
||||
h('span', { key: 'ring', style: { flex: 'none', width: '18px', height: '18px', marginTop: '1px', borderRadius: '50%', border: `1.5px solid ${selected ? C_ORANGE : 'rgba(255,255,255,0.22)'}`, display: 'grid', placeItems: 'center' } },
|
||||
h('span', { style: { width: '8px', height: '8px', borderRadius: '50%', background: selected ? C_ORANGE : 'transparent' } })),
|
||||
h('span', { key: 'tx', style: { flex: 1 } }, [
|
||||
h('span', { key: 't', style: { display: 'block', fontSize: '14px', fontWeight: 700, color: '#f4f4f6' } }, title),
|
||||
h('span', { key: 'd', style: { display: 'block', fontSize: '12.5px', color: '#8a8a92', marginTop: '2px' } }, desc)
|
||||
])
|
||||
]));
|
||||
]);
|
||||
|
||||
// pill toggle row (relay / save)
|
||||
const toggleRow = (on, onClick, title, desc, accent, badge) => h('button', {
|
||||
type: 'button', onClick,
|
||||
style: {
|
||||
width: '100%', textAlign: 'left', display: 'flex', alignItems: 'flex-start', gap: '12px',
|
||||
padding: '14px 15px', borderRadius: '13px',
|
||||
border: `1px solid ${on ? 'rgba(62,207,142,0.3)' : 'rgba(255,255,255,0.07)'}`,
|
||||
background: on ? 'rgba(62,207,142,0.05)' : '#141416',
|
||||
color: 'inherit', fontFamily: 'inherit', cursor: 'pointer', transition: 'all .15s', marginBottom: '10px'
|
||||
}
|
||||
}, [
|
||||
h('span', { key: 'tx', style: { flex: 1 } }, [
|
||||
h('span', { key: 'r1', style: { display: 'flex', alignItems: 'center', gap: '8px' } }, [
|
||||
h('span', { key: 't', style: { fontSize: '14px', fontWeight: 700, color: '#f4f4f6' } }, title),
|
||||
badge && h('span', { key: 'b', style: { fontSize: '10px', fontWeight: 700, color: C_GREEN, padding: '2px 7px', borderRadius: '5px', background: 'rgba(62,207,142,0.1)', border: '1px solid rgba(62,207,142,0.22)' } }, badge)
|
||||
]),
|
||||
h('span', { key: 'd', style: { display: 'block', fontSize: '12.5px', lineHeight: 1.5, color: '#8a8a92', marginTop: '3px' } }, desc)
|
||||
]),
|
||||
h('span', { key: 'tr', style: { flex: 'none', width: '42px', height: '24px', borderRadius: '99px', background: on ? (accent || C_GREEN) : 'rgba(255,255,255,0.08)', border: `1px solid ${on ? (accent || C_GREEN) : 'rgba(255,255,255,0.12)'}`, position: 'relative', transition: 'all .18s', marginTop: '1px' } },
|
||||
h('span', { style: { position: 'absolute', top: '2px', left: '2px', width: '18px', height: '18px', borderRadius: '50%', background: '#fff', transform: on ? 'translateX(18px)' : 'translateX(0)', transition: 'transform .18s' } }))
|
||||
]);
|
||||
|
||||
// ── 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));
|
||||
|
||||
// Textarea + validation (only in custom mode)
|
||||
if (useCustom) {
|
||||
children.push(React.createElement('textarea', {
|
||||
key: 'textarea',
|
||||
value: serversText,
|
||||
onChange: (e) => setServersText(e.target.value),
|
||||
placeholder: PLACEHOLDER,
|
||||
spellCheck: false,
|
||||
autoComplete: 'off',
|
||||
className: 'w-full h-36 mb-2 p-3 rounded-lg bg-black/30 border border-purple-500/20 text-sm text-primary font-mono'
|
||||
}));
|
||||
|
||||
const custom = [];
|
||||
custom.push(h('div', { key: 'ta', style: { borderRadius: '13px', border: '1px solid rgba(255,255,255,0.08)', background: '#0c0c0e', overflow: 'hidden', marginBottom: '12px' } },
|
||||
h('textarea', {
|
||||
value: serversText, onChange: (e) => setServersText(e.target.value), rows: 5, spellCheck: false, autoComplete: 'off',
|
||||
placeholder: PLACEHOLDER,
|
||||
style: { width: '100%', resize: 'vertical', border: 'none', outline: 'none', background: 'transparent', color: '#c9ccd8', fontFamily: MONO, fontSize: '12px', lineHeight: 1.65, padding: '13px 14px', minHeight: '104px' }
|
||||
})));
|
||||
if (parsed.errors.length > 0) {
|
||||
children.push(React.createElement('ul', { key: 'errors', className: 'mb-2 text-sm text-red-400 list-disc pl-5' },
|
||||
parsed.errors.slice(0, 6).map((err, i) => React.createElement('li', { key: i }, err))
|
||||
));
|
||||
custom.push(h('ul', { key: 'err', style: { margin: '0 0 10px', paddingLeft: '18px', color: '#e5727a', fontSize: '12.5px' } },
|
||||
parsed.errors.slice(0, 6).map((err, i) => h('li', { key: i }, err))));
|
||||
}
|
||||
if (parsed.warnings.length > 0) {
|
||||
children.push(React.createElement('ul', { key: 'warnings', className: 'mb-2 text-sm text-yellow-400 list-disc pl-5' },
|
||||
parsed.warnings.slice(0, 6).map((w, i) => React.createElement('li', { key: i }, w))
|
||||
));
|
||||
custom.push(h('ul', { key: 'warn', style: { margin: '0 0 10px', paddingLeft: '18px', color: '#e3c84e', fontSize: '12.5px' } },
|
||||
parsed.warnings.slice(0, 6).map((w, i) => h('li', { key: i }, w))));
|
||||
}
|
||||
if (parsed.servers.length > 0 && parsed.errors.length === 0) {
|
||||
children.push(React.createElement('p', { key: 'ok', className: 'mb-2 text-sm text-green-400' },
|
||||
`${parsed.servers.length} server(s) parsed${hasTurn ? ' (TURN present)' : ' (STUN only — does not hide IP)'}.`
|
||||
));
|
||||
custom.push(h('p', { key: 'ok', style: { margin: '0 0 10px', fontSize: '12.5px', color: C_GREEN } },
|
||||
`${parsed.servers.length} server(s) parsed${hasTurn ? ' (TURN present)' : ' (STUN only — does not hide IP)'}.`));
|
||||
}
|
||||
|
||||
// Privacy disclaimer about third-party relays
|
||||
children.push(React.createElement('p', { key: 'disclaimer', className: 'mb-3 text-xs text-secondary' },
|
||||
'Privacy note: a TURN relay sees the IP addresses and traffic timing of both peers (never your message contents, which stay end-to-end encrypted). Only a TURN server you trust or self-host improves privacy — pointing this at a random public relay does not. Prefer turns: (TLS).'
|
||||
));
|
||||
|
||||
// Test button + result
|
||||
children.push(React.createElement('div', { key: 'test', className: 'mb-3' }, [
|
||||
React.createElement('button', {
|
||||
key: 'btn',
|
||||
type: 'button',
|
||||
disabled: !canApply || testState === 'running',
|
||||
onClick: handleTest,
|
||||
className: 'px-3 py-2 text-sm rounded-lg border border-purple-500/30 text-primary disabled:opacity-50'
|
||||
}, testState === 'running' ? 'Testing…' : 'Test servers'),
|
||||
testState === 'done' && testResult ? React.createElement('span', {
|
||||
key: 'res',
|
||||
className: 'ml-3 text-sm ' + (testResult.error ? 'text-red-400' : 'text-secondary')
|
||||
}, testResult.error
|
||||
? `Test failed: ${testResult.error}`
|
||||
: (testResult.srflx > 0 || testResult.relay > 0)
|
||||
? `STUN ${testResult.srflx > 0 ? 'OK' : 'none'} · TURN ${testResult.relay > 0 ? 'OK' : 'none'} · host ${testResult.host}`
|
||||
: `host ${testResult.host} · this browser (e.g. Safari) hides STUN/TURN candidates from the test — your servers are still applied to real connections`
|
||||
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).'
|
||||
])
|
||||
]));
|
||||
const testColor = testState === 'done' && testResult && !testResult.error ? C_GREEN : '#cfcfd4';
|
||||
custom.push(h('div', { key: 'test', style: { display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'wrap', marginBottom: '4px' } }, [
|
||||
h('button', {
|
||||
key: 'btn', type: 'button', disabled: !canApply || testState === 'running', onClick: handleTest,
|
||||
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 === 'done' && testResult) ? h('span', { key: 'res', style: { fontSize: '12px', color: testResult.error ? '#e5727a' : '#8a8a92' } },
|
||||
testResult.error
|
||||
? `Test failed: ${testResult.error}`
|
||||
: (testResult.srflx > 0 || testResult.relay > 0)
|
||||
? `STUN ${testResult.srflx > 0 ? 'OK' : 'none'} · TURN ${testResult.relay > 0 ? 'OK' : 'none'} · host ${testResult.host}`
|
||||
: `host ${testResult.host} · this browser hides STUN/TURN candidates from the test — your servers still apply to real connections`
|
||||
) : null
|
||||
]));
|
||||
body.push(h('div', { key: 'custom', style: { marginBottom: '16px' } }, custom));
|
||||
}
|
||||
|
||||
// Relay-only privacy toggle
|
||||
children.push(React.createElement('label', { key: 'relay', className: 'flex items-start gap-3 mb-3 rounded-lg border border-purple-500/20 bg-purple-500/10 p-3' }, [
|
||||
React.createElement('input', {
|
||||
key: 'i', type: 'checkbox', checked: relayOnly,
|
||||
onChange: (e) => setRelayOnly(e.target.checked), className: 'mt-1'
|
||||
}),
|
||||
React.createElement('span', { key: 's' }, [
|
||||
React.createElement('span', { key: 't', className: labelCls }, 'Relay-only mode (maximum privacy)'),
|
||||
React.createElement('span', { key: 'd', className: descCls }, 'Routes all traffic through TURN so your IP is not exposed to the peer. Requires a TURN server; connections cannot start without one.')
|
||||
])
|
||||
]));
|
||||
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'));
|
||||
if (relayOnly && useCustom && !hasTurn) {
|
||||
children.push(React.createElement('p', { key: 'relaywarn', className: 'mb-3 text-sm text-yellow-400' },
|
||||
'Relay-only is enabled but no TURN server is configured. The connection will not be able to start.'
|
||||
));
|
||||
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.'));
|
||||
}
|
||||
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));
|
||||
|
||||
// Save on device
|
||||
children.push(React.createElement('label', { key: 'persist', className: 'flex items-start gap-3 mb-4' }, [
|
||||
React.createElement('input', {
|
||||
key: 'i', type: 'checkbox', checked: persist,
|
||||
onChange: (e) => setPersist(e.target.checked), className: 'mt-1'
|
||||
}),
|
||||
React.createElement('span', { key: 's' }, [
|
||||
React.createElement('span', { key: 't', className: labelCls }, 'Save on this device'),
|
||||
React.createElement('span', { key: 'd', className: descCls }, 'Stored encrypted in this browser. Leave off to use only for this session.')
|
||||
])
|
||||
// ── 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'));
|
||||
}
|
||||
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'));
|
||||
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'
|
||||
]));
|
||||
|
||||
// Action buttons
|
||||
const actions = [
|
||||
React.createElement('button', {
|
||||
key: 'cancel', type: 'button', onClick: onClose,
|
||||
className: 'px-4 py-2 text-sm rounded-lg border border-white/10 text-secondary'
|
||||
}, 'Cancel'),
|
||||
React.createElement('button', {
|
||||
key: 'apply', type: 'button', onClick: handleApply, disabled: !canApply,
|
||||
className: 'px-4 py-2 text-sm rounded-lg bg-purple-500/20 border border-purple-500/30 text-primary disabled:opacity-50'
|
||||
}, 'Apply')
|
||||
];
|
||||
if (hasSaved) {
|
||||
actions.unshift(React.createElement('button', {
|
||||
key: 'forget', type: 'button', onClick: handleForget,
|
||||
className: 'px-4 py-2 text-sm rounded-lg border border-red-500/30 text-red-400 mr-auto'
|
||||
}, 'Forget saved'));
|
||||
}
|
||||
children.push(React.createElement('div', { key: 'actions', className: 'flex items-center gap-2 flex-wrap' }, actions));
|
||||
// Embedded mode (default for the new design): fill the connection screen's
|
||||
// right column and slide up over it. Fallback: a fixed right-side drawer.
|
||||
const wrapperStyle = embedded
|
||||
? { position: 'absolute', inset: 0, zIndex: 30, display: 'flex', flexDirection: 'column', background: '#0f0f11', animation: 'sbSlideUp .32s cubic-bezier(.2,.7,.3,1)' }
|
||||
: { position: 'fixed', inset: 0, zIndex: 60, display: 'flex', flexDirection: 'column', alignItems: 'stretch', background: '#0f0f11', animation: 'sbSlideUp .32s cubic-bezier(.2,.7,.3,1)' };
|
||||
|
||||
return React.createElement('div', {
|
||||
className: 'fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4',
|
||||
onClick: (e) => { if (e.target === e.currentTarget) onClose(); }
|
||||
}, [
|
||||
React.createElement('div', {
|
||||
key: 'modal',
|
||||
className: 'card-minimal rounded-xl p-6 max-w-lg w-full border-purple-500/20 max-h-[90vh] overflow-y-auto'
|
||||
}, children)
|
||||
return h('div', { className: 'sb-ice-overlay', style: wrapperStyle }, [
|
||||
h(React.Fragment, { key: 'panel' }, [
|
||||
// header
|
||||
h('div', { key: 'head', style: { display: 'flex', alignItems: 'center', gap: '12px', padding: '20px 24px', borderBottom: '1px solid rgba(255,255,255,0.06)' } }, [
|
||||
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('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' }))
|
||||
]),
|
||||
// scroll body
|
||||
h('div', { key: 'body', className: 'custom-scrollbar', style: { flex: 1, overflowY: 'auto', padding: '20px 24px' } }, body),
|
||||
// footer
|
||||
h('div', { key: 'foot', style: { display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '10px', padding: '16px 24px', borderTop: '1px solid rgba(255,255,255,0.06)', background: '#0e0e10', borderRadius: '0' } }, footerBtns)
|
||||
])
|
||||
]);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,449 +1,183 @@
|
||||
// "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.
|
||||
function Roadmap() {
|
||||
const [selectedPhase, setSelectedPhase] = React.useState(null);
|
||||
const phases = [
|
||||
{
|
||||
version: "v1.0",
|
||||
title: "Start of Development",
|
||||
status: "done",
|
||||
date: "Early 2025",
|
||||
description: "Idea, prototype, and infrastructure setup",
|
||||
features: [
|
||||
"Concept and requirements formation",
|
||||
"Stack selection: WebRTC, P2P, cryptography",
|
||||
"First messaging prototypes",
|
||||
"Repository creation and CI",
|
||||
"Basic encryption architecture",
|
||||
"UX/UI design"
|
||||
]
|
||||
},
|
||||
{
|
||||
version: "v1.5",
|
||||
title: "Alpha Release",
|
||||
status: "done",
|
||||
date: "Spring 2025",
|
||||
description: "First public alpha: basic chat and key exchange",
|
||||
features: [
|
||||
"Basic P2P messaging via WebRTC",
|
||||
"Simple E2E encryption (demo scheme)",
|
||||
"Stable signaling and reconnection",
|
||||
"Minimal UX for testing",
|
||||
"Feedback collection from early testers"
|
||||
]
|
||||
},
|
||||
{
|
||||
version: "v2.0",
|
||||
title: "Security Hardened",
|
||||
status: "done",
|
||||
date: "Summer 2025",
|
||||
description: "Security strengthening and stable branch release",
|
||||
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"
|
||||
]
|
||||
},
|
||||
{
|
||||
version: "v3.0",
|
||||
title: "Scaling & Stability",
|
||||
status: "done",
|
||||
date: "Fall 2025",
|
||||
description: "Network scaling and stability improvements",
|
||||
features: [
|
||||
"Optimization of P2P connections and NAT traversal",
|
||||
"Reconnection mechanisms and message queues",
|
||||
"Reduced battery consumption on mobile",
|
||||
"Support for multi-device synchronization",
|
||||
"Monitoring and logging tools for developers"
|
||||
]
|
||||
},
|
||||
{
|
||||
version: "v3.5",
|
||||
title: "Privacy-first Release",
|
||||
status: "done",
|
||||
date: "Winter 2025",
|
||||
description: "Focus on privacy: minimizing metadata",
|
||||
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"
|
||||
]
|
||||
},
|
||||
|
||||
// current and future phases
|
||||
{
|
||||
version: "v4.5",
|
||||
title: "Enhanced Security Edition",
|
||||
status: "done",
|
||||
date: "Now",
|
||||
description: "Version with ECDH + DTLS + SAS security, 18-layer military-grade cryptography and complete ASN.1 validation",
|
||||
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"
|
||||
]
|
||||
},
|
||||
{
|
||||
version: "v4.7",
|
||||
title: "Desktop Edition",
|
||||
status: "current",
|
||||
date: "Now",
|
||||
description: "Native desktop applications for Windows, macOS, and Linux",
|
||||
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"
|
||||
]
|
||||
},
|
||||
{
|
||||
version: "v5.0",
|
||||
title: "Mobile Edition",
|
||||
status: "development",
|
||||
date: "Q1 2026",
|
||||
description: "Native mobile applications for iOS and Android",
|
||||
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"
|
||||
]
|
||||
},
|
||||
{
|
||||
version: "v5.5",
|
||||
title: "Quantum-Resistant Edition",
|
||||
status: "planned",
|
||||
date: "Q2 2026",
|
||||
description: "Protection against quantum computers",
|
||||
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 v4.x",
|
||||
"Quantum-resistant protocols"
|
||||
]
|
||||
},
|
||||
{
|
||||
version: "v6.0",
|
||||
title: "Group Communications",
|
||||
status: "planned",
|
||||
date: "Q4 2026",
|
||||
description: "Group chats with preserved privacy",
|
||||
features: [
|
||||
"P2P group connections up to 8 participants",
|
||||
"Mesh networking for groups",
|
||||
"Signal Double Ratchet for groups",
|
||||
"Anonymous groups without metadata",
|
||||
"Ephemeral groups (disappear after session)",
|
||||
"Cryptographic group administration",
|
||||
"Group member auditing"
|
||||
]
|
||||
},
|
||||
{
|
||||
version: "v6.5",
|
||||
title: "Decentralized Network",
|
||||
status: "research",
|
||||
date: "2027",
|
||||
description: "Fully decentralized network",
|
||||
features: [
|
||||
"LockBit 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"
|
||||
]
|
||||
},
|
||||
{
|
||||
version: "v7.0",
|
||||
title: "AI Privacy Assistant",
|
||||
status: "research",
|
||||
date: "2028+",
|
||||
description: "AI for privacy and security",
|
||||
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"
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
const getStatusConfig = (status) => {
|
||||
switch (status) {
|
||||
case 'current':
|
||||
return {
|
||||
color: 'green',
|
||||
bgClass: 'bg-green-500/10 border-green-500/20',
|
||||
textClass: 'text-green-400',
|
||||
icon: 'fas fa-check-circle',
|
||||
label: 'Current Version'
|
||||
};
|
||||
case 'development':
|
||||
return {
|
||||
color: 'orange',
|
||||
bgClass: 'bg-orange-500/10 border-orange-500/20',
|
||||
textClass: 'text-orange-400',
|
||||
icon: 'fas fa-code',
|
||||
label: 'In Development'
|
||||
};
|
||||
case 'planned':
|
||||
return {
|
||||
color: 'blue',
|
||||
bgClass: 'bg-blue-500/10 border-blue-500/20',
|
||||
textClass: 'text-blue-400',
|
||||
icon: 'fas fa-calendar-alt',
|
||||
label: 'Planned'
|
||||
};
|
||||
case 'research':
|
||||
return {
|
||||
color: 'purple',
|
||||
bgClass: 'bg-purple-500/10 border-purple-500/20',
|
||||
textClass: 'text-purple-400',
|
||||
icon: 'fas fa-flask',
|
||||
label: 'Research'
|
||||
};
|
||||
case 'done':
|
||||
return {
|
||||
color: 'gray',
|
||||
bgClass: 'bg-gray-500/10 border-gray-500/20',
|
||||
textClass: 'text-gray-300',
|
||||
icon: 'fas fa-flag-checkered',
|
||||
label: 'Released'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
color: 'gray',
|
||||
bgClass: 'bg-gray-500/10 border-gray-500/20',
|
||||
textClass: 'text-gray-400',
|
||||
icon: 'fas fa-question',
|
||||
label: 'Unknown'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const togglePhaseDetail = (index) => {
|
||||
setSelectedPhase(selectedPhase === index ? null : index);
|
||||
};
|
||||
return (
|
||||
<div key="roadmap-section" className="mt-16 px-4 sm:px-0">
|
||||
<div key="section-header" className="text-center mb-12">
|
||||
<h3 key="title" className="text-2xl font-semibold text-primary mb-3">
|
||||
Development Roadmap
|
||||
</h3>
|
||||
<p key="subtitle" className="text-secondary max-w-2xl mx-auto mb-6">
|
||||
Evolution of SecureBit.chat : from initial development to quantum-resistant decentralized network with complete ASN.1 validation
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div key="roadmap-container" className="max-w-6xl mx-auto">
|
||||
<div key="timeline" className="relative">
|
||||
{/* The line has been removed */}
|
||||
|
||||
<div key="phases" className="space-y-8">
|
||||
{phases.map((phase, index) => {
|
||||
const statusConfig = getStatusConfig(phase.status);
|
||||
const isExpanded = selectedPhase === index;
|
||||
|
||||
return (
|
||||
<div key={`phase-${index}`} className="relative">
|
||||
{/* The dots are visible only on sm and larger screens */}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={isExpanded}
|
||||
onClick={() => togglePhaseDetail(index)}
|
||||
key={`phase-button-${index}`}
|
||||
className={`card-minimal rounded-xl p-4 text-left w-full transition-all duration-300 ${
|
||||
isExpanded
|
||||
? "ring-2 ring-" + statusConfig.color + "-500/30"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
key="phase-header"
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-4 space-y-2 sm:space-y-0"
|
||||
>
|
||||
<div
|
||||
key="phase-info"
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:space-x-4"
|
||||
>
|
||||
<div
|
||||
key="version-badge"
|
||||
className={`px-3 py-1 ${statusConfig.bgClass} border rounded-lg mb-2 sm:mb-0`}
|
||||
>
|
||||
<span
|
||||
key="version"
|
||||
className={`${statusConfig.textClass} font-bold text-sm`}
|
||||
>
|
||||
{phase.version}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div key="title-section">
|
||||
<h4
|
||||
key="title"
|
||||
className="text-lg font-semibold text-primary"
|
||||
>
|
||||
{phase.title}
|
||||
</h4>
|
||||
<p
|
||||
key="description"
|
||||
className="text-secondary text-sm"
|
||||
>
|
||||
{phase.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
key="phase-meta"
|
||||
className="flex items-center space-x-3 text-sm text-gray-400 font-medium"
|
||||
>
|
||||
<div
|
||||
key="status-badge"
|
||||
className={`flex items-center px-3 py-1 ${statusConfig.bgClass} border rounded-lg`}
|
||||
>
|
||||
<i
|
||||
key="status-icon"
|
||||
className={`${statusConfig.icon} ${statusConfig.textClass} mr-2 text-xs`}
|
||||
/>
|
||||
<span
|
||||
key="status-text"
|
||||
className={`${statusConfig.textClass} text-xs font-medium`}
|
||||
>
|
||||
{statusConfig.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div key="date">{phase.date}</div>
|
||||
<i
|
||||
key="expand-icon"
|
||||
className={`fas fa-chevron-${
|
||||
isExpanded ? "up" : "down"
|
||||
} text-gray-400 text-sm`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div
|
||||
key="features-section"
|
||||
className="mt-6 pt-6 border-t border-gray-700/30"
|
||||
>
|
||||
<h5
|
||||
key="features-title"
|
||||
className="text-primary font-medium mb-4 flex items-center"
|
||||
>
|
||||
<i
|
||||
key="features-icon"
|
||||
className="fas fa-list-ul mr-2 text-sm"
|
||||
/>
|
||||
Key features:
|
||||
</h5>
|
||||
|
||||
<div
|
||||
key="features-grid"
|
||||
className="grid md:grid-cols-2 gap-3"
|
||||
>
|
||||
{phase.features.map((feature, featureIndex) => (
|
||||
<div
|
||||
key={`feature-${featureIndex}`}
|
||||
className="flex items-center space-x-3 p-3 bg-custom-bg rounded-lg"
|
||||
>
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${statusConfig.textClass.replace(
|
||||
"text-",
|
||||
"bg-"
|
||||
)}`}
|
||||
/>
|
||||
<span className="text-secondary text-sm">
|
||||
{feature}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div key="cta-section" className="mt-12 text-center">
|
||||
<div
|
||||
key="cta-card"
|
||||
className="card-minimal rounded-xl p-8 max-w-2xl mx-auto"
|
||||
>
|
||||
<h4
|
||||
key="cta-title"
|
||||
className="text-xl font-semibold text-primary mb-3"
|
||||
>
|
||||
Join the future of privacy
|
||||
</h4>
|
||||
<p key="cta-description" className="text-secondary mb-6">
|
||||
SecureBit.chat grows thanks to the community. Your ideas and feedback help shape the future of secure communication with complete ASN.1 validation.
|
||||
</p>
|
||||
|
||||
<div
|
||||
key="cta-buttons"
|
||||
className="flex flex-col sm:flex-row gap-4 justify-center"
|
||||
>
|
||||
<a
|
||||
key="github-link"
|
||||
href="https://github.com/SecureBitChat/securebit-chat/"
|
||||
className="btn-primary text-white py-3 px-6 rounded-lg font-medium transition-all duration-200 flex items-center justify-center"
|
||||
>
|
||||
<i key="github-icon" className="fab fa-github mr-2" />
|
||||
GitHub Repository
|
||||
</a>
|
||||
|
||||
<a
|
||||
key="feedback-link"
|
||||
href="mailto:lockbitchat@tutanota.com"
|
||||
className="btn-secondary text-white py-3 px-6 rounded-lg font-medium transition-all duration-200 flex items-center justify-center"
|
||||
>
|
||||
<i key="feedback-icon" className="fas fa-comments mr-2" />
|
||||
Feedback
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
const [isMobile, setIsMobile] = React.useState(
|
||||
typeof window !== 'undefined' && window.matchMedia('(max-width:767px)').matches
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const mq = window.matchMedia('(max-width:767px)');
|
||||
const onChange = () => setIsMobile(mq.matches);
|
||||
mq.addEventListener ? mq.addEventListener('change', onChange) : mq.addListener(onChange);
|
||||
return () => {
|
||||
mq.removeEventListener ? mq.removeEventListener('change', onChange) : mq.removeListener(onChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const MONO = "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace";
|
||||
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: "v4.7", title: "Desktop Edition", sub: "Native desktop apps for Windows, macOS, and Linux", status: "current", date: "Now",
|
||||
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.0", title: "Mobile Edition", sub: "Native mobile apps for iOS and Android", status: "dev", date: "Q1 2026",
|
||||
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: "v5.5", title: "Quantum-Resistant Edition", sub: "Protection against quantum computers", status: "planned", date: "Q2 2026",
|
||||
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 v4.x", "Quantum-resistant protocols"] },
|
||||
{ v: "v6.0", title: "Group Communications", sub: "Group chats with preserved privacy", status: "planned", date: "Q4 2026",
|
||||
features: ["P2P group connections up to 8 participants", "Mesh networking for groups", "Signal Double Ratchet for groups", "Anonymous groups without metadata", "Ephemeral groups (disappear after session)", "Cryptographic group administration", "Group member auditing"] },
|
||||
{ v: "v6.5", title: "Decentralized Network", sub: "Fully decentralized network", status: "research", date: "2027",
|
||||
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: "v7.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"] }
|
||||
];
|
||||
|
||||
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)" }
|
||||
};
|
||||
|
||||
const [open, setOpen] = React.useState({});
|
||||
const isOpen = (i) => (open[i] === undefined ? DATA[i].status === 'current' : open[i]);
|
||||
const toggle = (i) => setOpen((s) => ({ ...s, [i]: !isOpen(i) }));
|
||||
|
||||
const hexA = (hex, a) => {
|
||||
const n = parseInt(hex.slice(1), 16);
|
||||
return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`;
|
||||
};
|
||||
|
||||
const total = DATA.length;
|
||||
const shipped = DATA.filter((d) => d.status === 'released' || d.status === 'current').length;
|
||||
const upcoming = total - shipped;
|
||||
const shippedPct = (shipped / total * 100).toFixed(1) + '%';
|
||||
|
||||
const renderNode = (status) => {
|
||||
if (status === 'released') {
|
||||
return (
|
||||
<div style={{ position: 'absolute', left: '13px', top: '16px', width: '28px', height: '28px', borderRadius: '50%', display: 'grid', placeItems: 'center', background: 'linear-gradient(rgba(62,207,142,0.16),rgba(62,207,142,0.16)), #0f0f11', border: '1px solid rgba(62,207,142,0.4)', zIndex: 2 }}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#3ecf8e" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round"><path d="M5 13l4 4 10-11" /></svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (status === 'current') {
|
||||
return (
|
||||
<div style={{ position: 'absolute', left: '13px', top: '16px', width: '28px', height: '28px', borderRadius: '50%', display: 'grid', placeItems: 'center', background: 'linear-gradient(rgba(240,137,42,0.2),rgba(240,137,42,0.2)), #0f0f11', border: '1px solid #f0892a', zIndex: 2, animation: 'rmPulse 2.4s ease-out infinite' }}>
|
||||
<span style={{ width: '9px', height: '9px', borderRadius: '50%', background: '#f0892a' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (status === 'dev') {
|
||||
return (
|
||||
<div style={{ position: 'absolute', left: '13px', top: '16px', width: '28px', height: '28px', borderRadius: '50%', display: 'grid', placeItems: 'center', background: 'linear-gradient(rgba(227,179,65,0.15),rgba(227,179,65,0.15)), #0f0f11', border: '1px solid rgba(227,179,65,0.4)', zIndex: 2 }}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#e3b341" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3a9 9 0 1 0 9 9" /></svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// planned / research
|
||||
return (
|
||||
<div style={{ position: 'absolute', left: '13px', top: '16px', width: '28px', height: '28px', borderRadius: '50%', display: 'grid', placeItems: 'center', background: '#0f0f11', border: `1px ${status === 'research' ? 'dashed' : 'solid'} rgba(255,255,255,0.18)`, zIndex: 2 }}>
|
||||
<span style={{ width: '7px', height: '7px', borderRadius: '50%', background: META[status].color }} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section style={{ width: '100%', color: '#e8e8eb', fontFamily: SANS, padding: isMobile ? '48px 0' : '64px 0', background: 'radial-gradient(1200px 720px at 50% -8%, rgba(240,137,42,0.05), transparent 60%), #0f0f11' }}>
|
||||
<style dangerouslySetInnerHTML={{ __html: '@keyframes rmExp{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}@keyframes rmPulse{0%,100%{box-shadow:0 0 0 0 rgba(240,137,42,0.18)}60%{box-shadow:0 0 0 9px rgba(240,137,42,0)}}' }} />
|
||||
|
||||
<div style={{ maxWidth: '1040px', margin: '0 auto', padding: isMobile ? '0 18px' : '0 40px' }}>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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={{ 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>
|
||||
);
|
||||
};
|
||||
window.Roadmap = Roadmap;
|
||||
<div style={{ fontFamily: MONO, fontSize: '11px', fontWeight: 600, color: '#6b6b73', textTransform: 'uppercase', letterSpacing: '0.8px', whiteSpace: 'nowrap' }}>{upcoming} on the way</div>
|
||||
</div>
|
||||
|
||||
{/* timeline */}
|
||||
{DATA.map((d, i) => {
|
||||
const meta = META[d.status];
|
||||
const opened = isOpen(i);
|
||||
const notLast = i < total - 1;
|
||||
return (
|
||||
<div key={i} style={{ position: 'relative', display: 'grid', gridTemplateColumns: '54px 1fr', marginBottom: '16px' }}>
|
||||
|
||||
{/* spine */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
{notLast && <div style={{ position: 'absolute', left: '26px', top: '30px', height: 'calc(100% + 16px)', width: '2px', background: meta.line }} />}
|
||||
{renderNode(d.status)}
|
||||
</div>
|
||||
|
||||
{/* card */}
|
||||
<div style={{ borderRadius: '16px', background: '#141416', border: `1px solid ${d.status === 'current' ? 'rgba(240,137,42,0.28)' : 'rgba(255,255,255,0.06)'}`, overflow: 'hidden' }}>
|
||||
<div
|
||||
onClick={() => toggle(i)}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: isMobile ? '11px' : '16px', padding: isMobile ? '16px 16px' : '18px 22px', cursor: 'pointer', transition: 'background .18s ease' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.018)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
<div style={{ flex: 'none', minWidth: '52px', textAlign: 'center', padding: '7px 10px', borderRadius: '9px', background: '#0c0c0e', border: '1px solid rgba(255,255,255,0.07)', fontFamily: MONO, fontSize: '13px', fontWeight: 700, color: d.status === 'current' ? '#f0892a' : '#cfcfd4' }}>{d.v}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: isMobile ? '15.5px' : '17px', fontWeight: 800, letterSpacing: '-0.4px', color: '#f4f4f6' }}>{d.title}</div>
|
||||
{!isMobile && <div style={{ marginTop: '3px', fontSize: '13.5px', color: '#9a9aa2' }}>{d.sub}</div>}
|
||||
</div>
|
||||
<div style={{ flex: 'none', display: 'flex', alignItems: 'center', gap: isMobile ? '8px' : '14px' }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '7px', padding: '6px 11px', borderRadius: '8px', background: hexA(meta.color, 0.1), border: `1px solid ${hexA(meta.color, 0.22)}`, fontFamily: MONO, fontSize: '10.5px', fontWeight: 600, color: meta.color, textTransform: 'uppercase', letterSpacing: '0.8px', whiteSpace: 'nowrap' }}>
|
||||
<span style={{ width: '6px', height: '6px', borderRadius: '50%', background: meta.color }} />
|
||||
{!isMobile && meta.word}
|
||||
</span>
|
||||
{!isMobile && <span style={{ fontFamily: MONO, fontSize: '12px', fontWeight: 500, color: '#8a8a92', whiteSpace: 'nowrap', minWidth: '74px', textAlign: 'right' }}>{d.date}</span>}
|
||||
<span style={{ color: '#6b6b73', display: 'inline-flex', transition: 'transform .22s cubic-bezier(.2,.7,.3,1)', transform: opened ? 'rotate(180deg)' : 'rotate(0deg)' }}>
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.1" strokeLinecap="round" strokeLinejoin="round"><path d="M6 9l6 6 6-6" /></svg>
|
||||
</span>
|
||||
</div>
|
||||
</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={{ 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' }}>
|
||||
<span style={{ flex: 'none', marginTop: '7px', width: '5px', height: '5px', borderRadius: '50%', background: meta.color }} />
|
||||
<span style={{ fontSize: '13.5px', lineHeight: 1.5, color: '#cfcfd4' }}>{f}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
window.Roadmap = Roadmap;
|
||||
|
||||
@@ -1,209 +1,239 @@
|
||||
// Enhanced Modern Slider Component with Loading Protection
|
||||
// "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
|
||||
// expands to reveal full content, the rest collapse to a vertical spine label.
|
||||
const UniqueFeatureSlider = () => {
|
||||
const trackRef = React.useRef(null);
|
||||
const wrapRef = React.useRef(null);
|
||||
const [current, setCurrent] = React.useState(0);
|
||||
const [isReady, setIsReady] = React.useState(false);
|
||||
const [active, setActive] = React.useState(0);
|
||||
const [isMobile, setIsMobile] = React.useState(
|
||||
typeof window !== 'undefined' && window.matchMedia('(max-width:767px)').matches
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const mq = window.matchMedia('(max-width:767px)');
|
||||
const onChange = () => setIsMobile(mq.matches);
|
||||
mq.addEventListener ? mq.addEventListener('change', onChange) : mq.addListener(onChange);
|
||||
return () => {
|
||||
mq.removeEventListener ? mq.removeEventListener('change', onChange) : mq.removeListener(onChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const ACCENT = '#f0892a';
|
||||
const ACTIVE_BG = 'radial-gradient(130% 90% at 28% 0%, rgba(240,137,42,0.11), transparent 60%), #141416';
|
||||
const ACTIVE_BD = 'rgba(240,137,42,0.3)';
|
||||
const IDLE_BG = '#111113';
|
||||
const IDLE_BD = 'rgba(255,255,255,0.06)';
|
||||
const MONO = "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace";
|
||||
const SANS = "'Manrope', system-ui, -apple-system, sans-serif";
|
||||
|
||||
const slides = [
|
||||
{
|
||||
icon: "🛡️",
|
||||
bgImage: "linear-gradient(135deg, rgb(255 107 53 / 6%) 0%, rgb(255 140 66 / 45%) 100%)",
|
||||
thumbIcon: "🔒",
|
||||
title: "18-Layer Military Security",
|
||||
description: "Revolutionary defense system with ECDH P-384 + AES-GCM 256 + ECDSA + Complete ASN.1 Validation."
|
||||
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'],
|
||||
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"/>'
|
||||
},
|
||||
{
|
||||
icon: "🌐",
|
||||
bgImage: "linear-gradient(135deg, rgb(147 51 234 / 6%) 0%, rgb(168 85 247 / 45%) 100%)",
|
||||
thumbIcon: "🔗",
|
||||
title: "Pure P2P WebRTC",
|
||||
description: "Direct peer-to-peer connections without any servers. Complete decentralization with zero infrastructure."
|
||||
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'],
|
||||
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"/>'
|
||||
},
|
||||
{
|
||||
icon: "🔄",
|
||||
bgImage: "linear-gradient(135deg, rgb(16 185 129 / 6%) 0%, rgb(52 211 153 / 45%) 100%)",
|
||||
thumbIcon: "⚡",
|
||||
title: "Perfect Forward Secrecy",
|
||||
description: "Automatic key rotation every 5 minutes. Non-extractable keys with hardware protection."
|
||||
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'],
|
||||
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"/>'
|
||||
},
|
||||
{
|
||||
icon: "🎭",
|
||||
bgImage: "linear-gradient(135deg, rgb(6 182 212 / 6%) 0%, rgb(34 211 238 / 45%) 100%)",
|
||||
thumbIcon: "🌫️",
|
||||
title: "Traffic Obfuscation",
|
||||
description: "Fake traffic generation and pattern masking make communication indistinguishable from noise."
|
||||
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'],
|
||||
icon: '<path d="M3 7h4l3 10h4M14 7h3l3 0"/><path d="M17 4l3 3-3 3"/><path d="M3 17h4l2-6"/>'
|
||||
},
|
||||
{
|
||||
icon: "👁️",
|
||||
bgImage: "linear-gradient(135deg, rgb(37 99 235 / 6%) 0%, rgb(59 130 246 / 45%) 100%)",
|
||||
thumbIcon: "🚫",
|
||||
title: "Zero Data Collection",
|
||||
description: "No registration, no servers, no logs. Complete anonymity with instant channels."
|
||||
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'],
|
||||
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"/>'
|
||||
}
|
||||
];
|
||||
|
||||
// Проверка готовности компонента
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setIsReady(true);
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const isMobile = () => window.matchMedia("(max-width:767px)").matches;
|
||||
|
||||
const center = React.useCallback((i) => {
|
||||
if (!trackRef.current || !wrapRef.current) return;
|
||||
const card = trackRef.current.children[i];
|
||||
if (!card) return;
|
||||
|
||||
const axis = isMobile() ? "top" : "left";
|
||||
const size = isMobile() ? "clientHeight" : "clientWidth";
|
||||
const start = isMobile() ? card.offsetTop : card.offsetLeft;
|
||||
|
||||
wrapRef.current.scrollTo({
|
||||
[axis]: start - (wrapRef.current[size] / 2 - card[size] / 2),
|
||||
behavior: "smooth"
|
||||
const svg = (inner, size, stroke, sw) =>
|
||||
React.createElement('svg', {
|
||||
width: size, height: size, viewBox: '0 0 24 24', fill: 'none',
|
||||
stroke, strokeWidth: sw, strokeLinecap: 'round', strokeLinejoin: 'round',
|
||||
dangerouslySetInnerHTML: { __html: inner }
|
||||
});
|
||||
}, []);
|
||||
|
||||
const activate = React.useCallback((i, scroll = false) => {
|
||||
if (i === current) return;
|
||||
setCurrent(i);
|
||||
if (scroll) {
|
||||
setTimeout(() => center(i), 50);
|
||||
}
|
||||
}, [current, center]);
|
||||
const go = (step) =>
|
||||
setActive((a) => (a + step + slides.length) % slides.length);
|
||||
|
||||
const go = (step) => {
|
||||
const newIndex = Math.min(Math.max(current + step, 0), slides.length - 1);
|
||||
activate(newIndex, true);
|
||||
};
|
||||
const navBtn = (key, onClick, path) =>
|
||||
React.createElement('button', {
|
||||
key, onClick, 'aria-label': key,
|
||||
style: {
|
||||
width: '46px', height: '46px', display: 'grid', placeItems: 'center',
|
||||
borderRadius: '50%', border: '1px solid rgba(255,255,255,0.1)',
|
||||
background: 'rgba(255,255,255,0.025)', color: '#cfcfd4', cursor: 'pointer',
|
||||
transition: 'all .2s cubic-bezier(.2,.7,.3,1)'
|
||||
},
|
||||
onMouseEnter: (e) => { e.currentTarget.style.borderColor = ACTIVE_BD; e.currentTarget.style.color = ACCENT; },
|
||||
onMouseLeave: (e) => { e.currentTarget.style.borderColor = 'rgba(255,255,255,0.1)'; e.currentTarget.style.color = '#cfcfd4'; }
|
||||
}, svg(path, 18, 'currentColor', 2.1));
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleKeydown = (e) => {
|
||||
if (["ArrowRight", "ArrowDown"].includes(e.key)) go(1);
|
||||
if (["ArrowLeft", "ArrowUp"].includes(e.key)) go(-1);
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeydown, { passive: true });
|
||||
return () => window.removeEventListener("keydown", handleKeydown);
|
||||
}, [current]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isReady) {
|
||||
center(current);
|
||||
}
|
||||
}, [current, center, isReady]);
|
||||
// Render loading state if not ready
|
||||
if (!isReady) {
|
||||
return React.createElement('section', {
|
||||
style: {
|
||||
background: 'transparent',
|
||||
minHeight: '400px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}
|
||||
},
|
||||
React.createElement('div', {
|
||||
style: {
|
||||
opacity: 0.5,
|
||||
fontSize: '14px',
|
||||
color: '#fff'
|
||||
}
|
||||
}, 'Loading...')
|
||||
);
|
||||
}
|
||||
|
||||
return React.createElement('section', { style: { background: 'transparent' } }, [
|
||||
// Header
|
||||
React.createElement('div', {
|
||||
key: 'head',
|
||||
className: 'head'
|
||||
const tag = (label) =>
|
||||
React.createElement('span', {
|
||||
key: label,
|
||||
style: {
|
||||
display: 'inline-flex', alignItems: 'center', gap: '7px', padding: '7px 12px',
|
||||
borderRadius: '9px', border: '1px solid rgba(255,255,255,0.07)',
|
||||
background: 'rgba(255,255,255,0.025)', fontFamily: MONO,
|
||||
fontSize: '11.5px', fontWeight: 500, color: '#9a9aa2'
|
||||
}
|
||||
}, [
|
||||
React.createElement('h2', {
|
||||
key: 'title',
|
||||
className: 'text-2xl sm:text-3xl font-bold text-white mb-4 leading-snug'
|
||||
}, 'Why SecureBit.chat is unique'),
|
||||
React.createElement('div', {
|
||||
key: 'controls',
|
||||
className: 'controls'
|
||||
React.createElement('span', { key: 'dot', style: { width: '5px', height: '5px', borderRadius: '50%', background: '#3ecf8e' } }),
|
||||
label
|
||||
]);
|
||||
|
||||
const expandedContent = (s) =>
|
||||
React.createElement('div', {
|
||||
key: 'exp',
|
||||
style: {
|
||||
height: '100%', display: 'flex', flexDirection: 'column',
|
||||
justifyContent: isMobile ? 'flex-start' : 'space-between',
|
||||
gap: isMobile ? '18px' : 0,
|
||||
padding: isMobile ? '24px 22px' : '32px 34px',
|
||||
minWidth: isMobile ? 'auto' : '320px',
|
||||
animation: 'wuUp .42s cubic-bezier(.2,.7,.3,1)'
|
||||
}
|
||||
}, [
|
||||
React.createElement('div', { key: 'top', style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' } }, [
|
||||
React.createElement('div', {
|
||||
key: 'ic',
|
||||
style: {
|
||||
width: '54px', height: '54px', borderRadius: '15px', display: 'grid', placeItems: 'center',
|
||||
background: 'rgba(240,137,42,0.13)', border: '1px solid rgba(240,137,42,0.3)'
|
||||
}
|
||||
}, svg(s.icon, 26, ACCENT, 1.9)),
|
||||
React.createElement('span', { key: 'n', style: { fontFamily: MONO, fontSize: '13px', fontWeight: 600, color: '#6b6b73' } }, s.num)
|
||||
]),
|
||||
React.createElement('div', { key: 'mid' }, [
|
||||
React.createElement('h3', {
|
||||
key: 'h', style: { margin: '0 0 12px', fontSize: isMobile ? '24px' : '30px', fontWeight: 800, letterSpacing: '-0.7px', lineHeight: 1.08, color: '#f4f4f6' }
|
||||
}, [s.title[0], React.createElement('br', { key: 'br' }), s.title[1]]),
|
||||
React.createElement('p', {
|
||||
key: 'p', style: { margin: 0, fontSize: '15px', lineHeight: 1.6, color: '#9a9aa2', maxWidth: '380px' }
|
||||
}, s.desc)
|
||||
]),
|
||||
React.createElement('div', { key: 'tags', style: { display: 'flex', flexWrap: 'wrap', gap: '8px' } }, s.tags.map(tag))
|
||||
]);
|
||||
|
||||
const collapsedContent = (s) => isMobile
|
||||
? React.createElement('div', {
|
||||
key: 'col',
|
||||
style: { display: 'flex', alignItems: 'center', gap: '16px', padding: '20px 22px' }
|
||||
}, [
|
||||
React.createElement('button', {
|
||||
key: 'prev',
|
||||
id: 'prev-slider',
|
||||
className: 'nav-btn',
|
||||
'aria-label': 'Prev',
|
||||
disabled: current === 0,
|
||||
onClick: () => go(-1)
|
||||
}, '‹'),
|
||||
React.createElement('button', {
|
||||
key: 'next',
|
||||
id: 'next-slider',
|
||||
className: 'nav-btn',
|
||||
'aria-label': 'Next',
|
||||
disabled: current === slides.length - 1,
|
||||
onClick: () => go(1)
|
||||
}, '›')
|
||||
React.createElement('span', { key: 'n', style: { fontFamily: MONO, fontSize: '12px', fontWeight: 600, color: '#56565e' } }, s.num),
|
||||
React.createElement('span', { key: 'l', style: { fontSize: '16px', fontWeight: 800, letterSpacing: '-0.2px', color: '#cfcfd4' } }, s.collapsed)
|
||||
])
|
||||
: React.createElement('div', {
|
||||
key: 'col',
|
||||
style: { position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'space-between', padding: '24px 0' }
|
||||
}, [
|
||||
React.createElement('span', { key: 'n', style: { fontFamily: MONO, fontSize: '12px', fontWeight: 600, color: '#56565e' } }, s.num),
|
||||
React.createElement('span', {
|
||||
key: 'l',
|
||||
style: { writingMode: 'vertical-rl', transform: 'rotate(180deg)', fontSize: '17px', fontWeight: 800, letterSpacing: '-0.2px', color: '#cfcfd4', whiteSpace: 'nowrap' }
|
||||
}, s.collapsed),
|
||||
svg(s.icon, 22, '#56565e', 1.8)
|
||||
]);
|
||||
|
||||
const panels = slides.map((s, i) => {
|
||||
const isActive = active === i;
|
||||
return React.createElement('div', {
|
||||
key: i,
|
||||
onClick: () => setActive(i),
|
||||
// Selection is click-only (like the design); hover just brightens the panel
|
||||
// a touch so the orange glow never jumps around chasing the cursor.
|
||||
onMouseEnter: (e) => { if (!isActive) e.currentTarget.style.filter = 'brightness(1.18)'; },
|
||||
onMouseLeave: (e) => { e.currentTarget.style.filter = 'none'; },
|
||||
style: {
|
||||
flex: isMobile ? 'none' : (isActive ? 6.2 : 1),
|
||||
minWidth: isMobile ? 'auto' : '72px',
|
||||
position: 'relative',
|
||||
borderRadius: '18px',
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
background: isActive ? ACTIVE_BG : IDLE_BG,
|
||||
border: '1px solid ' + (isActive ? ACTIVE_BD : IDLE_BD),
|
||||
color: '#8a8a92',
|
||||
transition: 'flex .46s cubic-bezier(.2,.7,.3,1), background .3s ease, border-color .3s ease, filter .2s ease'
|
||||
}
|
||||
}, isActive ? expandedContent(s) : collapsedContent(s));
|
||||
});
|
||||
|
||||
const inner = React.createElement('div', {
|
||||
key: 'inner',
|
||||
style: {
|
||||
maxWidth: '1180px', margin: '0 auto',
|
||||
padding: isMobile ? '0 18px' : '0 40px'
|
||||
}
|
||||
}, [
|
||||
// Header
|
||||
React.createElement('div', {
|
||||
key: 'head',
|
||||
style: { display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: '24px', marginBottom: '28px' }
|
||||
}, [
|
||||
React.createElement('div', { key: 'titles' }, [
|
||||
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'),
|
||||
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')
|
||||
]),
|
||||
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"/>'),
|
||||
navBtn('next', () => go(1), '<path d="M9 6l6 6-6 6"/>')
|
||||
])
|
||||
]),
|
||||
|
||||
// Slider
|
||||
// Accordion
|
||||
React.createElement('div', {
|
||||
key: 'slider',
|
||||
className: 'slider',
|
||||
ref: wrapRef
|
||||
},
|
||||
React.createElement('div', {
|
||||
className: 'track',
|
||||
ref: trackRef
|
||||
}, slides.map((slide, index) =>
|
||||
React.createElement('article', {
|
||||
key: index,
|
||||
className: 'project-card',
|
||||
...(index === current ? { active: '' } : {}),
|
||||
onMouseEnter: () => {
|
||||
if (window.matchMedia("(hover:hover)").matches) {
|
||||
activate(index, true);
|
||||
}
|
||||
},
|
||||
onClick: () => activate(index, true)
|
||||
}, [
|
||||
// Background
|
||||
React.createElement('div', {
|
||||
key: 'bg',
|
||||
className: 'project-card__bg',
|
||||
style: {
|
||||
background: slide.bgImage,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center'
|
||||
}
|
||||
}),
|
||||
key: 'accordion',
|
||||
style: {
|
||||
display: 'flex',
|
||||
flexDirection: isMobile ? 'column' : 'row',
|
||||
gap: isMobile ? '12px' : '14px',
|
||||
height: isMobile ? 'auto' : '440px'
|
||||
}
|
||||
}, panels)
|
||||
]);
|
||||
|
||||
// Content
|
||||
React.createElement('div', {
|
||||
key: 'content',
|
||||
className: 'project-card__content'
|
||||
}, [
|
||||
// Text container
|
||||
React.createElement('div', { key: 'text' }, [
|
||||
React.createElement('h3', {
|
||||
key: 'title',
|
||||
className: 'project-card__title'
|
||||
}, slide.title),
|
||||
React.createElement('p', {
|
||||
key: 'desc',
|
||||
className: 'project-card__desc'
|
||||
}, slide.description)
|
||||
])
|
||||
])
|
||||
])
|
||||
))
|
||||
),
|
||||
// Full-bleed dark band with the radial accent glow — matches the design mockup.
|
||||
return React.createElement('section', {
|
||||
style: {
|
||||
width: '100%', color: '#e8e8eb', fontFamily: SANS,
|
||||
padding: isMobile ? '44px 0' : '64px 0',
|
||||
background: 'radial-gradient(1100px 700px at 18% 8%, rgba(240,137,42,0.05), transparent 60%), #0f0f11'
|
||||
}
|
||||
}, [
|
||||
React.createElement('style', { key: 'kf', dangerouslySetInnerHTML: { __html: '@keyframes wuUp{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}' } }),
|
||||
inner
|
||||
]);
|
||||
};
|
||||
|
||||
// Export for use in your app
|
||||
window.UniqueFeatureSlider = UniqueFeatureSlider;
|
||||
window.UniqueFeatureSlider = UniqueFeatureSlider;
|
||||
|
||||
@@ -73,6 +73,8 @@ class EnhancedSecureWebRTCManager {
|
||||
|
||||
// Per-message control (unsend / disappearing sync)
|
||||
MESSAGE_DELETE: 'message_delete',
|
||||
// Delivery receipt: recipient acks a chat message by id (WhatsApp ✓✓).
|
||||
MESSAGE_RECEIPT: 'message_receipt',
|
||||
|
||||
// System messages
|
||||
HEARTBEAT: 'heartbeat',
|
||||
@@ -3203,9 +3205,24 @@ this._secureLog('info', '🔒 Enhanced Mutex system fully initialized and valida
|
||||
this._secureMemoryManager.isCleaning = true;
|
||||
|
||||
// Clean up sensitive data, but DO NOT wipe active crypto in ratchet session
|
||||
const shouldPreserveActiveKeys = (this.sessionMode === 'ratchet') && this.isConnected && this.dataChannel && this.dataChannel.readyState === 'open';
|
||||
const preserveActiveRatchet = (this.sessionMode === 'ratchet') && this.isConnected && this.dataChannel && this.dataChannel.readyState === 'open';
|
||||
// DO NOT wipe an offer that is still awaiting its answer: the creator
|
||||
// holds a pending offer context (with the session salt) until the peer's
|
||||
// response is applied. Wiping it here drops sessionSalt + the context and
|
||||
// makes handleSecureAnswer fail with "Missing pending offer context".
|
||||
// Keep it for as long as the offer itself is valid (OFFER_MAX_AGE).
|
||||
const pendingOfferAgeMs = this._pendingOfferContext
|
||||
? (Date.now() - (this._pendingOfferContext.createdAt || 0))
|
||||
: Infinity;
|
||||
const hasPendingOffer = !!this._pendingOfferContext
|
||||
&& Array.isArray(this._pendingOfferContext.sessionSalt)
|
||||
&& this._pendingOfferContext.sessionSalt.length === 64
|
||||
&& pendingOfferAgeMs < EnhancedSecureWebRTCManager.LIMITS.OFFER_MAX_AGE;
|
||||
const shouldPreserveActiveKeys = preserveActiveRatchet || hasPendingOffer;
|
||||
if (shouldPreserveActiveKeys) {
|
||||
this._secureLog('debug', '🧹 Skipping crypto key wipe during periodic cleanup (ratchet mode, active connection)');
|
||||
this._secureLog('debug', '🧹 Skipping crypto key wipe during periodic cleanup', {
|
||||
reason: preserveActiveRatchet ? 'active ratchet connection' : 'offer awaiting answer'
|
||||
});
|
||||
} else {
|
||||
this._secureCleanupCryptographicMaterials();
|
||||
}
|
||||
@@ -6491,6 +6508,21 @@ async processOrderedPackets() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delivery receipt: tell the sender we received a chat message (by id), so
|
||||
* their bubble can flip from "sent" (✓) to "delivered" (✓✓). Best-effort,
|
||||
* over the same authenticated control channel as unsend.
|
||||
* @param {string} messageId
|
||||
* @returns {boolean}
|
||||
*/
|
||||
sendDeliveryReceipt(messageId) {
|
||||
if (typeof messageId !== 'string' || !messageId) return false;
|
||||
return this.sendSystemMessage({
|
||||
type: EnhancedSecureWebRTCManager.MESSAGE_TYPES.MESSAGE_RECEIPT,
|
||||
messageId: messageId.slice(0, 64)
|
||||
});
|
||||
}
|
||||
|
||||
async sendMessage(data, meta = null) {
|
||||
// Comprehensive input validation
|
||||
const validation = this._validateInputData(data, 'sendMessage');
|
||||
@@ -6808,7 +6840,16 @@ async processMessage(data) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Delivery receipt from the peer → flip our bubble to "delivered".
|
||||
if (parsed.type === EnhancedSecureWebRTCManager.MESSAGE_TYPES.MESSAGE_RECEIPT) {
|
||||
const messageId = parsed?.data?.messageId ?? parsed?.messageId;
|
||||
if (typeof messageId === 'string' && messageId) {
|
||||
try { this.onMessageDelivered?.(messageId.slice(0, 64)); } catch (_) {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// SYSTEM MESSAGES (WITHOUT MUTEX)
|
||||
// ============================================
|
||||
@@ -7861,6 +7902,15 @@ async processMessage(data) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Delivery receipt from the peer → mark our message "delivered".
|
||||
if (parsed.type === EnhancedSecureWebRTCManager.MESSAGE_TYPES.MESSAGE_RECEIPT) {
|
||||
const messageId = parsed?.data?.messageId ?? parsed?.messageId;
|
||||
if (typeof messageId === 'string' && messageId) {
|
||||
try { this.onMessageDelivered?.(messageId.slice(0, 64)); } catch (_) {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// SYSTEM MESSAGES (WITHOUT MUTEX)
|
||||
// ============================================
|
||||
|
||||
@@ -7,7 +7,10 @@ class PWAInstallPrompt {
|
||||
this.dismissedCount = 0;
|
||||
this.maxDismissals = 3;
|
||||
this.installationChecked = false;
|
||||
|
||||
// Per-page-load dismissal: hide the pill until the next reload/visit
|
||||
// instead of locking it out for 24h, so it reliably comes back.
|
||||
this.userDismissed = false;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
@@ -150,34 +153,45 @@ class PWAInstallPrompt {
|
||||
return;
|
||||
}
|
||||
|
||||
// Compact "pill" install prompt — translated from the Claude Design
|
||||
// component (Install Prompt.dc.html, compact variant). Styling is inline
|
||||
// so it tracks the design without relying on Tailwind/global CSS.
|
||||
this.installButton = document.createElement('div');
|
||||
this.installButton.id = 'pwa-install-button';
|
||||
this.installButton.className = 'hidden fixed bottom-6 right-6 bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white px-6 py-3 rounded-full shadow-lg transition-all duration-300 z-50 flex items-center space-x-3 group';
|
||||
|
||||
const buttonText = this.isIOSSafari() ? 'Install App' : 'Install App';
|
||||
const buttonIcon = this.isIOSSafari() ? 'fas fa-share' : 'fas fa-download';
|
||||
|
||||
this.installButton.className = 'hidden';
|
||||
this.installButton.style.cssText = "position:fixed; bottom:24px; right:24px; z-index:50; font-family:'Manrope',system-ui,-apple-system,sans-serif;";
|
||||
|
||||
this.installButton.innerHTML = `
|
||||
<i class="${buttonIcon} transition-transform group-hover:scale-110"></i>
|
||||
<span class="font-medium">${buttonText}</span>
|
||||
<button class="close-btn absolute -top-2 -right-2 w-6 h-6 bg-red-500 hover:bg-red-600 rounded-full text-white text-xs flex items-center justify-center transition-colors">
|
||||
×
|
||||
</button>
|
||||
<div style="position:relative; display:inline-flex;">
|
||||
<button class="close-btn" type="button" title="Dismiss" aria-label="Dismiss" style="position:absolute; top:-11px; right:-11px; z-index:3; width:28px; height:28px; padding:0; border-radius:50%; display:grid; place-items:center; border:1px solid rgba(255,255,255,0.1); background:#1a1a1d; color:#9a9aa2; cursor:pointer; transition:all .18s cubic-bezier(.2,.7,.3,1);">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="pointer-events:none;"><path d="M6 6l12 12M18 6L6 18"/></svg>
|
||||
</button>
|
||||
<button class="install-pill" type="button" style="display:inline-flex; align-items:center; gap:11px; padding:15px 26px 15px 22px; border-radius:15px; border:none; background:#f0892a; color:#1a0f04; font-family:inherit; font-size:16px; font-weight:700; letter-spacing:-0.2px; cursor:pointer; box-shadow:0 10px 30px rgba(240,137,42,0.32); transition:all .2s cubic-bezier(.2,.7,.3,1);">
|
||||
<svg width="20" height="20" 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="M12 3v11"/><path d="M7.5 10.5L12 15l4.5-4.5"/><path d="M5 20h14"/></svg>
|
||||
Install App
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const pill = this.installButton.querySelector('.install-pill');
|
||||
pill.addEventListener('mouseenter', () => { pill.style.background = '#ff9637'; pill.style.transform = 'translateY(-2px)'; });
|
||||
pill.addEventListener('mouseleave', () => { pill.style.background = '#f0892a'; pill.style.transform = 'none'; });
|
||||
|
||||
const closeBtn = this.installButton.querySelector('.close-btn');
|
||||
closeBtn.addEventListener('mouseenter', () => { closeBtn.style.color = '#e5727a'; closeBtn.style.borderColor = 'rgba(229,114,122,0.4)'; closeBtn.style.background = '#201416'; });
|
||||
closeBtn.addEventListener('mouseleave', () => { closeBtn.style.color = '#9a9aa2'; closeBtn.style.borderColor = 'rgba(255,255,255,0.1)'; closeBtn.style.background = '#1a1a1d'; });
|
||||
|
||||
this.installButton.addEventListener('click', (e) => {
|
||||
if (!e.target.classList.contains('close-btn')) {
|
||||
if (!e.target.closest('.close-btn')) {
|
||||
this.handleInstallClick();
|
||||
}
|
||||
});
|
||||
|
||||
const closeBtn = this.installButton.querySelector('.close-btn');
|
||||
closeBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.dismissInstallPrompt();
|
||||
});
|
||||
|
||||
|
||||
document.body.appendChild(this.installButton);
|
||||
}
|
||||
|
||||
@@ -410,45 +424,73 @@ class PWAInstallPrompt {
|
||||
}
|
||||
|
||||
showFallbackInstructions() {
|
||||
// Per-browser install guide — translated from the Claude Design component
|
||||
// (Install Guide.dc.html). Styling is inline so it tracks the design.
|
||||
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-install-guide';
|
||||
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 rowIcon = {
|
||||
chromeEdge: '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 9h18"/><path d="M12 12v4M10 14l2 2 2-2"/>',
|
||||
firefox: '<path d="M6 3h12v18l-6-4-6 4z"/>',
|
||||
safari: '<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"/>'
|
||||
};
|
||||
|
||||
const row = (icon, title, desc, delay, nowrap) => `
|
||||
<div style="display:flex; align-items:center; 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:40px; height:40px; border-radius:11px; display:grid; place-items:center; background:rgba(240,137,42,0.1); border:1px solid rgba(240,137,42,0.22);">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#f0892a" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">${icon}</svg>
|
||||
</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; color:#8a8a92;${nowrap ? ' white-space:nowrap;' : ''}">${desc}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="bg-gray-800 rounded-xl p-6 max-w-md w-full text-center">
|
||||
<div class="w-16 h-16 bg-orange-500/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<i class="fas fa-download text-orange-400 text-2xl"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-white mb-4">Install SecureBit.chat</h3>
|
||||
<p class="text-gray-300 text-sm mb-6 leading-relaxed">
|
||||
To install this app, look for the install option in your browser menu or address bar.
|
||||
Different browsers have different install methods.
|
||||
</p>
|
||||
|
||||
<div class="space-y-3 text-left text-sm">
|
||||
<div class="bg-gray-700/50 rounded-lg p-3">
|
||||
<div class="font-medium text-white mb-1">Chrome/Edge</div>
|
||||
<div class="text-gray-400">Look for install icon in address bar</div>
|
||||
</div>
|
||||
<div class="bg-gray-700/50 rounded-lg p-3">
|
||||
<div class="font-medium text-white mb-1">Firefox</div>
|
||||
<div class="text-gray-400">Add bookmark to home screen</div>
|
||||
</div>
|
||||
<div class="bg-gray-700/50 rounded-lg p-3">
|
||||
<div class="font-medium text-white mb-1">Safari</div>
|
||||
<div class="text-gray-400">Share → Add to Home Screen</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="close-btn w-full bg-orange-500 hover:bg-orange-600 text-white py-3 px-4 rounded-lg font-medium transition-colors mt-6">
|
||||
Close
|
||||
<div style="position:relative; z-index:2; width:480px; 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="Close" aria-label="Close" style="position:absolute; top:18px; right: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:24px;">
|
||||
<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 3v11"/><path d="M7.5 10.5L12 15l4.5-4.5"/><path d="M5 20h14"/></svg>
|
||||
</div>
|
||||
<h3 style="margin:0 0 10px; font-size:24px; font-weight:800; letter-spacing:-0.6px; color:#f4f4f6;">Install SecureBit</h3>
|
||||
<p style="margin:0 auto; max-width:380px; font-size:14px; line-height:1.55; color:#9a9aa2;">Your browser handles installs its own way. Pick the steps that match yours.</p>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; flex-direction:column; gap:10px; margin-bottom:22px;">
|
||||
${row(rowIcon.chromeEdge, 'Chrome / Edge', 'Click the install icon in the address bar', '.34s', false)}
|
||||
${row(rowIcon.firefox, 'Firefox', 'Add a bookmark to your home screen', '.42s', false)}
|
||||
${row(rowIcon.safari, 'Safari', 'Share → Add to Home Screen', '.5s', true)}
|
||||
</div>
|
||||
|
||||
<button class="got-it" type="button" style="width:100%; padding:14px 20px; border-radius:13px; border:1px solid rgba(255,255,255,0.1); background:rgba(255,255,255,0.03); color:#e8e8eb; font-family:inherit; font-size:15px; font-weight:700; cursor:pointer; transition:all .2s cubic-bezier(.2,.7,.3,1);">Got it</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const closeBtn = modal.querySelector('.close-btn');
|
||||
closeBtn.addEventListener('click', () => {
|
||||
modal.remove();
|
||||
});
|
||||
|
||||
|
||||
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');
|
||||
gotIt.addEventListener('mouseenter', () => { gotIt.style.borderColor = 'rgba(255,255,255,0.22)'; gotIt.style.background = 'rgba(255,255,255,0.06)'; });
|
||||
gotIt.addEventListener('mouseleave', () => { gotIt.style.borderColor = 'rgba(255,255,255,0.1)'; gotIt.style.background = 'rgba(255,255,255,0.03)'; });
|
||||
|
||||
const close = () => modal.remove();
|
||||
closeX.addEventListener('click', close);
|
||||
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);
|
||||
}
|
||||
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
@@ -500,35 +542,28 @@ class PWAInstallPrompt {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Hidden only for the current page load once the user dismisses it;
|
||||
// a reload or a fresh visit surfaces it again (until installed).
|
||||
if (this.userDismissed) return false;
|
||||
|
||||
if (this.isIOSSafari()) {
|
||||
const lastShown = preferences.ios_instructions_shown;
|
||||
|
||||
|
||||
if (lastShown && Date.now() - lastShown < 24 * 60 * 60 * 1000) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (preferences.dismissed >= this.maxDismissals) return false;
|
||||
|
||||
const lastDismissed = preferences.lastDismissed;
|
||||
if (lastDismissed && Date.now() - lastDismissed < 24 * 60 * 60 * 1000) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
dismissInstallPrompt() {
|
||||
this.userDismissed = true;
|
||||
this.dismissedCount++;
|
||||
this.hideInstallPrompts();
|
||||
this.saveInstallPreference('dismissed', this.dismissedCount);
|
||||
|
||||
// Show encouraging message on final dismissal
|
||||
if (this.dismissedCount >= this.maxDismissals) {
|
||||
this.showFinalDismissalMessage();
|
||||
}
|
||||
}
|
||||
|
||||
handleInstallDismissal() {
|
||||
|
||||
@@ -218,31 +218,38 @@ class PWAOfflineManager {
|
||||
updateConnectionStatus(isOnline) {
|
||||
if (!this.offlineIndicator) return;
|
||||
|
||||
// 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).
|
||||
const PILL = "display:inline-flex; align-items:center; gap:10px; padding:9px 14px; border-radius:11px; background:#161618; box-shadow:0 12px 30px rgba(0,0,0,0.45); font-family:'Manrope',system-ui,-apple-system,sans-serif; font-size:13px; font-weight:600; color:#e8e8eb;";
|
||||
|
||||
if (isOnline) {
|
||||
this.offlineIndicator.innerHTML = `
|
||||
<div class="pwa-online-indicator flex items-center space-x-2 bg-green-500/90 text-white px-4 py-2 rounded-full backdrop-blur-sm">
|
||||
<div class="w-2 h-2 bg-green-200 rounded-full animate-pulse"></div>
|
||||
<span class="text-sm font-medium">🌐 Back online</span>
|
||||
</div>
|
||||
`;
|
||||
this.offlineIndicator.innerHTML =
|
||||
`<div style="${PILL} border:1px solid rgba(62,207,142,0.3);">
|
||||
<span style="width:8px; height:8px; border-radius:50%; background:#3ecf8e; box-shadow:0 0 8px rgba(62,207,142,0.6);"></span>
|
||||
<span>Back online</span>
|
||||
</div>`;
|
||||
this.offlineIndicator.classList.remove('hidden');
|
||||
|
||||
// Hide after 3 seconds
|
||||
// Auto-hide after 3 seconds.
|
||||
setTimeout(() => {
|
||||
this.offlineIndicator.classList.add('hidden');
|
||||
if (this.offlineIndicator) this.offlineIndicator.classList.add('hidden');
|
||||
}, 3000);
|
||||
} else {
|
||||
this.offlineIndicator.innerHTML = `
|
||||
<div class="pwa-offline-indicator flex items-center space-x-2 bg-red-500/90 text-white px-4 py-2 rounded-full backdrop-blur-sm">
|
||||
<div class="w-2 h-2 bg-red-200 rounded-full"></div>
|
||||
<span class="text-sm font-medium">📴 Offline mode</span>
|
||||
<button onclick="this.parentElement.parentElement.classList.add('hidden')"
|
||||
class="ml-2 text-red-200 hover:text-white">
|
||||
<i class="fas fa-times text-xs"></i>
|
||||
this.offlineIndicator.innerHTML =
|
||||
`<div style="${PILL} border:1px solid rgba(227,179,65,0.32);">
|
||||
<span style="width:8px; height:8px; border-radius:50%; background:#e3b341;"></span>
|
||||
<span>Offline mode</span>
|
||||
<button class="oi-close" type="button" aria-label="Dismiss" style="margin-left:4px; width:22px; height:22px; padding:0; display:grid; place-items:center; border:none; background:transparent; color:#8a8a92; cursor:pointer; border-radius:6px; transition:color .15s ease;">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="pointer-events:none;"><path d="M6 6l12 12M18 6L6 18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
</div>`;
|
||||
this.offlineIndicator.classList.remove('hidden');
|
||||
const closeBtn = this.offlineIndicator.querySelector('.oi-close');
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('mouseenter', () => { closeBtn.style.color = '#e8e8eb'; });
|
||||
closeBtn.addEventListener('mouseleave', () => { closeBtn.style.color = '#8a8a92'; });
|
||||
closeBtn.addEventListener('click', () => this.offlineIndicator.classList.add('hidden'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,63 +292,138 @@ class PWAOfflineManager {
|
||||
return;
|
||||
}
|
||||
|
||||
// Offline modal — translated from the Claude Design component
|
||||
// (Offline Modal.dc.html). Two views (main + details) inside one card.
|
||||
if (!document.getElementById('pwa-offline-modal-kf')) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'pwa-offline-modal-kf';
|
||||
style.textContent =
|
||||
'@keyframes omPop{from{opacity:0;transform:scale(.96) translateY(10px)}to{opacity:1;transform:scale(1) translateY(0)}}' +
|
||||
'@keyframes omFade{from{opacity:0}to{opacity:1}}' +
|
||||
'@keyframes omSwap{from{opacity:0;transform:translateX(10px)}to{opacity:1;transform:translateX(0)}}';
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
const guidance = document.createElement('div');
|
||||
guidance.className = 'fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 backdrop-blur-sm';
|
||||
guidance.innerHTML = `
|
||||
<div class="bg-gray-800 rounded-xl p-6 max-w-md w-full text-center">
|
||||
<div class="w-16 h-16 bg-red-500/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<i class="fas fa-wifi-slash text-red-400 text-2xl"></i>
|
||||
guidance.id = 'pwa-offline-modal';
|
||||
guidance.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:omFade .3s ease; font-family:'Manrope',system-ui,-apple-system,sans-serif;";
|
||||
|
||||
const feature = (bg, bd, stroke, sw, icon, text) => `
|
||||
<div style="display:flex; align-items:center; gap:13px;">
|
||||
<span style="flex:none; width:34px; height:34px; border-radius:9px; display:grid; place-items:center; background:${bg}; border:1px solid ${bd};"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="${stroke}" stroke-width="${sw}" stroke-linecap="round" stroke-linejoin="round">${icon}</svg></span>
|
||||
<span style="font-size:14.5px; color:#e8e8eb;">${text}</span>
|
||||
</div>`;
|
||||
|
||||
const card = (bg, bd, stroke, icon, title, desc) => `
|
||||
<div style="display:flex; align-items:flex-start; gap:13px; padding:14px 16px; border-radius:13px; background:#161618; border:1px solid rgba(255,255,255,0.06);">
|
||||
<span style="flex:none; width:36px; height:36px; border-radius:10px; display:grid; place-items:center; background:${bg}; border:1px solid ${bd};"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="${stroke}" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">${icon}</svg></span>
|
||||
<div><div style="font-size:14.5px; font-weight:700; color:#f4f4f6; margin-bottom:2px;">${title}</div><div style="font-size:13px; line-height:1.5; color:#8a8a92;">${desc}</div></div>
|
||||
</div>`;
|
||||
|
||||
const GREEN_BG = 'rgba(62,207,142,0.12)', GREEN_BD = 'rgba(62,207,142,0.24)';
|
||||
const ORANGE_BG = 'rgba(240,137,42,0.12)', ORANGE_BD = 'rgba(240,137,42,0.24)';
|
||||
|
||||
const mainHTML = `
|
||||
<div style="animation:omSwap .26s cubic-bezier(.2,.7,.3,1);">
|
||||
<div style="text-align:center; margin-bottom:22px;">
|
||||
<div style="display:inline-flex; width:64px; height:64px; border-radius:50%; align-items:center; justify-content:center; background:rgba(227,179,65,0.12); border:1px solid rgba(227,179,65,0.3); margin-bottom:18px;">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#e3b341" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M2 8.8a15 15 0 0 1 20 0"/><path d="M5 12.5a11 11 0 0 1 14 0"/><path d="M8.5 16.3a6 6 0 0 1 7 0"/><path d="M12 20h.01"/><path d="M2 2l20 20"/></svg>
|
||||
</div>
|
||||
<h3 style="margin:0 0 10px; font-size:24px; font-weight:800; letter-spacing:-0.6px; color:#f4f4f6;">Connection lost</h3>
|
||||
<p style="margin:0 auto; max-width:380px; font-size:14px; line-height:1.55; color:#9a9aa2;">SecureBit is now in offline mode. Some features are limited, but your data stays safe.</p>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-white mb-3">Connection Lost</h3>
|
||||
<p class="text-gray-300 mb-4 text-sm leading-relaxed">
|
||||
SecureBit.chat is now in offline mode. Some features are limited, but your data is safe.
|
||||
</p>
|
||||
|
||||
<div class="space-y-3 text-left mb-6">
|
||||
<div class="flex items-center text-sm">
|
||||
<div class="w-6 h-6 bg-green-500/20 rounded flex items-center justify-center mr-3">
|
||||
<i class="fas fa-check text-green-400 text-xs"></i>
|
||||
</div>
|
||||
<span class="text-gray-300">Your session and keys are preserved</span>
|
||||
</div>
|
||||
<div class="flex items-center text-sm">
|
||||
<div class="w-6 h-6 bg-green-500/20 rounded flex items-center justify-center mr-3">
|
||||
<i class="fas fa-shield-alt text-green-400 text-xs"></i>
|
||||
</div>
|
||||
<span class="text-gray-300">No data is stored on servers</span>
|
||||
</div>
|
||||
<div class="flex items-center text-sm">
|
||||
<div class="w-6 h-6 bg-blue-500/20 rounded flex items-center justify-center mr-3">
|
||||
<i class="fas fa-sync-alt text-blue-400 text-xs"></i>
|
||||
</div>
|
||||
<span class="text-gray-300">Messages will sync when online</span>
|
||||
</div>
|
||||
<div style="display:flex; flex-direction:column; gap:14px; margin-bottom:24px; padding:0 6px;">
|
||||
${feature(GREEN_BG, GREEN_BD, '#3ecf8e', '2.3', '<path d="M5 13l4 4 10-11"/>', 'Your session and keys are preserved')}
|
||||
${feature(GREEN_BG, GREEN_BD, '#3ecf8e', '1.9', '<path d="M12 3l8 4v5c0 4.5-3.2 7.8-8 9-4.8-1.2-8-4.5-8-9V7l8-4z"/>', 'No data is stored on servers')}
|
||||
${feature(ORANGE_BG, ORANGE_BD, '#f0892a', '1.9', '<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"/>', 'Messages & files sync when you reconnect')}
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-3">
|
||||
<button onclick="this.parentElement.parentElement.remove()"
|
||||
class="flex-1 bg-orange-500 hover:bg-orange-600 text-white py-3 px-4 rounded-lg font-medium transition-colors">
|
||||
Continue Offline
|
||||
</button>
|
||||
<button onclick="window.pwaOfflineManager.showOfflineHelp(); this.parentElement.parentElement.parentElement.remove();"
|
||||
class="flex-1 bg-gray-600 hover:bg-gray-500 text-white py-3 px-4 rounded-lg font-medium transition-colors">
|
||||
Learn More
|
||||
<div style="display:flex; flex-direction:column; gap:11px;">
|
||||
<div style="display:flex; gap:12px;">
|
||||
<button class="om-continue" type="button" style="flex:1; padding:14px 18px; border-radius:13px; border:none; background:#f0892a; color:#1a0f04; font-family:inherit; font-size:15px; font-weight:700; cursor:pointer; box-shadow:0 8px 24px rgba(240,137,42,0.28); transition:all .2s cubic-bezier(.2,.7,.3,1);">Continue offline</button>
|
||||
<button class="om-disconnect" type="button" style="flex:1; display:inline-flex; align-items:center; justify-content:center; gap:9px; padding:14px 18px; border-radius:13px; border:1px solid rgba(229,114,122,0.3); background:rgba(229,114,122,0.08); color:#e5727a; font-family:inherit; font-size:15px; font-weight:700; cursor:pointer; transition:all .2s cubic-bezier(.2,.7,.3,1);">
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="pointer-events:none;"><path d="M12 4v8"/><path d="M7 7a8 8 0 1 0 10 0"/></svg>
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
<button class="om-learn" type="button" style="width:100%; display:inline-flex; align-items:center; justify-content:center; gap:8px; padding:12px 18px; border-radius:13px; border:none; background:transparent; color:#9a9aa2; font-family:inherit; font-size:14px; font-weight:600; cursor:pointer; transition:color .18s cubic-bezier(.2,.7,.3,1);">
|
||||
Learn more
|
||||
<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="M5 12h14M13 6l6 6-6 6"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
</div>`;
|
||||
|
||||
const detailsHTML = `
|
||||
<div style="animation:omSwap .26s cubic-bezier(.2,.7,.3,1);">
|
||||
<div style="display:flex; align-items:center; gap:12px; margin-bottom:18px;">
|
||||
<button class="om-back" type="button" title="Back" style="flex:none; width:34px; height:34px; border-radius:10px; display:grid; place-items:center; border:1px solid rgba(255,255,255,0.1); background:rgba(255,255,255,0.025); color:#cfcfd4; cursor:pointer; transition:all .18s cubic-bezier(.2,.7,.3,1);">
|
||||
<svg width="17" height="17" 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="M15 6l-6 6 6 6"/></svg>
|
||||
</button>
|
||||
<h3 style="margin:0; font-size:20px; font-weight:800; letter-spacing:-0.5px; color:#f4f4f6;">When you reconnect</h3>
|
||||
</div>
|
||||
<p style="margin:0 0 20px; font-size:14px; line-height:1.6; color:#9a9aa2;">A dropped connection costs you nothing. SecureBit queues everything locally and resumes the encrypted session the instant you're back online.</p>
|
||||
<div style="display:flex; flex-direction:column; gap:11px; margin-bottom:22px;">
|
||||
${card(GREEN_BG, GREEN_BD, '#3ecf8e', '<path d="M22 2L11 13"/><path d="M22 2l-7 20-4-9-9-4z"/>', 'Your messages get delivered', 'Everything you wrote while offline is sent to your contact automatically.')}
|
||||
${card(GREEN_BG, GREEN_BD, '#3ecf8e', '<path d="M14 3v5h5"/><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M12 18v-6M9.5 14.5L12 12l2.5 2.5"/>', 'Files finish transferring', 'Uploads resume from where they stopped — no need to resend.')}
|
||||
${card(GREEN_BG, GREEN_BD, '#3ecf8e', '<path d="M12 3v12"/><path d="M7.5 10.5L12 15l4.5-4.5"/><path d="M5 20h14"/>', 'Their messages & files arrive', 'Whatever your contact sent during the outage is delivered to you in order.')}
|
||||
${card(ORANGE_BG, ORANGE_BD, '#f0892a', '<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"/>', 'Nothing is lost', "After reconnect there's no gap — the conversation continues exactly where it paused.")}
|
||||
</div>
|
||||
<button class="om-gotit" type="button" style="width:100%; padding:14px 18px; border-radius:13px; border:none; background:#f0892a; color:#1a0f04; font-family:inherit; font-size:15px; font-weight:700; cursor:pointer; box-shadow:0 8px 24px rgba(240,137,42,0.28); transition:all .2s cubic-bezier(.2,.7,.3,1);">Got it</button>
|
||||
</div>`;
|
||||
|
||||
const cardWrap = document.createElement('div');
|
||||
cardWrap.style.cssText = "position:relative; z-index:2; width:470px; 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:omPop .32s cubic-bezier(.2,.7,.3,1);";
|
||||
guidance.appendChild(cardWrap);
|
||||
|
||||
const hoverLift = (btn) => {
|
||||
btn.addEventListener('mouseenter', () => { btn.style.background = '#ff9637'; btn.style.transform = 'translateY(-2px)'; });
|
||||
btn.addEventListener('mouseleave', () => { btn.style.background = '#f0892a'; btn.style.transform = 'none'; });
|
||||
};
|
||||
const close = () => guidance.remove();
|
||||
|
||||
const renderMain = () => {
|
||||
cardWrap.innerHTML = mainHTML;
|
||||
const cont = cardWrap.querySelector('.om-continue');
|
||||
hoverLift(cont);
|
||||
cont.addEventListener('click', close);
|
||||
|
||||
const disc = cardWrap.querySelector('.om-disconnect');
|
||||
disc.addEventListener('mouseenter', () => { disc.style.background = 'rgba(229,114,122,0.14)'; disc.style.borderColor = 'rgba(229,114,122,0.5)'; });
|
||||
disc.addEventListener('mouseleave', () => { disc.style.background = 'rgba(229,114,122,0.08)'; disc.style.borderColor = 'rgba(229,114,122,0.3)'; });
|
||||
disc.addEventListener('click', () => {
|
||||
try {
|
||||
if (window.webrtcManager && typeof window.webrtcManager.disconnect === 'function') {
|
||||
window.webrtcManager.disconnect();
|
||||
}
|
||||
} catch (e) { console.warn('Offline modal disconnect failed:', e); }
|
||||
close();
|
||||
});
|
||||
|
||||
const learn = cardWrap.querySelector('.om-learn');
|
||||
learn.addEventListener('mouseenter', () => { learn.style.color = '#f0892a'; });
|
||||
learn.addEventListener('mouseleave', () => { learn.style.color = '#9a9aa2'; });
|
||||
learn.addEventListener('click', renderDetails);
|
||||
};
|
||||
|
||||
const renderDetails = () => {
|
||||
cardWrap.innerHTML = detailsHTML;
|
||||
const back = cardWrap.querySelector('.om-back');
|
||||
back.addEventListener('mouseenter', () => { back.style.color = '#f0892a'; back.style.borderColor = 'rgba(240,137,42,0.45)'; });
|
||||
back.addEventListener('mouseleave', () => { back.style.color = '#cfcfd4'; back.style.borderColor = 'rgba(255,255,255,0.1)'; });
|
||||
back.addEventListener('click', renderMain);
|
||||
|
||||
const gotit = cardWrap.querySelector('.om-gotit');
|
||||
hoverLift(gotit);
|
||||
gotit.addEventListener('click', renderMain);
|
||||
};
|
||||
|
||||
renderMain();
|
||||
// Click on the backdrop (outside the card) dismisses.
|
||||
guidance.addEventListener('click', (e) => { if (e.target === guidance) close(); });
|
||||
|
||||
document.body.appendChild(guidance);
|
||||
|
||||
|
||||
// Save that we showed the guidance
|
||||
localStorage.setItem('offline_guidance_shown', Date.now().toString());
|
||||
|
||||
// Auto-remove after 15 seconds
|
||||
setTimeout(() => {
|
||||
if (guidance.parentElement) {
|
||||
guidance.remove();
|
||||
}
|
||||
}, 15000);
|
||||
}
|
||||
|
||||
startReconnectionAttempts() {
|
||||
|
||||
@@ -8,10 +8,8 @@ import '../components/ui/Header.jsx';
|
||||
import '../components/ui/DownloadApps.jsx';
|
||||
import '../components/ui/BecomePartner.jsx';
|
||||
import '../components/ui/UniqueFeatureSlider.jsx';
|
||||
import '../components/ui/SecurityFeatures.jsx';
|
||||
import '../components/ui/Testimonials.jsx';
|
||||
import '../components/ui/ComparisonTable.jsx';
|
||||
import '../components/ui/Roadmap.jsx';
|
||||
import '../components/ui/CommunityCTA.jsx';
|
||||
import '../components/ui/FileTransfer.jsx';
|
||||
import '../components/ui/IceServerSettings.jsx';
|
||||
|
||||
|
||||
@@ -24,41 +24,98 @@ if (window.DEBUG_MODE) {
|
||||
console.log('✅ Global timer management functions loaded');
|
||||
}
|
||||
|
||||
// Inline onclick replacement for update notification button
|
||||
function attachUpdateNotificationHandlers(container) {
|
||||
const btn = container.querySelector('[data-action="reload"]');
|
||||
if (btn) {
|
||||
btn.addEventListener('click', () => window.location.reload());
|
||||
}
|
||||
const dismissBtn = container.querySelector('[data-action="dismiss-notification"]');
|
||||
if (dismissBtn) {
|
||||
dismissBtn.addEventListener('click', () => {
|
||||
const host = dismissBtn.closest('div');
|
||||
if (host && host.parentElement) host.parentElement.remove();
|
||||
// Format a version (build timestamp -> date, or pass through a semver string)
|
||||
function formatUpdateVersion(v) {
|
||||
if (!v) return null;
|
||||
if (/^\d+$/.test(String(v))) {
|
||||
return new Date(parseInt(v, 10)).toLocaleString('en-US', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
}
|
||||
return String(v);
|
||||
}
|
||||
|
||||
// Update notification — translated from the Claude Design component
|
||||
// (Update Notification.dc.html). Centered modal with version comparison.
|
||||
window.showUpdateNotification = function showUpdateNotification() {
|
||||
if (window.DEBUG_MODE) console.log('🆕 Showing update notification for PWA');
|
||||
const notification = document.createElement('div');
|
||||
notification.className = 'fixed top-4 left-1/2 transform -translate-x-1/2 bg-blue-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-download text-lg"></i>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium">Update Available</div>
|
||||
<div class="text-sm opacity-90">SecureBit.chat v4.4.18 - ECDH + DTLS + SAS is ready</div>
|
||||
</div>
|
||||
<button data-action="reload" class="bg-white/20 hover:bg-white/30 px-3 py-1 rounded text-sm font-medium transition-colors">
|
||||
Update
|
||||
</button>
|
||||
</div>`;
|
||||
document.body.appendChild(notification);
|
||||
attachUpdateNotificationHandlers(notification);
|
||||
setTimeout(() => {
|
||||
if (notification.parentElement) notification.remove();
|
||||
}, 30000);
|
||||
|
||||
// Avoid stacking duplicates if the SW fires more than once.
|
||||
const existing = document.getElementById('pwa-update-modal');
|
||||
if (existing) existing.remove();
|
||||
|
||||
if (!document.getElementById('pwa-update-modal-kf')) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'pwa-update-modal-kf';
|
||||
style.textContent =
|
||||
'@keyframes unPop{from{opacity:0;transform:scale(.96) translateY(10px)}to{opacity:1;transform:scale(1) translateY(0)}}' +
|
||||
'@keyframes unFade{from{opacity:0}to{opacity:1}}' +
|
||||
'@keyframes unSpin{to{transform:rotate(360deg)}}';
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
let currentVersion = null;
|
||||
try { currentVersion = localStorage.getItem('app_version'); } catch (e) {}
|
||||
const currentStr = formatUpdateVersion(currentVersion) || 'Installed build';
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'pwa-update-modal';
|
||||
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:unFade .3s ease; font-family:'Manrope',system-ui,-apple-system,sans-serif;";
|
||||
|
||||
modal.innerHTML = `
|
||||
<div style="position:relative; width:440px; max-width:calc(100vw - 48px); border-radius:22px; background:#121214; border:1px solid rgba(255,255,255,0.08); padding:36px 32px 28px; text-align:center; box-shadow:0 30px 70px rgba(0,0,0,0.6); animation:unPop .32s cubic-bezier(.2,.7,.3,1);">
|
||||
<div style="display:inline-flex; width:64px; height:64px; border-radius:50%; align-items:center; justify-content:center; background:rgba(240,137,42,0.12); border:1px solid rgba(240,137,42,0.3); margin-bottom:20px;">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#f0892a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="animation:unSpin 6s linear infinite;"><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"/></svg>
|
||||
</div>
|
||||
<h2 style="margin:0 0 9px; font-size:26px; font-weight:800; letter-spacing:-0.7px; color:#f4f4f6;">Update available</h2>
|
||||
<p style="margin:0 0 24px; font-size:14.5px; line-height:1.55; color:#9a9aa2;">A newer version of SecureBit has been detected.</p>
|
||||
|
||||
<div style="border-radius:14px; background:#0c0c0e; border:1px solid rgba(255,255,255,0.06); padding:16px 18px; margin-bottom:24px; text-align:left;">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:14px; padding:5px 0;">
|
||||
<span style="font-size:13.5px; font-weight:500; color:#8a8a92;">Current version</span>
|
||||
<span class="cur-ver" style="font-family:'JetBrains Mono',ui-monospace,Menlo,monospace; font-size:13px; font-weight:500; color:#9a9aa2; white-space:nowrap;">${currentStr}</span>
|
||||
</div>
|
||||
<div style="height:1px; background:rgba(255,255,255,0.05); margin:4px 0;"></div>
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:14px; padding:5px 0;">
|
||||
<span style="display:inline-flex; align-items:center; gap:8px; font-size:13.5px; font-weight:600; color:#e8e8eb;"><span style="width:6px; height:6px; border-radius:50%; background:#f0892a;"></span>New version</span>
|
||||
<span class="new-ver" style="font-family:'JetBrains Mono',ui-monospace,Menlo,monospace; font-size:13px; font-weight:700; color:#f0892a; white-space:nowrap;">Latest</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; align-items:center; gap:12px;">
|
||||
<button class="upd-now" type="button" style="flex:1; display:inline-flex; align-items:center; justify-content:center; gap:10px; padding:15px 20px; border-radius:13px; border:none; background:#f0892a; color:#1a0f04; font-family:inherit; font-size:15.5px; font-weight:700; letter-spacing:-0.2px; cursor:pointer; box-shadow:0 8px 24px rgba(240,137,42,0.28); transition:all .2s cubic-bezier(.2,.7,.3,1);">
|
||||
<svg width="18" height="18" 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="M12 3v11"/><path d="M7.5 10.5L12 15l4.5-4.5"/><path d="M5 20h14"/></svg>
|
||||
Update now
|
||||
</button>
|
||||
<button class="upd-later" type="button" title="Later" style="flex:none; width:50px; height:50px; border-radius:13px; display:grid; place-items:center; border:1px solid rgba(255,255,255,0.1); background:rgba(255,255,255,0.025); color:#9a9aa2; cursor:pointer; transition:all .18s cubic-bezier(.2,.7,.3,1);">
|
||||
<svg width="17" height="17" 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>
|
||||
</div>`;
|
||||
|
||||
const updNow = modal.querySelector('.upd-now');
|
||||
updNow.addEventListener('mouseenter', () => { updNow.style.background = '#ff9637'; updNow.style.transform = 'translateY(-2px)'; });
|
||||
updNow.addEventListener('mouseleave', () => { updNow.style.background = '#f0892a'; updNow.style.transform = 'none'; });
|
||||
updNow.addEventListener('click', () => window.location.reload());
|
||||
|
||||
const updLater = modal.querySelector('.upd-later');
|
||||
updLater.addEventListener('mouseenter', () => { updLater.style.color = '#e5727a'; updLater.style.borderColor = 'rgba(229,114,122,0.4)'; });
|
||||
updLater.addEventListener('mouseleave', () => { updLater.style.color = '#9a9aa2'; updLater.style.borderColor = 'rgba(255,255,255,0.1)'; });
|
||||
updLater.addEventListener('click', () => modal.remove());
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// Fill in the new version once meta.json is fetched (best-effort).
|
||||
fetch('/meta.json?t=' + Date.now(), { cache: 'no-store' })
|
||||
.then((r) => r.json())
|
||||
.then((meta) => {
|
||||
const label = meta.appVersion
|
||||
? ('v' + meta.appVersion)
|
||||
: (formatUpdateVersion(meta.version || meta.buildVersion) || 'Latest');
|
||||
const el = modal.querySelector('.new-ver');
|
||||
if (el) el.textContent = label;
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
window.showServiceWorkerError = function showServiceWorkerError(error) {
|
||||
|
||||
@@ -732,4 +732,53 @@ button i {
|
||||
100% {
|
||||
left: 250px; /* Move past button width (200px + buffer) */
|
||||
}
|
||||
}
|
||||
}
|
||||
/* ============================================================
|
||||
SecureBit Chat — redesigned chat surface (v4.8.21)
|
||||
============================================================ */
|
||||
.sb-scroll { scrollbar-gutter: auto; }
|
||||
.sb-scroll::-webkit-scrollbar { width: 9px; }
|
||||
.sb-scroll::-webkit-scrollbar-track { background: transparent; }
|
||||
.sb-scroll::-webkit-scrollbar-thumb {
|
||||
background: rgba(255,255,255,0.07);
|
||||
border-radius: 99px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
.sb-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255,255,255,0.13);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
.sb-textarea::placeholder { color: #56565e; }
|
||||
.sb-chip:hover { border-color: rgba(255,255,255,0.16) !important; color: #e8e8eb !important; }
|
||||
.sb-send:hover:not(:disabled) { filter: brightness(1.06); }
|
||||
.sb-unsend:hover { color: #e5727a !important; }
|
||||
.sb-link:hover { color: #e8e8eb !important; }
|
||||
.sb-disconnect:hover { border-color: rgba(229,114,122,0.4) !important; color: #e5727a !important; background: rgba(229,114,122,0.06) !important; }
|
||||
@media (max-width: 560px) { .sb-hide-sm { display: none; } }
|
||||
.sb-secpill:hover { border-color: rgba(255,255,255,0.16) !important; background: rgba(255,255,255,0.05) !important; }
|
||||
|
||||
/* ── Start Secure — new connection screen (design import) ───────────────── */
|
||||
@keyframes sbFlowR { 0% { left: 4%; opacity: 0; } 12% { opacity: 1; } 88% { opacity: 1; } 100% { left: 96%; opacity: 0; } }
|
||||
@keyframes sbFlowL { 0% { left: 96%; opacity: 0; } 12% { opacity: 1; } 88% { opacity: 1; } 100% { left: 4%; opacity: 0; } }
|
||||
@keyframes sbPulse { 0%,100% { transform: translate(-50%,-50%) scale(1); opacity: 0.5; } 50% { transform: translate(-50%,-50%) scale(1.5); opacity: 0; } }
|
||||
@keyframes sbSpin { to { transform: rotate(360deg); } }
|
||||
@keyframes sbUp { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes sbNode { 0%,100% { box-shadow: 0 0 0 0 rgba(62,207,142,0.0); } 50% { box-shadow: 0 0 0 6px rgba(62,207,142,0.06); } }
|
||||
@keyframes sbSlideUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
|
||||
.sb-start textarea::placeholder, .sb-start input::placeholder { color: #56565e; }
|
||||
.sb-start .sb-sc::-webkit-scrollbar { width: 8px; }
|
||||
.sb-start .sb-sc::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 99px; }
|
||||
.sb-seg-btn { transition: color .2s; }
|
||||
.sb-soft-btn { transition: all .15s; }
|
||||
.sb-soft-btn:hover { border-color: rgba(255,255,255,0.18) !important; color: #e8e8eb !important; }
|
||||
.sb-scan-btn:hover { border-color: rgba(62,207,142,0.5) !important; background: rgba(62,207,142,0.1) !important; }
|
||||
.sb-gen-btn:hover { background: #ff9637 !important; }
|
||||
.sb-start-card { transition: transform .26s cubic-bezier(.3,.8,.3,1); }
|
||||
@media (max-width: 900px) { .sb-start-left { border-right: none !important; } }
|
||||
@media (max-width: 560px) { .sb-start-left { padding: 30px 22px !important; } }
|
||||
/* PWA install pill belongs to the landing page only — hide it inside the chat. */
|
||||
body.sb-in-chat #pwa-install-button { display: none !important; }
|
||||
/* The new design spaces icons with flex gap, not icon margins — neutralise the
|
||||
global `button i { margin-right: .5rem }` so icons stay centered in their tiles. */
|
||||
.sb-start button i, .sb-ice-overlay button i { margin-right: 0; vertical-align: baseline; }
|
||||
|
||||
@@ -1,26 +1,8 @@
|
||||
/* PWA Specific Styles for SecureBit.chat */
|
||||
|
||||
/* PWA Install Button */
|
||||
#pwa-install-button {
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 8px 32px rgba(255, 107, 53, 0.3);
|
||||
border: 1px solid rgba(255, 107, 53, 0.2);
|
||||
animation: pulse-install 2s infinite;
|
||||
}
|
||||
|
||||
#pwa-install-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 40px rgba(255, 107, 53, 0.4);
|
||||
}
|
||||
|
||||
@keyframes pulse-install {
|
||||
0%, 100% {
|
||||
box-shadow: 0 8px 32px rgba(255, 107, 53, 0.3);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 8px 32px rgba(255, 107, 53, 0.5);
|
||||
}
|
||||
}
|
||||
/* PWA Install Button — compact pill. All visual styling (orange pill, dismiss
|
||||
chip, shadows, hover) is applied inline in install-prompt.js so it stays in
|
||||
sync with the design; this anchor intentionally adds no decoration. */
|
||||
|
||||
/* PWA Update Banner */
|
||||
#pwa-update-banner {
|
||||
|
||||
@@ -371,9 +371,14 @@ class EnhancedSecureFileTransfer {
|
||||
this.transferQueue = []; // Queue for pending transfers
|
||||
this.pendingChunks = new Map();
|
||||
this.incomingOfferLimiter = new RateLimiter(5, 60000);
|
||||
this.incomingChunkLimiter = new RateLimiter(240, 60000);
|
||||
// Chunks are 16 KB, so a 100 MB file is ~6400 chunks. The previous caps
|
||||
// (240 aggregate / 120 per-transfer per minute) throttled to ~64 KB/s and
|
||||
// KILLED any file larger than ~3.8 MB mid-transfer. Size the limits to the
|
||||
// worst-case file plus retransmission headroom so legitimate transfers are
|
||||
// never starved, while still bounding a flooding peer.
|
||||
this.incomingChunkLimiter = new RateLimiter(60000, 60000); // aggregate ceiling (~16 MB/s)
|
||||
this.incomingTransferChunkLimiters = new Map();
|
||||
this.MAX_INCOMING_CHUNKS_PER_TRANSFER_PER_MINUTE = 120;
|
||||
this.MAX_INCOMING_CHUNKS_PER_TRANSFER_PER_MINUTE = 30000; // per transfer (~8 MB/s)
|
||||
this.MAX_PENDING_INCOMING_TRANSFERS = 3;
|
||||
|
||||
// Session key derivation
|
||||
@@ -675,9 +680,10 @@ class EnhancedSecureFileTransfer {
|
||||
|
||||
const fileMessageTypes = [
|
||||
'file_transfer_start',
|
||||
'file_transfer_response',
|
||||
'file_transfer_response',
|
||||
'file_chunk',
|
||||
'chunk_confirmation',
|
||||
'file_chunk_request',
|
||||
'file_transfer_complete',
|
||||
'file_transfer_error'
|
||||
];
|
||||
@@ -736,7 +742,11 @@ class EnhancedSecureFileTransfer {
|
||||
case 'chunk_confirmation':
|
||||
this.handleChunkConfirmation(message);
|
||||
break;
|
||||
|
||||
|
||||
case 'file_chunk_request':
|
||||
await this.handleChunkRequest(message);
|
||||
break;
|
||||
|
||||
case 'file_transfer_complete':
|
||||
this.handleTransferComplete(message);
|
||||
break;
|
||||
@@ -1033,17 +1043,13 @@ class EnhancedSecureFileTransfer {
|
||||
}
|
||||
|
||||
transferState.status = 'waiting_confirmation';
|
||||
|
||||
// Timeout for completion confirmation
|
||||
setTimeout(() => {
|
||||
if (this.activeTransfers.has(transferState.fileId)) {
|
||||
const state = this.activeTransfers.get(transferState.fileId);
|
||||
if (state.status === 'waiting_confirmation') {
|
||||
this.cleanupTransfer(transferState.fileId);
|
||||
}
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
|
||||
// Keep the file + session key alive while the receiver may still be
|
||||
// re-requesting missing chunks (e.g. after a connection blip). The
|
||||
// sender is only torn down once the receiver confirms completion or
|
||||
// after a long idle with no chunk requests/confirmations.
|
||||
this._armSenderIdleTimeout(transferState);
|
||||
|
||||
} catch (error) {
|
||||
const safeError = SecurityErrorHandler.sanitizeError(error);
|
||||
console.error('❌ Chunk transmission failed:', safeError);
|
||||
@@ -1052,6 +1058,50 @@ class EnhancedSecureFileTransfer {
|
||||
}
|
||||
}
|
||||
|
||||
// Resets a long idle timer; the sender stays available to retransmit missing
|
||||
// chunks until the receiver finishes or this fires after sustained silence.
|
||||
_armSenderIdleTimeout(transferState) {
|
||||
const IDLE_MS = 180000; // 3 minutes with no activity from the receiver
|
||||
if (transferState._idleTimeout) clearTimeout(transferState._idleTimeout);
|
||||
transferState._idleTimeout = setTimeout(() => {
|
||||
const state = this.activeTransfers.get(transferState.fileId);
|
||||
if (state && state.status !== 'completed') {
|
||||
this.cleanupTransfer(transferState.fileId);
|
||||
}
|
||||
}, IDLE_MS);
|
||||
}
|
||||
|
||||
// Receiver asked us to re-send specific chunk indices (loss recovery / resume).
|
||||
async handleChunkRequest(message) {
|
||||
const transferState = this.activeTransfers.get(message?.fileId);
|
||||
if (!transferState || !transferState.file) return;
|
||||
const missing = Array.isArray(message.missing) ? message.missing : [];
|
||||
if (missing.length === 0) return;
|
||||
|
||||
this._armSenderIdleTimeout(transferState);
|
||||
transferState.status = 'transmitting';
|
||||
|
||||
const MAX_PER_REQUEST = 512;
|
||||
const indices = missing.slice(0, MAX_PER_REQUEST);
|
||||
for (const idx of indices) {
|
||||
if (!Number.isInteger(idx) || idx < 0 || idx >= transferState.totalChunks) continue;
|
||||
try {
|
||||
const start = idx * this.CHUNK_SIZE;
|
||||
const end = Math.min(start + this.CHUNK_SIZE, transferState.file.size);
|
||||
const chunkData = await this.readFileChunk(transferState.file, start, end);
|
||||
await this.sendFileChunk(transferState, idx, chunkData);
|
||||
await this.waitForBackpressure();
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Failed to retransmit chunk', idx, SecurityErrorHandler.sanitizeError(error));
|
||||
}
|
||||
}
|
||||
|
||||
if (transferState.status === 'transmitting') {
|
||||
transferState.status = 'waiting_confirmation';
|
||||
}
|
||||
this._armSenderIdleTimeout(transferState);
|
||||
}
|
||||
|
||||
async readFileChunk(file, start, end) {
|
||||
try {
|
||||
const blob = file.slice(start, end);
|
||||
@@ -1261,12 +1311,17 @@ class EnhancedSecureFileTransfer {
|
||||
async () => {
|
||||
try {
|
||||
let receivingState = this.receivingTransfers.get(chunkMessage.fileId);
|
||||
|
||||
|
||||
// Never buffer chunks before explicit consent.
|
||||
if (!receivingState) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Already assembled — ignore late/duplicate (retransmitted) chunks.
|
||||
if (receivingState._assembled || receivingState.status === 'completed') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._isIncomingChunkAllowed(chunkMessage.fileId)) {
|
||||
console.warn('⚠️ Incoming file chunk rate limit exceeded; cleaning up transfer:', chunkMessage.fileId);
|
||||
this.cleanupReceivingTransfer(chunkMessage.fileId);
|
||||
@@ -1331,28 +1386,12 @@ class EnhancedSecureFileTransfer {
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// A single bad/lost chunk must NOT kill the whole transfer:
|
||||
// drop it and let the receiver's stall detector re-request it.
|
||||
// (The data channel is reliable+ordered, so this path is rare —
|
||||
// typically a transient decrypt hiccup or post-cleanup straggler.)
|
||||
const safeError = SecurityErrorHandler.sanitizeError(error);
|
||||
console.error('❌ Failed to handle file chunk:', safeError);
|
||||
|
||||
// Send error notification
|
||||
const errorMessage = {
|
||||
type: 'file_transfer_error',
|
||||
fileId: chunkMessage.fileId,
|
||||
error: safeError,
|
||||
chunkIndex: chunkMessage.chunkIndex,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
await this.sendSecureMessage(errorMessage);
|
||||
|
||||
// Mark transfer as failed
|
||||
const receivingState = this.receivingTransfers.get(chunkMessage.fileId);
|
||||
if (receivingState) {
|
||||
receivingState.status = 'failed';
|
||||
}
|
||||
|
||||
if (this.onError) {
|
||||
this.onError(`Chunk processing failed: ${safeError}`);
|
||||
}
|
||||
console.warn('⚠️ Dropping unprocessable file chunk (will be re-requested):', chunkMessage.chunkIndex, safeError);
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -1486,14 +1525,19 @@ class EnhancedSecureFileTransfer {
|
||||
timestamp: Date.now()
|
||||
};
|
||||
await this.sendSecureMessage(completionMessage);
|
||||
|
||||
// Cleanup
|
||||
if (this.receivingTransfers.has(receivingState.fileId)) {
|
||||
const rs = this.receivingTransfers.get(receivingState.fileId);
|
||||
if (rs && rs.receivedChunks) rs.receivedChunks.clear();
|
||||
|
||||
// Stop the stall detector and free the heavy chunk data, but KEEP the
|
||||
// transfer entry in receivingTransfers with status 'completed' so the UI
|
||||
// can render the Download action. The assembled file lives in
|
||||
// receivedFileBuffers; this entry is removed on cancel/disconnect or when
|
||||
// its buffer is evicted (see _discardReceivedFileBuffer).
|
||||
if (receivingState._stallTimer) {
|
||||
clearInterval(receivingState._stallTimer);
|
||||
receivingState._stallTimer = null;
|
||||
}
|
||||
this.receivingTransfers.delete(receivingState.fileId);
|
||||
|
||||
if (receivingState.receivedChunks) receivingState.receivedChunks.clear();
|
||||
receivingState.sessionKey = null;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ File assembly failed:', error);
|
||||
receivingState.status = 'failed';
|
||||
@@ -1571,6 +1615,9 @@ class EnhancedSecureFileTransfer {
|
||||
|
||||
transferState.confirmedChunks++;
|
||||
transferState.lastChunkTime = Date.now();
|
||||
if (transferState.status === 'waiting_confirmation') {
|
||||
this._armSenderIdleTimeout(transferState);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to handle chunk confirmation:', error);
|
||||
}
|
||||
@@ -1644,6 +1691,9 @@ class EnhancedSecureFileTransfer {
|
||||
fileName: transfer.file?.name || 'Unknown',
|
||||
fileSize: transfer.file?.size || 0,
|
||||
progress: Math.round((transfer.sentChunks / transfer.totalChunks) * 100),
|
||||
// Per-chunk detail for the segmented progress UI.
|
||||
totalChunks: transfer.totalChunks || 0,
|
||||
transferredChunks: transfer.sentChunks || 0,
|
||||
status: transfer.status,
|
||||
startTime: transfer.startTime
|
||||
}));
|
||||
@@ -1655,6 +1705,9 @@ class EnhancedSecureFileTransfer {
|
||||
fileName: transfer.fileName || 'Unknown',
|
||||
fileSize: transfer.fileSize || 0,
|
||||
progress: Math.round((transfer.receivedCount / transfer.totalChunks) * 100),
|
||||
// Per-chunk detail for the segmented progress UI.
|
||||
totalChunks: transfer.totalChunks || 0,
|
||||
transferredChunks: transfer.receivedCount || 0,
|
||||
status: transfer.status,
|
||||
startTime: transfer.startTime
|
||||
}));
|
||||
@@ -1692,9 +1745,83 @@ class EnhancedSecureFileTransfer {
|
||||
});
|
||||
this.pendingIncomingTransfers.delete(fileId);
|
||||
await this.sendSecureMessage({ type: 'file_transfer_response', fileId, accepted: true, timestamp: Date.now() });
|
||||
// Loss-recovery / resume: watch for missing chunks and re-request them.
|
||||
this._startReceiverStallDetector(fileId);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Periodically detects a stalled receive (lost chunks, connection blip,
|
||||
// reconnect) and asks the sender to retransmit only the chunks we are still
|
||||
// missing — so a dropped connection never loses the file.
|
||||
_startReceiverStallDetector(fileId) {
|
||||
const TICK_MS = 2500; // how often we evaluate
|
||||
const STALL_MS = 5000; // quiet period before we re-request
|
||||
const MAX_IDLE_MS = 180000; // give up after 3 min of zero progress
|
||||
|
||||
const rs = this.receivingTransfers.get(fileId);
|
||||
if (!rs) return;
|
||||
if (rs._stallTimer) clearInterval(rs._stallTimer);
|
||||
rs._lastProgressCount = rs.receivedCount || 0;
|
||||
rs._lastProgressTime = Date.now();
|
||||
|
||||
rs._stallTimer = setInterval(async () => {
|
||||
const state = this.receivingTransfers.get(fileId);
|
||||
if (!state || state._stallTimer !== rs._stallTimer) {
|
||||
clearInterval(rs._stallTimer);
|
||||
return;
|
||||
}
|
||||
if (state.status === 'completed' || state._assembled) {
|
||||
clearInterval(state._stallTimer);
|
||||
state._stallTimer = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Track forward progress for the idle/give-up clock.
|
||||
if (state.receivedCount !== state._lastProgressCount) {
|
||||
state._lastProgressCount = state.receivedCount;
|
||||
state._lastProgressTime = Date.now();
|
||||
}
|
||||
if (state.receivedCount >= state.totalChunks) return; // assembly handled elsewhere
|
||||
|
||||
// Still actively receiving — don't interrupt.
|
||||
if (Date.now() - (state.lastChunkTime || 0) < STALL_MS) return;
|
||||
|
||||
// No progress for too long → fail cleanly rather than hang forever.
|
||||
if (Date.now() - state._lastProgressTime > MAX_IDLE_MS) {
|
||||
clearInterval(state._stallTimer);
|
||||
state._stallTimer = null;
|
||||
state.status = 'failed';
|
||||
if (this.onError) this.onError('File transfer stalled — no data received. Please try again.');
|
||||
this.cleanupReceivingTransfer(fileId);
|
||||
return;
|
||||
}
|
||||
|
||||
await this._requestMissingChunks(fileId);
|
||||
}, TICK_MS);
|
||||
}
|
||||
|
||||
async _requestMissingChunks(fileId) {
|
||||
const state = this.receivingTransfers.get(fileId);
|
||||
if (!state || !state.receivedChunks) return;
|
||||
const MAX_PER_REQUEST = 256;
|
||||
const missing = [];
|
||||
for (let i = 0; i < state.totalChunks && missing.length < MAX_PER_REQUEST; i++) {
|
||||
if (!state.receivedChunks.has(i)) missing.push(i);
|
||||
}
|
||||
if (missing.length === 0) return;
|
||||
state.status = 'receiving';
|
||||
try {
|
||||
await this.sendSecureMessage({
|
||||
type: 'file_chunk_request',
|
||||
fileId,
|
||||
missing,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
} catch (_) {
|
||||
// Will retry on the next tick.
|
||||
}
|
||||
}
|
||||
|
||||
async rejectIncomingFile(fileId, error = 'Rejected by user') {
|
||||
if (!this.pendingIncomingTransfers.has(fileId)) return false;
|
||||
this.pendingIncomingTransfers.delete(fileId);
|
||||
@@ -1722,6 +1849,10 @@ class EnhancedSecureFileTransfer {
|
||||
cleanupTransfer(fileId) {
|
||||
const transferState = this.activeTransfers.get(fileId);
|
||||
if (transferState) {
|
||||
if (transferState._idleTimeout) {
|
||||
clearTimeout(transferState._idleTimeout);
|
||||
transferState._idleTimeout = null;
|
||||
}
|
||||
if (transferState.consentTimeout) {
|
||||
clearTimeout(transferState.consentTimeout);
|
||||
transferState.consentTimeout = null;
|
||||
@@ -1766,6 +1897,14 @@ class EnhancedSecureFileTransfer {
|
||||
// Best-effort wipe; deletion must still proceed.
|
||||
}
|
||||
this.receivedFileBuffers.delete(fileId);
|
||||
// The matching 'completed' entry is kept only to drive the Download UI;
|
||||
// once the file bytes are gone the entry is meaningless, so drop it too
|
||||
// (keeps the receiving list bounded over a long session).
|
||||
const rs = this.receivingTransfers.get(fileId);
|
||||
if (rs && (rs.status === 'completed' || rs._assembled)) {
|
||||
if (rs._stallTimer) { clearInterval(rs._stallTimer); rs._stallTimer = null; }
|
||||
this.receivingTransfers.delete(fileId);
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ УЛУЧШЕННАЯ безопасная очистка памяти для предотвращения use-after-free
|
||||
@@ -1776,6 +1915,11 @@ class EnhancedSecureFileTransfer {
|
||||
|
||||
const receivingState = this.receivingTransfers.get(fileId);
|
||||
if (receivingState) {
|
||||
// Stop the loss-recovery stall detector for this transfer.
|
||||
if (receivingState._stallTimer) {
|
||||
clearInterval(receivingState._stallTimer);
|
||||
receivingState._stallTimer = null;
|
||||
}
|
||||
// ✅ БЕЗОПАСНАЯ очистка receivedChunks с дополнительной защитой
|
||||
if (receivingState.receivedChunks && receivingState.receivedChunks.size > 0) {
|
||||
for (const [index, chunk] of receivingState.receivedChunks) {
|
||||
|
||||