-
-
- Development Roadmap
-
-
- Evolution of SecureBit.chat : from initial development to quantum-resistant decentralized network with complete ASN.1 validation
-
-
-
-
-
- {/* The line has been removed */}
-
-
- {phases.map((phase, index) => {
- const statusConfig = getStatusConfig(phase.status);
- const isExpanded = selectedPhase === index;
-
- return (
-
- {/* The dots are visible only on sm and larger screens */}
-
-
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"
- : ""
- }`}
- >
-
-
-
-
- {phase.version}
-
-
-
-
-
- {phase.title}
-
-
- {phase.description}
-
-
-
-
-
-
-
-
- {statusConfig.label}
-
-
-
-
{phase.date}
-
-
-
-
- {isExpanded && (
-
-
-
- Key features:
-
-
-
- {phase.features.map((feature, featureIndex) => (
-
- ))}
-
-
- )}
-
-
- );
- })}
-
-
-
-
-
-
-
- Join the future of privacy
-
-
- SecureBit.chat grows thanks to the community. Your ideas and feedback help shape the future of secure communication with complete ASN.1 validation.
-
-
-
-
-
+ 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 (
+
+ );
+ }
+ if (status === 'current') {
+ return (
+
+
+
+ );
+ }
+ if (status === 'dev') {
+ return (
+
+ );
+ }
+ // planned / research
+ return (
+
+
+
+ );
+ };
+
+ return (
+
+
+
+
+
+ {/* header */}
+
+
Development Roadmap
+
The evolution of SecureBit
+
From the first prototype to a quantum-resistant, decentralized network — with complete ASN.1 validation at every layer.
+
+
+ {/* progress */}
+
+
{shipped} of {total} milestones shipped
+
- );
- };
- window.Roadmap = Roadmap;
\ No newline at end of file
+
{upcoming} on the way
+
+
+ {/* timeline */}
+ {DATA.map((d, i) => {
+ const meta = META[d.status];
+ const opened = isOpen(i);
+ const notLast = i < total - 1;
+ return (
+
+
+ {/* spine */}
+
+ {notLast &&
}
+ {renderNode(d.status)}
+
+
+ {/* card */}
+
+
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'; }}
+ >
+
{d.v}
+
+
{d.title}
+ {!isMobile &&
{d.sub}
}
+
+
+
+
+ {!isMobile && meta.word}
+
+ {!isMobile &&
{d.date} }
+
+
+
+
+
+ {opened && (
+
+
Key features
+
+ {d.features.map((f, fi) => (
+
+
+ {f}
+
+ ))}
+
+
+ )}
+
+
+ );
+ })}
+
+
+
+ );
+}
+
+window.Roadmap = Roadmap;
diff --git a/src/components/ui/UniqueFeatureSlider.jsx b/src/components/ui/UniqueFeatureSlider.jsx
index c45d4a6..fa379fa 100644
--- a/src/components/ui/UniqueFeatureSlider.jsx
+++ b/src/components/ui/UniqueFeatureSlider.jsx
@@ -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: '
'
},
{
- 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: '
'
},
{
- 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: '
'
},
{
- 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: '
'
},
{
- 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: '
'
}
];
- // Проверка готовности компонента
- 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), '
'),
+ navBtn('next', () => go(1), '
')
])
]),
- // 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;
\ No newline at end of file
+window.UniqueFeatureSlider = UniqueFeatureSlider;
diff --git a/src/network/EnhancedSecureWebRTCManager.js b/src/network/EnhancedSecureWebRTCManager.js
index ad2d5c3..7091b6c 100644
--- a/src/network/EnhancedSecureWebRTCManager.js
+++ b/src/network/EnhancedSecureWebRTCManager.js
@@ -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)
// ============================================
diff --git a/src/pwa/install-prompt.js b/src/pwa/install-prompt.js
index 93e6150..3a1f1f5 100644
--- a/src/pwa/install-prompt.js
+++ b/src/pwa/install-prompt.js
@@ -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 = `
-
-
${buttonText}
-
- ×
-
+
+
+
+
+
+
+ Install App
+
+
`;
+ 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: '
',
+ firefox: '
',
+ safari: '
'
+ };
+
+ const row = (icon, title, desc, delay, nowrap) => `
+
`;
+
modal.innerHTML = `
-
-
-
-
-
Install SecureBit.chat
-
- To install this app, look for the install option in your browser menu or address bar.
- Different browsers have different install methods.
-
-
-
-
-
Chrome/Edge
-
Look for install icon in address bar
-
-
-
Firefox
-
Add bookmark to home screen
-
-
-
Safari
-
Share → Add to Home Screen
-
-
-
-
- Close
+
+
+
+
+
+
+
Install SecureBit
+
Your browser handles installs its own way. Pick the steps that match yours.
+
+
+
+ ${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)}
+
+
+
Got it
`;
-
- 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() {
diff --git a/src/pwa/pwa-manager.js b/src/pwa/pwa-manager.js
index d0b1656..a5eb0de 100644
--- a/src/pwa/pwa-manager.js
+++ b/src/pwa/pwa-manager.js
@@ -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 = `
-
- `;
+ this.offlineIndicator.innerHTML =
+ `
+
+ Back online
+
`;
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 = `
-
-
-
📴 Offline mode
-
-
+ this.offlineIndicator.innerHTML =
+ `
- `;
+ `;
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 = `
-
-
-
+ 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) => `
+
+ ${icon}
+ ${text}
+
`;
+
+ const card = (bg, bd, stroke, icon, title, desc) => `
+
`;
+
+ 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 = `
+
+
+
+
Connection lost
+
SecureBit is now in offline mode. Some features are limited, but your data stays safe.
-
Connection Lost
-
- SecureBit.chat is now in offline mode. Some features are limited, but your data is safe.
-
-
-
-
-
-
-
-
Your session and keys are preserved
-
-
-
-
-
-
No data is stored on servers
-
-
-
-
-
-
Messages will sync when online
-
+
+ ${feature(GREEN_BG, GREEN_BD, '#3ecf8e', '2.3', '
', 'Your session and keys are preserved')}
+ ${feature(GREEN_BG, GREEN_BD, '#3ecf8e', '1.9', '
', 'No data is stored on servers')}
+ ${feature(ORANGE_BG, ORANGE_BD, '#f0892a', '1.9', '
', 'Messages & files sync when you reconnect')}
-
-
-
- Continue Offline
-
-
- Learn More
+
+
+
Continue offline
+
+
+ Disconnect
+
+
+
+ Learn more
+
-
- `;
-
+
`;
+
+ const detailsHTML = `
+
+
+
+
+
+
When you reconnect
+
+
A dropped connection costs you nothing. SecureBit queues everything locally and resumes the encrypted session the instant you're back online.
+
+ ${card(GREEN_BG, GREEN_BD, '#3ecf8e', '
', 'Your messages get delivered', 'Everything you wrote while offline is sent to your contact automatically.')}
+ ${card(GREEN_BG, GREEN_BD, '#3ecf8e', '
', 'Files finish transferring', 'Uploads resume from where they stopped — no need to resend.')}
+ ${card(GREEN_BG, GREEN_BD, '#3ecf8e', '
', 'Their messages & files arrive', 'Whatever your contact sent during the outage is delivered to you in order.')}
+ ${card(ORANGE_BG, ORANGE_BD, '#f0892a', '
', 'Nothing is lost', "After reconnect there's no gap — the conversation continues exactly where it paused.")}
+
+
Got it
+
`;
+
+ 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() {
diff --git a/src/scripts/app-boot.js b/src/scripts/app-boot.js
index fdeae9b..5f2df36 100644
--- a/src/scripts/app-boot.js
+++ b/src/scripts/app-boot.js
@@ -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';
diff --git a/src/scripts/pwa-globals.js b/src/scripts/pwa-globals.js
index 4701502..dd856cd 100644
--- a/src/scripts/pwa-globals.js
+++ b/src/scripts/pwa-globals.js
@@ -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 = `
-
-
-
-
Update Available
-
SecureBit.chat v4.4.18 - ECDH + DTLS + SAS is ready
-
-
- Update
-
-
`;
- 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 = `
+
+
+
Update available
+
A newer version of SecureBit has been detected.
+
+
+
+ Current version
+ ${currentStr}
+
+
+
+ New version
+ Latest
+
+
+
+
+
+
+ Update now
+
+
+
+
+
+
`;
+
+ 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) {
diff --git a/src/styles/components.css b/src/styles/components.css
index 0ee12a2..3ebccac 100644
--- a/src/styles/components.css
+++ b/src/styles/components.css
@@ -732,4 +732,53 @@ button i {
100% {
left: 250px; /* Move past button width (200px + buffer) */
}
-}
\ No newline at end of file
+}
+/* ============================================================
+ 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; }
diff --git a/src/styles/pwa.css b/src/styles/pwa.css
index 52ab6a2..dc025ad 100644
--- a/src/styles/pwa.css
+++ b/src/styles/pwa.css
@@ -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 {
diff --git a/src/transfer/EnhancedSecureFileTransfer.js b/src/transfer/EnhancedSecureFileTransfer.js
index 0921d2c..1df3231 100644
--- a/src/transfer/EnhancedSecureFileTransfer.js
+++ b/src/transfer/EnhancedSecureFileTransfer.js
@@ -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) {