Add end-to-end encrypted voice messages; release v5.4.5
CodeQL Analysis / Analyze CodeQL (push) Canceled after 0s
Deploy Application / deploy (push) Canceled after 0s
Mirror to Codeberg / mirror (push) Canceled after 0s
Mirror to PrivacyGuides / mirror (push) Canceled after 0s

- Record voice notes in-browser, sent over the chunked AES-GCM file-transfer
  channel (per-file session key + signed SHA-256 integrity).
- Captured as PCM and encoded to WAV for universal playback (incl. iOS/Safari);
  auto-accepted and played inline from an in-memory blob, never written to disk.
- Composer mic button with live waveform + timer; desktop shows mic + send side
  by side, mobile swaps mic to send when typing.
- CSP media-src now allows blob: so recorded/received audio can play.
- Roadmap: Desktop Edition -> 5.0, new 5.5 'Secure Voice & Calls', later
  milestones shifted; version bumped to 5.4.5.
- Update README, docs (security/API/cryptography), and CHANGELOG.
This commit is contained in:
lockbitchat
2026-07-22 00:41:58 -04:00
parent 0e3e3a2974
commit c98a01b1d5
87 changed files with 1678 additions and 164 deletions
+5
View File
@@ -9,3 +9,8 @@ fly.toml
*.log
.DS_Store
**/.DS_Store
# Source maps are debug-only and never needed at runtime; excluding them keeps the
# build context small (the .map files are several MB) and the deploy upload fast.
**/*.map
# Marketing screenshots are referenced only from the README, not served by the app.
assets/screenshots
+15
View File
@@ -1,5 +1,20 @@
# Changelog
## v5.4.5 — Encrypted voice messages
SecureBit now supports **end-to-end encrypted voice messages**, sent over the same secure transfer channel as files.
### Added
- **Voice messages.** Record a note in the browser and send it over the existing chunked, AES-GCM-encrypted file-transfer pipeline, so it inherits the same per-file session key and SHA-256 integrity check. Audio is captured as raw PCM and encoded to **WAV**, so it plays back on every platform — including iOS/Safari. Duration and a downsampled waveform travel as unsigned presentation metadata; the audio bytes stay integrity-protected by the signed file hash.
- Voice notes are **auto-accepted** on the receiver (no consent prompt) and played **inline** from an in-memory blob — nothing is written to disk. The bubble shows an upload/download progress ring, a seekable waveform, play/pause and duration, and the usual Encrypted/Decrypted status.
- **Composer.** A mic button records a note with a live waveform and timer plus discard / send controls. On desktop the mic and send buttons sit side by side; on mobile the mic swaps to a send button as soon as you type text.
### Changed
- Content Security Policy `media-src` now allows `blob:` so recorded and received audio can be played back.
- Version scheme moved to the 5.x line (Desktop Edition = **5.0**). The roadmap gains a **5.5 "Secure Voice & Calls"** milestone (encrypted voice messages, audio calls, video calls); later milestones shift accordingly.
## 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.
+2 -1
View File
@@ -9,7 +9,7 @@
No accounts. No servers storing your messages. No installation required.
[![License: MIT](https://img.shields.io/badge/License-MIT-f0892a.svg)](LICENSE)
[![Version](https://img.shields.io/badge/version-4.10.0-3ecf8e.svg)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-5.4.5-3ecf8e.svg)](CHANGELOG.md)
[![PWA](https://img.shields.io/badge/PWA-installable-3ecf8e.svg)](#install-as-an-app)
[![Encryption](https://img.shields.io/badge/crypto-ECDH%20P--384%20%C2%B7%20AES--256--GCM-blue.svg)](#security-model)
@@ -43,6 +43,7 @@ It is designed for people who need a small, auditable, zero-infrastructure way t
- Local key metadata is stored encrypted in IndexedDB; disconnecting cleans up session state.
** Messaging**
- **Encrypted voice messages** — record in the browser and send over the same end-to-end encrypted transfer channel as files. Audio is captured as PCM/WAV, integrity-protected by a signed hash, and played back inline on the recipient's device without ever touching disk.
- Code blocks with syntax highlighting and an auto-clearing copy button.
- View-once and disappearing messages with countdown timers.
- Unsend (delete for everyone) over the authenticated control channel.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+69 -12
View File
@@ -1,36 +1,93 @@
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v19/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuOKfMZg.ttf) format('truetype');
}
/* Inter self-hosted (latin + latin-ext subsets, weights 400/500/600/700/800).
Generated from Google Fonts css2 to remove the third-party fonts.gstatic.com
dependency (privacy) and ship compact woff2 instead of full TTF. */
/* latin-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v19/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfMZg.ttf) format('truetype');
src: url(files/inter-latin-ext-400.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(files/inter-latin-400.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v19/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuI6fMZg.ttf) format('truetype');
src: url(files/inter-latin-ext-500.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(files/inter-latin-500.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v19/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuGKYMZg.ttf) format('truetype');
src: url(files/inter-latin-ext-600.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(files/inter-latin-600.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v19/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuFuYMZg.ttf) format('truetype');
src: url(files/inter-latin-ext-700.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(files/inter-latin-700.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 800;
font-display: swap;
src: url(files/inter-latin-ext-800.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 800;
font-display: swap;
src: url(files/inter-latin-800.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
+30 -2
View File
@@ -28,8 +28,13 @@ http {
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 256;
gzip_types text/plain text/css application/javascript application/json image/svg+xml font/woff2;
gzip_types text/plain text/css text/javascript
application/javascript application/json application/manifest+json
application/ld+json application/wasm
image/svg+xml image/x-icon font/woff2;
# Decide Cache-Control from the request path. Keeping all add_header calls at
# one level avoids nginx's header-inheritance reset between blocks.
@@ -40,7 +45,28 @@ http {
~^/sw\.js$ "no-cache, no-store, must-revalidate";
~^/manifest\.json$ "no-cache, no-store, must-revalidate";
~^/meta\.json$ "no-cache, no-store, must-revalidate";
~^/dist/ "no-cache, no-store, must-revalidate";
# dist/ bundles are query-versioned (?v=) in index.html. "no-cache" forces
# revalidation on every load, but dropping "no-store" lets the browser reuse
# the cached copy on a 304 avoiding a full re-download of the large bundles
# (e.g. qr-local.js ~1.2MB) when nothing changed, while still picking up
# new releases immediately.
~^/dist/ "no-cache, must-revalidate";
}
# CDN-Cache-Control is read by Cloudflare (and other CDNs) *independently* of the
# browser Cache-Control above, and takes precedence at the edge. We force "no-store"
# for the app shell, service worker and version files so a CDN can never serve a
# stale meta.json / index.html / sw.js which would silently break the in-app
# update notification. The empty default emits no header (nginx skips empty values),
# so immutable hashed assets keep being edge-cached normally for performance.
map $uri $sb_cdn_cache {
default "";
~^/index\.html$ "no-store";
~^/$ "no-store";
~^/sw\.js$ "no-store";
~^/manifest\.json$ "no-store";
~^/meta\.json$ "no-store";
~^/dist/ "no-store";
}
server {
@@ -62,6 +88,8 @@ http {
# microphone/geolocation and other sensors are denied outright.
add_header Permissions-Policy "camera=(self), microphone=(), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()" always;
add_header Cache-Control $sb_cache always;
# Edge-cache directive for Cloudflare/CDNs (empty value header is omitted).
add_header CDN-Cache-Control $sb_cdn_cache always;
add_header Service-Worker-Allowed "/" always;
# Real asset files must return 404 when missing never fall back to the
+104 -32
View File
@@ -4467,6 +4467,29 @@ var EnhancedSecureFileTransfer = class {
// 100 MB
category: "Archives",
description: "ZIP"
},
// Encrypted voice messages. Recorded in-browser via MediaRecorder and
// sent as a normal chunked+AES-GCM transfer, so they inherit the exact
// same end-to-end security as files. The app normalises the mime to a
// bare `audio/webm` (or `audio/mp4` on Safari) before sending so the
// codec-suffixed MediaRecorder type still matches this allow-list.
voice: {
extensions: [".webm", ".ogg", ".oga", ".opus", ".m4a", ".mp4", ".mp3", ".wav"],
mimeTypes: [
"audio/webm",
"audio/ogg",
"audio/opus",
"audio/mp4",
"audio/mpeg",
"audio/mp3",
"audio/wav",
"audio/x-m4a",
"audio/aac"
],
maxSize: 20 * 1024 * 1024,
// 20 MB (well beyond any sane voice note)
category: "Voice",
description: "Voice messages"
}
};
this.BLOCKED_EXTENSIONS = /* @__PURE__ */ new Set([
@@ -4904,7 +4927,30 @@ var EnhancedSecureFileTransfer = class {
// ============================================
// FILE TRANSFER IMPLEMENTATION
// ============================================
async sendFile(file) {
// Emit a progress update to the app layer. `direction` is 'up' for the
// sender and 'down' for the receiver. `uiId` (sender only) lets the UI match
// the event to a locally-created bubble before sendFile() has resolved a
// fileId. Voice fields ride along so the receiver can render the waveform.
_emitTransferProgress(state, direction) {
if (typeof this.onProgress !== "function" || !state) return;
const total = state.totalChunks || 0;
const done = direction === "up" ? state.sentChunks || 0 : state.receivedCount || 0;
const progress = total > 0 ? Math.min(100, Math.round(done / total * 100)) : 0;
try {
this.onProgress({
fileId: state.fileId,
uiId: state.uiId || null,
direction,
progress,
transferredChunks: done,
totalChunks: total,
isVoice: !!state.isVoice,
voice: state.voice || null
});
} catch (_) {
}
}
async sendFile(file, options = {}) {
try {
if (!this.webrtcManager) {
throw new Error("WebRTC Manager not initialized");
@@ -4942,7 +4988,11 @@ var EnhancedSecureFileTransfer = class {
startTime: Date.now(),
status: "preparing",
retryCount: 0,
lastChunkTime: Date.now()
lastChunkTime: Date.now(),
// Voice-message extras (undefined for ordinary files).
isVoice: !!(options && options.voice),
voice: options && options.voice ? options.voice : null,
uiId: options && options.uiId ? options.uiId : null
};
this.activeTransfers.set(fileId, transferState);
this.transferNonces.set(fileId, 0);
@@ -4980,6 +5030,10 @@ var EnhancedSecureFileTransfer = class {
timestamp: Date.now(),
version: "2.0"
};
if (transferState.isVoice) {
metadata.isVoice = true;
if (transferState.voice) metadata.voice = transferState.voice;
}
if (this.signingKey) {
try {
metadata.signature = await FileMetadataSigner.signFileMetadata(metadata, this.signingKey);
@@ -5012,6 +5066,7 @@ var EnhancedSecureFileTransfer = class {
await this.sendFileChunk(transferState, chunkIndex, chunkData);
transferState.sentChunks++;
const progress = Math.round(transferState.sentChunks / totalChunks * 95) + 5;
this._emitTransferProgress(transferState, "up");
await this.waitForBackpressure();
}
transferState.status = "waiting_confirmation";
@@ -5216,7 +5271,10 @@ var EnhancedSecureFileTransfer = class {
fileId: pendingMetadata.fileId,
fileName: pendingMetadata.fileName,
fileSize: pendingMetadata.fileSize,
mimeType: pendingMetadata.fileType || "application/octet-stream"
mimeType: pendingMetadata.fileType || "application/octet-stream",
// Voice notes auto-accept and render inline (no consent card).
isVoice: !!pendingMetadata.isVoice,
voice: pendingMetadata.voice || null
});
} else {
await this.rejectIncomingFile(metadata.fileId, "User consent unavailable");
@@ -5280,6 +5338,7 @@ var EnhancedSecureFileTransfer = class {
}
receivingState.receivedChunks.set(chunkMessage.chunkIndex, decryptedChunk);
receivingState.receivedCount++;
this._emitTransferProgress(receivingState, "down");
const confirmation = {
type: "chunk_confirmation",
fileId: chunkMessage.fileId,
@@ -5387,6 +5446,9 @@ var EnhancedSecureFileTransfer = class {
fileSize: receivingState.fileSize,
mimeType: receivingState.fileType,
transferTime: receivingState.endTime - receivingState.startTime,
// Voice notes are played inline, not saved to disk.
isVoice: !!receivingState.isVoice,
voice: receivingState.voice || null,
// backward-compatibility for existing UIs
fileBlob,
getBlob,
@@ -5582,7 +5644,9 @@ var EnhancedSecureFileTransfer = class {
receivedCount: 0,
startTime: Date.now(),
lastChunkTime: Date.now(),
status: "receiving"
status: "receiving",
isVoice: !!metadata.isVoice,
voice: metadata.voice || null
});
this.pendingIncomingTransfers.delete(fileId);
await this.sendSecureMessage({ type: "file_transfer_response", fileId, accepted: true, timestamp: Date.now() });
@@ -15634,7 +15698,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
}
}
// Public method to send files
async sendFile(file) {
async sendFile(file, options = {}) {
this._enforceVerificationGate("sendFile");
if (!this.isConnected()) {
throw new Error("Connection not ready for file transfer. Please ensure the connection is established.");
@@ -15650,7 +15714,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
throw new Error("Encryption keys not ready. Please wait for connection to be fully established.");
}
try {
const fileId = await this.fileTransferSystem.sendFile(file);
const fileId = await this.fileTransferSystem.sendFile(file, options);
return fileId;
} catch (error) {
this._secureLog("error", "File transfer error:", { errorType: error?.constructor?.name || "Unknown" });
@@ -17657,7 +17721,7 @@ Right-click or Ctrl+click to disconnect`,
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.1")
React.createElement("span", { key: "v", style: { fontFamily: MONO, fontSize: "10px", fontWeight: 500, color: "#56565e" } }, "v5.4.5")
]),
React.createElement("div", { key: "r2", className: "hidden sm:block", style: { fontSize: "11px", color: "#6b6b73", fontWeight: 500 } }, "End-to-end encrypted")
])
@@ -18316,47 +18380,55 @@ function Roadmap() {
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",
v: "v5.0",
title: "Desktop Edition",
sub: "Native desktop apps for Windows, macOS, and Linux",
status: "current",
date: "Now",
status: "released",
date: "Early 2026",
features: ["Windows desktop app (Tauri v2)", "macOS desktop app (Tauri v2)", "Linux AppImage support (Tauri v2)", "Real-time notifications", "Automatic reconnection", "Cross-device synchronization", "Improved UX/UI", "Support for files up to 100MB"]
},
{
v: "v5.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"]
title: "Secure Voice & Calls",
sub: "Encrypted voice messages, audio calls, and video calls",
status: "current",
date: "Now",
features: ["End-to-end encrypted voice messages", "1:1 encrypted audio calls (WebRTC)", "1:1 encrypted video calls (WebRTC)", "Perfect Forward Secrecy for live media", "SRTP/DTLS-protected media streams", "In-call SAS verification", "Call notifications and auto-reconnection", "Low-latency P2P media"]
},
{
v: "v6.0",
title: "Group Communications",
sub: "Group chats with preserved privacy",
status: "planned",
title: "Mobile Edition",
sub: "Native mobile apps for iOS and Android",
status: "dev",
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"]
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: "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"]
title: "Quantum-Resistant Edition",
sub: "Protection against quantum computers",
status: "planned",
date: "Q2 2027",
features: ["Post-quantum cryptography CRYSTALS-Kyber", "SPHINCS+ digital signatures", "Hybrid scheme: classic + PQ", "Quantum-safe key exchange", "Updated hashing algorithms", "Migration of existing sessions", "Compatibility with v5.x", "Quantum-resistant protocols"]
},
{
v: "v7.0",
title: "Group Communications",
sub: "Group chats with preserved privacy",
status: "planned",
date: "Q4 2027",
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: "v7.5",
title: "Decentralized Network",
sub: "Fully decentralized network",
status: "research",
date: "2028",
features: ["Node mesh network", "DHT for peer discovery", "Built-in onion routing", "Tokenomics and node incentives", "Governance via DAO", "Interoperability with other networks", "Cross-platform compatibility", "Self-healing network"]
},
{
v: "v8.0",
title: "AI Privacy Assistant",
sub: "AI for privacy and security",
status: "research",
+2 -2
View File
File diff suppressed because one or more lines are too long
Vendored
+635 -31
View File
@@ -163,6 +163,7 @@ var SESSION_ACTIONS = Object.freeze({
ADD_MESSAGE: "ADD_MESSAGE",
SET_MESSAGES: "SET_MESSAGES",
UPDATE_MESSAGE_STATUS: "UPDATE_MESSAGE_STATUS",
PATCH_MESSAGE: "PATCH_MESSAGE",
DELETE_MESSAGE: "DELETE_MESSAGE",
EXPIRE_MESSAGE: "EXPIRE_MESSAGE",
INCREMENT_UNREAD: "INCREMENT_UNREAD",
@@ -289,7 +290,9 @@ function sessionsReducer(state, action) {
case A.SET_STATUS: {
const session = state.sessions[action.id];
if (!session || session.status === action.status) return state;
return patchSession(state, action.id, { status: action.status });
const connected = action.status === "connected" || action.status === "verified";
const patch = !connected && session.peerPresence !== null ? { status: action.status, peerPresence: null } : { status: action.status };
return patchSession(state, action.id, patch);
}
case A.SET_FINGERPRINT:
return patchSession(state, action.id, { keyFingerprint: action.fingerprint });
@@ -324,6 +327,22 @@ function sessionsReducer(state, action) {
});
return changed ? patchSession(state, action.id, { messages }) : state;
}
case A.PATCH_MESSAGE: {
const session = state.sessions[action.id];
if (!session) return state;
const byId = action.messageId != null ? String(action.messageId) : null;
const byMid = action.mid != null ? String(action.mid) : null;
const byFileId = action.fileId != null ? String(action.fileId) : null;
let changed = false;
const messages = session.messages.map((m) => {
const hit = byId != null && String(m.id) === byId || byMid != null && String(m.mid) === byMid || byFileId != null && m.fileId != null && String(m.fileId) === byFileId;
if (!hit) return m;
changed = true;
const patch = typeof action.patch === "function" ? action.patch(m) : action.patch;
return { ...m, ...patch };
});
return changed ? patchSession(state, action.id, { messages }) : state;
}
case A.DELETE_MESSAGE: {
const session = state.sessions[action.id];
if (!session) return state;
@@ -381,7 +400,7 @@ function sessionsReducer(state, action) {
}
}
function decorateSession(session, activeSessionId) {
const lastMessage = [...session.messages].reverse().find((m) => !m.expired && typeof m.message === "string" && m.message.trim());
const lastMessage = [...session.messages].reverse().find((m) => !m.expired && (typeof m.message === "string" && m.message.trim() || m.voice));
const s = session.status;
const isUp = s === "connected" || s === "verified";
const isPending = s === "connecting" || s === "verifying" || s === "new";
@@ -396,7 +415,7 @@ function decorateSession(session, activeSessionId) {
dot = "#e5727a";
headerSub = statusSub(s);
}
const preview = lastMessage ? lastMessage.message : headerSub;
const preview = lastMessage ? lastMessage.voice ? "\u{1F399} Voice message" : lastMessage.message : headerSub;
return {
id: session.id,
name: session.peerLabel,
@@ -708,7 +727,439 @@ var MessageBody = ({ text }) => {
};
var GRAIN_URL = `url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='100'%20height='100'%3E%3Cfilter%20id='n'%3E%3CfeTurbulence%20type='fractalNoise'%20baseFrequency='0.9'%20numOctaves='2'%20stitchTiles='stitch'/%3E%3C/filter%3E%3Crect%20width='100%25'%20height='100%25'%20filter='url(%23n)'/%3E%3C/svg%3E")`;
var SB_MONO = "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace";
var EnhancedChatMessage = ({ message, type, timestamp, mid, status, viewOnce, viewOnceTtl, expiresAt, expired, nowTick, canUnsend, onUnsend, onExpire }) => {
var sbGenBars = (seed, n) => {
const out = [];
let s = seed || 1;
for (let i = 0; i < n; i++) {
s = (s * 9301 + 49297) % 233280;
const r = s / 233280;
const env = 0.45 + 0.55 * Math.sin(i / n * Math.PI);
out.push(Math.max(0.16, Math.min(1, (0.3 + r * 0.7) * env)));
}
return out;
};
var sbFmtClock = (sec) => {
sec = Math.max(0, Math.round(sec));
const m = Math.floor(sec / 60), s = sec % 60;
return m + ":" + String(s).padStart(2, "0");
};
var VoicePlayer = ({ voice, isMe }) => {
const h = React.createElement;
const [playing, setPlaying] = React.useState(false);
const [progress, setProgress] = React.useState(0);
const [playErr, setPlayErr] = React.useState(false);
const audioRef = React.useRef(null);
const rafRef = React.useRef(null);
const v = voice || {};
const dur = Number.isFinite(v.dur) && v.dur > 0 ? v.dur : 8;
const bars = Array.isArray(v.bars) && v.bars.length ? v.bars : sbGenBars(dur * 37 + 11, 34);
const src = v.url || null;
const transfer = v.transfer || null;
const transferring = !!transfer && !src;
const failed = !!v.error || playErr;
const dir = transfer ? transfer.dir : null;
const pct = transfer ? Math.max(0, Math.min(100, transfer.pct || 0)) : 100;
React.useEffect(() => () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
if (audioRef.current) {
try {
audioRef.current.pause();
} catch (_) {
}
audioRef.current = null;
}
}, []);
React.useEffect(() => {
if (!src && audioRef.current) {
try {
audioRef.current.pause();
} catch (_) {
}
audioRef.current = null;
setPlaying(false);
setProgress(0);
}
}, [src]);
const pause = () => {
if (audioRef.current) {
try {
audioRef.current.pause();
} catch (_) {
}
}
if (rafRef.current) cancelAnimationFrame(rafRef.current);
setPlaying(false);
};
const play = () => {
if (!src) return;
setPlayErr(false);
if (!audioRef.current) {
const a = new Audio();
a.preload = "auto";
a.addEventListener("timeupdate", () => {
const d = a.duration && isFinite(a.duration) ? a.duration : dur;
if (d) setProgress(Math.min(1, a.currentTime / d));
});
a.addEventListener("ended", () => {
setPlaying(false);
setProgress(0);
});
a.addEventListener("error", () => {
setPlaying(false);
setPlayErr(true);
});
a.src = src;
audioRef.current = a;
}
const p = audioRef.current.play();
if (p && typeof p.then === "function") {
p.then(() => setPlaying(true)).catch((err) => {
console.warn("Voice playback failed:", err && err.name, err && err.message);
setPlayErr(true);
setPlaying(false);
});
} else {
setPlaying(true);
}
};
const toggle = () => {
if (transferring || failed || !src) return;
if (playing) pause();
else play();
};
const seek = (e) => {
if (transferring || failed || !src) return;
const rect = e.currentTarget.getBoundingClientRect();
const p = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
if (audioRef.current && audioRef.current.duration) audioRef.current.currentTime = p * audioRef.current.duration;
setProgress(p);
};
const elapsed = progress * dur;
const circ = 2 * Math.PI * 23;
const playBg = isMe ? "#f0892a" : "rgba(240,137,42,0.14)";
const playColor = isMe ? "#1a0f04" : "#f0892a";
const barEls = bars.map((hgt, i) => {
const played = (i + 0.5) / bars.length <= progress;
let col;
if (transferring) col = isMe ? "rgba(255,255,255,0.13)" : "rgba(255,255,255,0.1)";
else col = played ? "#f0892a" : isMe ? "rgba(255,255,255,0.22)" : "rgba(255,255,255,0.16)";
return h("span", { key: i, style: { flex: "1 1 0", minWidth: 0, width: "3px", height: Math.round(6 + hgt * 22) + "px", borderRadius: "2px", background: col, transition: "background .12s" } });
});
const ring = transferring && h("svg", { key: "ring", width: 50, height: 50, viewBox: "0 0 50 50", style: { position: "absolute", inset: 0, transform: "rotate(-90deg)" } }, [
h("circle", { key: "bg", cx: 25, cy: 25, r: 23, fill: "none", stroke: "rgba(240,137,42,0.2)", strokeWidth: 2.5 }),
h("circle", { key: "fg", cx: 25, cy: 25, r: 23, fill: "none", stroke: "#f0892a", strokeWidth: 2.5, strokeLinecap: "round", strokeDasharray: circ.toFixed(1), strokeDashoffset: (circ * (1 - pct / 100)).toFixed(1), style: { transition: "stroke-dashoffset .12s linear" } })
]);
const icon = failed ? h("i", { className: "fas fa-triangle-exclamation", style: { fontSize: "14px" } }) : transferring ? h("svg", { width: 15, height: 15, viewBox: "0 0 24 24", fill: "currentColor", style: { opacity: 0.5 } }, h("path", { d: "M8 5.2v13.6l11-6.8z" })) : playing ? h("svg", { width: 16, height: 16, viewBox: "0 0 24 24", fill: "currentColor" }, [h("rect", { key: "a", x: 6, y: 5, width: 4, height: 14, rx: 1.2 }), h("rect", { key: "b", x: 14, y: 5, width: 4, height: 14, rx: 1.2 })]) : h("svg", { width: 16, height: 16, viewBox: "0 0 24 24", fill: "currentColor" }, h("path", { d: "M8 5.2v13.6l11-6.8z" }));
const label = failed ? "Failed" : transferring ? dir === "up" ? "Uploading" : "Downloading" : "Voice";
const timeText = transferring ? pct + "%" : sbFmtClock(playing || progress > 0 ? elapsed : dur);
return h("div", { style: { display: "flex", alignItems: "center", gap: "13px", padding: "13px 15px 12px" } }, [
h("div", { key: "pw", style: { position: "relative", flex: "none", width: "50px", height: "50px", display: "grid", placeItems: "center" } }, [
ring,
h("button", {
key: "pb",
onClick: toggle,
title: transferring ? "Transferring\u2026" : playing ? "Pause" : "Play",
style: { width: "42px", height: "42px", borderRadius: "50%", display: "grid", placeItems: "center", border: "none", background: failed ? "rgba(229,114,122,0.15)" : playBg, color: failed ? "#e5727a" : playColor, cursor: transferring || failed || !src ? "default" : "pointer", transition: "transform .15s cubic-bezier(.2,.7,.3,1)" }
}, icon)
]),
h("div", { key: "body", style: { flex: 1, minWidth: 0 } }, [
h("div", { key: "wave", onClick: seek, style: { display: "flex", alignItems: "center", gap: "2px", height: "30px", cursor: transferring || failed || !src ? "default" : "pointer" } }, barEls),
h("div", { key: "meta", style: { display: "flex", alignItems: "center", justifyContent: "space-between", marginTop: "6px" } }, [
h("span", { key: "t", style: { fontFamily: SB_MONO, fontSize: "10.5px", fontWeight: 500, color: transferring ? "#f0b072" : "#9a9aa2" } }, timeText),
h("span", { key: "l", style: { fontFamily: SB_MONO, fontSize: "9.5px", fontWeight: 600, color: failed ? "#e5727a" : transferring ? "#f0892a" : "#56565e", textTransform: "uppercase", letterSpacing: "0.8px" } }, label)
])
])
]);
};
var VoiceRecorder = ({ onSend, onCancel }) => {
const h = React.createElement;
const MAX_SECONDS = 300;
const NBARS = 40;
const [elapsed, setElapsed] = React.useState(0);
const [liveBars, setLiveBars] = React.useState(() => new Array(NBARS).fill(0.06));
const [micError, setMicError] = React.useState(false);
const R = React.useRef({}).current;
const teardownGraph = () => {
try {
if (R.node) {
R.node.disconnect();
if (R.node.port) R.node.port.onmessage = null;
R.node.onaudioprocess = null;
}
} catch (_) {
}
try {
if (R.srcNode) R.srcNode.disconnect();
} catch (_) {
}
try {
if (R.sink) R.sink.disconnect();
} catch (_) {
}
R.node = R.srcNode = R.sink = null;
};
const cleanup = () => {
if (R.timer) clearInterval(R.timer);
R.timer = null;
teardownGraph();
try {
if (R.stream) R.stream.getTracks().forEach((t) => t.stop());
} catch (_) {
}
try {
if (R.ac) R.ac.close();
} catch (_) {
}
if (R.wurl) {
try {
URL.revokeObjectURL(R.wurl);
} catch (_) {
}
R.wurl = null;
}
R.stream = R.ac = null;
};
const pushPeak = (amp) => {
R.peaks = R.peaks || [];
R.peaks.push(amp);
const now = performance.now();
if (!R.lastDraw || now - R.lastDraw > 55) {
R.lastDraw = now;
setLiveBars((prev) => {
const live = prev.slice(1);
live.push(Math.max(0.08, amp));
return live;
});
}
};
const downsample = (peaks, n) => {
if (!peaks || !peaks.length) return new Array(n).fill(0.3);
const out = [];
const step = peaks.length / n;
for (let i = 0; i < n; i++) {
const a = Math.floor(i * step), b = Math.max(a + 1, Math.floor((i + 1) * step));
let m = 0;
for (let j = a; j < b && j < peaks.length; j++) m = Math.max(m, peaks[j]);
out.push(Math.max(0.16, Math.min(1, m)));
}
return out;
};
const resample = (data, from, to) => {
if (!data || !data.length || to >= from) return data || new Float32Array(0);
const ratio = from / to;
const outLen = Math.floor(data.length / ratio);
const out = new Float32Array(outLen);
for (let i = 0; i < outLen; i++) {
const start = Math.floor(i * ratio), end = Math.min(data.length, Math.floor((i + 1) * ratio));
let s = 0, n = 0;
for (let j = start; j < end; j++) {
s += data[j];
n++;
}
out[i] = n ? s / n : 0;
}
return out;
};
const encodeWav = (samples, rate) => {
const buf = new ArrayBuffer(44 + samples.length * 2);
const view = new DataView(buf);
const ws = (o2, s) => {
for (let i = 0; i < s.length; i++) view.setUint8(o2 + i, s.charCodeAt(i));
};
ws(0, "RIFF");
view.setUint32(4, 36 + samples.length * 2, true);
ws(8, "WAVE");
ws(12, "fmt ");
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, 1, true);
view.setUint32(24, rate, true);
view.setUint32(28, rate * 2, true);
view.setUint16(32, 2, true);
view.setUint16(34, 16, true);
ws(36, "data");
view.setUint32(40, samples.length * 2, true);
let o = 44;
for (let i = 0; i < samples.length; i++) {
const s = Math.max(-1, Math.min(1, samples[i]));
view.setInt16(o, s < 0 ? s * 32768 : s * 32767, true);
o += 2;
}
return new Blob([buf], { type: "audio/wav" });
};
const onPcmFrame = (frame) => {
if (!frame || !frame.length) return;
R.pcm.push(frame);
R.sampleCount = (R.sampleCount || 0) + frame.length;
let sum = 0;
for (let i = 0; i < frame.length; i++) sum += frame[i] * frame[i];
pushPeak(Math.min(1, Math.sqrt(sum / frame.length) * 3.2));
};
const finish = (send) => {
if (R.finished) return;
R.finished = true;
if (R.timer) clearInterval(R.timer);
R.timer = null;
const peaks = (R.peaks || []).slice();
const srcRate = R.ac && R.ac.sampleRate || 48e3;
teardownGraph();
let outBlob = null, outDur = 1;
const total = R.sampleCount || 0;
if (send && R.pcm && R.pcm.length && total > srcRate * 0.2) {
const merged = new Float32Array(total);
let off = 0;
for (const c of R.pcm) {
merged.set(c, off);
off += c.length;
}
const targetRate = 24e3;
const out = resample(merged, srcRate, targetRate);
outDur = Math.max(1, Math.round(out.length / targetRate));
outBlob = encodeWav(out, targetRate);
}
try {
if (R.stream) R.stream.getTracks().forEach((t) => t.stop());
} catch (_) {
}
try {
if (R.ac) R.ac.close();
} catch (_) {
}
if (R.wurl) {
try {
URL.revokeObjectURL(R.wurl);
} catch (_) {
}
R.wurl = null;
}
R.stream = R.ac = null;
R.pcm = [];
if (send && outBlob && outBlob.size > 44) onSend(outBlob, outDur, downsample(peaks, 34));
else if (send) {
setMicError(true);
R.finished = false;
} else onCancel();
};
React.useEffect(() => {
let cancelled = false;
R.finished = false;
R.peaks = [];
R.pcm = [];
R.sampleCount = 0;
R.t0 = performance.now();
R.timer = setInterval(() => {
const e = (performance.now() - R.t0) / 1e3;
setElapsed(e);
if (e >= MAX_SECONDS) finish(true);
}, 200);
(async () => {
let stream = null;
try {
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true } });
}
} catch (_) {
stream = null;
}
if (cancelled) {
if (stream) stream.getTracks().forEach((t) => t.stop());
return;
}
if (!stream) {
setMicError(true);
if (R.timer) clearInterval(R.timer);
return;
}
R.stream = stream;
try {
const AC = window.AudioContext || window.webkitAudioContext;
R.ac = new AC();
if (R.ac.state === "suspended") {
try {
await R.ac.resume();
} catch (_) {
}
}
R.srcNode = R.ac.createMediaStreamSource(stream);
R.sink = R.ac.createGain();
R.sink.gain.value = 0;
let useWorklet = false;
if (R.ac.audioWorklet && typeof R.ac.audioWorklet.addModule === "function" && window.AudioWorkletNode) {
try {
const code = 'class P extends AudioWorkletProcessor{process(i){const c=i[0]&&i[0][0];if(c){this.port.postMessage(c.slice(0));}return true;}}registerProcessor("sb-pcm",P);';
R.wurl = URL.createObjectURL(new Blob([code], { type: "application/javascript" }));
await R.ac.audioWorklet.addModule(R.wurl);
if (cancelled) return;
R.node = new AudioWorkletNode(R.ac, "sb-pcm");
R.node.port.onmessage = (e) => onPcmFrame(e.data);
R.srcNode.connect(R.node);
R.node.connect(R.sink);
R.sink.connect(R.ac.destination);
useWorklet = true;
} catch (_) {
useWorklet = false;
}
}
if (!useWorklet) {
const bufSize = 4096;
R.node = R.ac.createScriptProcessor(bufSize, 1, 1);
R.node.onaudioprocess = (e) => {
const inBuf = e.inputBuffer.getChannelData(0);
onPcmFrame(new Float32Array(inBuf));
};
R.srcNode.connect(R.node);
R.node.connect(R.sink);
R.sink.connect(R.ac.destination);
}
} catch (_) {
setMicError(true);
if (R.timer) clearInterval(R.timer);
}
})();
return () => {
cancelled = true;
cleanup();
};
}, []);
if (micError) {
return h("div", { style: { display: "flex", alignItems: "center", gap: "12px" } }, [
h("div", { key: "msg", style: { flex: 1, minWidth: 0, display: "flex", alignItems: "center", gap: "9px", height: "46px", padding: "0 16px", borderRadius: "13px", background: "rgba(229,72,72,0.06)", border: "1px solid rgba(229,72,72,0.22)", color: "#e5727a", fontSize: "13.5px" } }, [
h("i", { key: "i", className: "fas fa-microphone-slash", style: { fontSize: "14px" } }),
"No audio captured \u2014 check microphone permission and try again."
]),
h(
"button",
{ key: "x", onClick: onCancel, title: "Close", style: { flex: "none", width: "46px", height: "46px", borderRadius: "50%", display: "grid", placeItems: "center", border: "none", background: "rgba(255,255,255,0.05)", color: "#9a9aa2", cursor: "pointer" } },
h("i", { className: "fas fa-xmark", style: { fontSize: "16px" } })
)
]);
}
const barEls = liveBars.map((hgt, i) => h("span", { key: i, style: { flex: "none", width: "3px", height: Math.round(4 + hgt * 24) + "px", borderRadius: "2px", background: "#e5727a", opacity: 0.45 + hgt * 0.55 } }));
return h("div", { style: { display: "flex", alignItems: "center", gap: "12px" } }, [
h("button", {
key: "cancel",
onClick: () => finish(false),
title: "Discard",
style: { flex: "none", width: "42px", height: "42px", borderRadius: "12px", display: "grid", placeItems: "center", border: "none", background: "rgba(255,255,255,0.04)", color: "#9a9aa2", cursor: "pointer" }
}, h("i", { className: "fas fa-trash-can", style: { fontSize: "15px" } })),
h("div", { key: "bar", style: { flex: 1, minWidth: 0, display: "flex", alignItems: "center", gap: "11px", height: "46px", padding: "0 16px", borderRadius: "13px", background: "rgba(229,72,72,0.06)", border: "1px solid rgba(229,72,72,0.22)" } }, [
h(
"span",
{ key: "dot", style: { position: "relative", flex: "none", width: "9px", height: "9px" } },
h("span", { style: { position: "absolute", inset: 0, borderRadius: "50%", background: "#e5727a", animation: "vmRec 1.3s ease-in-out infinite" } })
),
h("span", { key: "time", style: { flex: "none", fontFamily: SB_MONO, fontSize: "13px", fontWeight: 600, color: "#f4f4f6", minWidth: "42px" } }, sbFmtClock(elapsed)),
h("div", { key: "wave", style: { flex: 1, minWidth: 0, display: "flex", alignItems: "center", justifyContent: "flex-end", gap: "2px", height: "30px", overflow: "hidden" } }, barEls)
]),
h("button", {
key: "send",
onClick: () => finish(true),
title: "Send voice message",
style: { flex: "none", width: "46px", height: "46px", borderRadius: "50%", display: "grid", placeItems: "center", border: "none", background: "#f0892a", color: "#1a0f04", cursor: "pointer", boxShadow: "0 8px 22px rgba(240,137,42,0.3)", transition: "transform .15s cubic-bezier(.2,.7,.3,1)" }
}, h("svg", { width: 20, height: 20, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round" }, [h("path", { key: "a", d: "M22 2L11 13" }), h("path", { key: "b", d: "M22 2l-7 20-4-9-9-4 20-7z" })]))
]);
};
var EnhancedChatMessage = ({ message, type, timestamp, mid, status, viewOnce, viewOnceTtl, expiresAt, expired, nowTick, canUnsend, onUnsend, onExpire, voice }) => {
const [revealed, setRevealed] = React.useState(false);
const revealTimerRef = React.useRef(null);
const formatTime = (ts) => new Date(ts).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", second: "2-digit" });
@@ -761,7 +1212,9 @@ var EnhancedChatMessage = ({ message, type, timestamp, mid, status, viewOnce, vi
));
}
let body;
if (isViewOnce && !revealed) {
if (voice) {
body = React.createElement(VoicePlayer, { key: "voice", voice, isMe });
} else if (isViewOnce && !revealed) {
body = React.createElement("div", {
key: "cover",
onClick: handleReveal,
@@ -1786,6 +2239,7 @@ var EnhancedChatInterface = ({
messageInput,
setMessageInput,
onSendMessage,
onSendVoice,
onDisconnect,
keyFingerprint,
isVerified,
@@ -1814,6 +2268,25 @@ var EnhancedChatInterface = ({
const [showTimer, setShowTimer] = React.useState(false);
const [showOnce, setShowOnce] = React.useState(false);
const [showHandshake, setShowHandshake] = React.useState(false);
const [isRecording, setIsRecording] = React.useState(false);
const [isDesktop, setIsDesktop] = React.useState(() => typeof window !== "undefined" && !!window.matchMedia && window.matchMedia("(min-width:1024px)").matches);
React.useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(min-width:1024px)");
const onCh = () => setIsDesktop(mq.matches);
try {
mq.addEventListener("change", onCh);
} catch (_) {
mq.addListener(onCh);
}
return () => {
try {
mq.removeEventListener("change", onCh);
} catch (_) {
mq.removeListener(onCh);
}
};
}, []);
const taRef = React.useRef(null);
React.useEffect(() => {
const el = taRef.current;
@@ -1944,6 +2417,7 @@ var EnhancedChatInterface = ({
setShowOnce(false);
};
const hasText = !!(messageInput && messageInput.trim());
const canRecord = typeof onSendVoice === "function" && !isOffline && isFileTransferReady() && typeof navigator !== "undefined" && navigator.mediaDevices && typeof navigator.mediaDevices.getUserMedia === "function";
const fmtT = (ts) => {
try {
return new Date(ts).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", second: "2-digit" });
@@ -2022,7 +2496,8 @@ var EnhancedChatInterface = ({
nowTick,
canUnsend: typeof onUnsendMessage === "function",
onUnsend: onUnsendMessage,
onExpire: () => onMessageExpire && onMessageExpire(msg.id)
onExpire: () => onMessageExpire && onMessageExpire(msg.id),
voice: msg.voice
})))
));
const timerRow = showTimer && React.createElement(
@@ -2089,6 +2564,26 @@ var EnhancedChatInterface = ({
React.createElement("span", { key: "s", style: { fontSize: "11.5px", fontWeight: 600, color: "#8a8a92" } }, "Code snippet \xB7 formatting preserved \xB7 \u2318\u21B5 to send"),
React.createElement("button", { key: "c", onClick: () => setCodeMode(false), className: "sb-link", style: { marginLeft: "auto", background: "none", border: "none", color: "#6b6b73", cursor: "pointer", fontSize: "11.5px", fontFamily: "inherit", fontWeight: 600 } }, "Close")
]);
const sendBtn = React.createElement("button", {
key: "send",
onClick: onSendMessage,
disabled: !hasText,
title: "Send message",
className: "sb-send",
style: { flex: "none", width: "44px", height: "44px", borderRadius: "11px", border: "none", display: "grid", placeItems: "center", cursor: hasText ? "pointer" : "default", background: hasText ? "#f0892a" : "rgba(255,255,255,0.05)", color: hasText ? "#1a0f04" : "#56565e", transition: "all .15s" }
}, React.createElement("i", { className: "fas fa-paper-plane", style: { fontSize: "15px" } }));
const micBtn = React.createElement("button", {
key: "mic",
onClick: () => setIsRecording(true),
title: "Record voice message",
className: "sb-send",
style: { flex: "none", width: "44px", height: "44px", borderRadius: "50%", border: "none", display: "grid", placeItems: "center", cursor: "pointer", background: "#f0892a", color: "#1a0f04", boxShadow: "0 8px 22px rgba(240,137,42,0.3)", transition: "transform .15s cubic-bezier(.2,.7,.3,1)" }
}, React.createElement("svg", { width: 19, height: 19, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.9, strokeLinecap: "round", strokeLinejoin: "round" }, [
React.createElement("rect", { key: "a", x: 9, y: 3, width: 6, height: 11, rx: 3 }),
React.createElement("path", { key: "b", d: "M5 11a7 7 0 0 0 14 0" }),
React.createElement("path", { key: "c", d: "M12 18v3" })
]));
const trailingButtons = !canRecord ? [sendBtn] : isDesktop ? [micBtn, sendBtn] : hasText ? [sendBtn] : [micBtn];
const inputRow = React.createElement("div", {
key: "input",
style: { display: "flex", alignItems: "flex-end", gap: "11px", padding: "11px 11px 11px 16px", border: "1px solid " + (hasText ? "rgba(255,255,255,0.18)" : "rgba(255,255,255,0.08)"), background: "#161618", borderRadius: codeMode ? "0 0 14px 14px" : "14px", transition: "border .15s" }
@@ -2113,27 +2608,27 @@ var EnhancedChatInterface = ({
]),
React.createElement("span", { key: "cnt", style: { fontFamily: MONO, fontSize: "10.5px", color: "#56565e", marginLeft: "auto" } }, (messageInput ? messageInput.length : 0) + "/2000")
])
]),
React.createElement("button", {
key: "send",
onClick: onSendMessage,
disabled: !hasText,
title: "Send",
className: "sb-send",
style: { flex: "none", width: "44px", height: "44px", borderRadius: "11px", border: "none", display: "grid", placeItems: "center", cursor: hasText ? "pointer" : "default", background: hasText ? "#f0892a" : "rgba(255,255,255,0.05)", color: hasText ? "#1a0f04" : "#56565e", transition: "all .15s" }
}, React.createElement("i", { className: "fas fa-paper-plane", style: { fontSize: "15px" } }))
]);
])
].concat(trailingButtons));
const recordingBar = React.createElement(
"div",
{ key: "recbar" },
React.createElement(VoiceRecorder, {
onCancel: () => setIsRecording(false),
onSend: (blob, dur, bars) => {
setIsRecording(false);
if (typeof onSendVoice === "function") onSendVoice(blob, dur, bars);
}
})
);
const composer = React.createElement(
"footer",
{ key: "composer", style: { flex: "none", padding: "12px 20px 18px", background: "#0f0f11", borderTop: "1px solid rgba(255,255,255,0.05)" } },
React.createElement("div", { style: { maxWidth: "1000px", margin: "0 auto" } }, [
timerRow,
onceRow,
filePanel,
chipsRow,
codeStrip,
inputRow
])
React.createElement(
"div",
{ style: { maxWidth: "1000px", margin: "0 auto" } },
isRecording ? [recordingBar] : [timerRow, onceRow, filePanel, chipsRow, codeStrip, inputRow]
)
);
const scrollBtn = showScrollButton && React.createElement("button", {
key: "scrollbtn",
@@ -2164,7 +2659,10 @@ var buildSessionMessage = (message, type, opts = {}) => ({
status: opts.status,
viewOnce: opts.viewOnce === true,
viewOnceTtl: typeof opts.viewOnceTtl === "number" ? opts.viewOnceTtl : 15,
expiresAt: typeof opts.expiresAt === "number" ? opts.expiresAt : void 0
expiresAt: typeof opts.expiresAt === "number" ? opts.expiresAt : void 0,
// Encrypted voice note descriptor: { url, dur, bars, transfer, fileId }.
voice: opts.voice || void 0,
fileId: opts.fileId || void 0
});
var SB_SVG = {
chevL: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 6l-6 6 6 6"/></svg>',
@@ -2596,17 +3094,20 @@ var EnhancedSecureP2PChat = () => {
});
}, []);
const addMessageWithAutoScroll = React.useCallback((message, type, opts = {}) => {
const newId = Date.now() + Math.random();
const newMessage = {
message,
type,
id: Date.now() + Math.random(),
id: newId,
timestamp: typeof opts.timestamp === "number" ? opts.timestamp : Date.now(),
mid: opts.mid,
status: opts.status,
// WhatsApp-style: sending | sent | delivered | failed
viewOnce: opts.viewOnce === true,
viewOnceTtl: typeof opts.viewOnceTtl === "number" ? opts.viewOnceTtl : 15,
expiresAt: typeof opts.expiresAt === "number" ? opts.expiresAt : void 0
expiresAt: typeof opts.expiresAt === "number" ? opts.expiresAt : void 0,
voice: opts.voice || void 0,
fileId: opts.fileId || void 0
};
setMessages((prev) => {
const updated = [...prev, newMessage];
@@ -2634,11 +3135,20 @@ var EnhancedSecureP2PChat = () => {
}, 50);
return updated;
});
return newId;
}, []);
const updateMessageStatus = React.useCallback((mid, status) => {
if (!mid) return;
setMessages((prev) => prev.map((m) => String(m.mid) === String(mid) && m.type === "sent" ? { ...m, status } : m));
}, []);
const patchMessageById = React.useCallback((messageId, patch) => {
if (messageId == null) return;
setMessages((prev) => prev.map((m) => String(m.id) === String(messageId) ? { ...m, ...typeof patch === "function" ? patch(m) : patch } : m));
}, []);
const patchMessageByFileId = React.useCallback((fileId, patch) => {
if (!fileId) return;
setMessages((prev) => prev.map((m) => m.fileId && String(m.fileId) === String(fileId) ? { ...m, ...typeof patch === "function" ? patch(m) : patch } : m));
}, []);
const flushOfflineQueues = React.useCallback(() => {
for (const [id, q] of queuesRef.current.entries()) {
const mgr = managersRef.current.get(id);
@@ -2783,6 +3293,12 @@ var EnhancedSecureP2PChat = () => {
const updateMessageStatus2 = (mid, status) => {
if (mid) dispatch({ type: SESSION_ACTIONS.UPDATE_MESSAGE_STATUS, id, mid, status });
};
const patchMessageById2 = (messageId, patch) => {
if (messageId != null) dispatch({ type: SESSION_ACTIONS.PATCH_MESSAGE, id, messageId, patch });
};
const patchMessageByFileId2 = (fileId, patch) => {
if (fileId) dispatch({ type: SESSION_ACTIONS.PATCH_MESSAGE, id, fileId, patch });
};
const setConnectionStatus2 = (status) => dispatch({ type: SESSION_ACTIONS.SET_STATUS, id, status });
const setKeyFingerprint2 = (fingerprint) => dispatch({ type: SESSION_ACTIONS.SET_FINGERPRINT, id, fingerprint });
const setVerificationCode2 = (code) => dispatch({ type: SESSION_ACTIONS.SET_VERIFICATION, id, code });
@@ -3007,14 +3523,38 @@ var EnhancedSecureP2PChat = () => {
} catch (error) {
}
}
handleMessage(" SecureBit.chat Enhanced Security Edition v4.10.0 - ECDH + DTLS + SAS initialized. Ready to establish a secure connection with ECDH key exchange, DTLS fingerprint verification, and SAS authentication to prevent MITM attacks.", "system");
handleMessage(" SecureBit.chat Enhanced Security Edition v5.4.5 - ECDH + DTLS + SAS initialized. Ready to establish a secure connection with ECDH key exchange, DTLS fingerprint verification, and SAS authentication to prevent MITM attacks.", "system");
manager.setFileTransferCallbacks(
// Progress callback
// Progress callback — drives the voice-note upload/download ring.
(progress) => {
if (progress && progress.isVoice) {
const pct = Math.max(0, Math.min(100, progress.progress || 0));
const applyPct = (m) => {
if (!m.voice) return {};
if (progress.direction === "up" && pct >= 100) {
return { voice: { ...m.voice, transfer: null } };
}
return { voice: { ...m.voice, transfer: { dir: progress.direction, pct } } };
};
if (progress.direction === "up" && progress.uiId != null) patchMessageById2(progress.uiId, applyPct);
else if (progress.fileId) patchMessageByFileId2(progress.fileId, applyPct);
return;
}
console.log("File progress:", progress);
},
// File received callback — auto-save to disk, no button press needed.
// File received callback. Voice notes play inline (never touch disk);
// ordinary files still auto-save.
(fileData) => {
const isVoice = !!fileData.isVoice || typeof fileData.mimeType === "string" && fileData.mimeType.startsWith("audio/");
if (isVoice) {
fileData.getObjectURL().then((url) => {
patchMessageByFileId2(fileData.fileId, (m) => ({ voice: { ...m.voice || {}, url, transfer: null } }));
}).catch((e) => {
console.error("Voice decode failed:", e);
patchMessageByFileId2(fileData.fileId, (m) => ({ voice: { ...m.voice || {}, transfer: null, error: true } }));
});
return;
}
const sizeMb = Math.max(1, Math.round((fileData.fileSize || 0) / (1024 * 1024)));
const saveToDisk = async () => {
const url = await fileData.getObjectURL();
@@ -3044,8 +3584,27 @@ var EnhancedSecureP2PChat = () => {
addMessageWithAutoScroll2(` File transfer error: ${error}`, "system");
}
},
// Incoming file request callback — receiver must explicitly accept before any data is sent
// Incoming file request callback. Voice notes are auto-accepted and
// rendered inline (a receiving bubble appears immediately and tracks
// the download ring); ordinary files still require explicit consent.
(fileRequest) => {
if (fileRequest && fileRequest.isVoice) {
const v = fileRequest.voice || {};
addMessageWithAutoScroll2("", "received", {
voice: {
dur: Number.isFinite(v.dur) ? v.dur : 0,
bars: Array.isArray(v.bars) ? v.bars : null,
transfer: { dir: "down", pct: 0 },
url: null
},
fileId: fileRequest.fileId
});
try {
manager.acceptIncomingFile(fileRequest.fileId);
} catch (_) {
}
return;
}
setPendingIncomingFiles2((prev) => {
if (prev.some((f) => f.fileId === fileRequest.fileId)) return prev;
return [...prev, fileRequest];
@@ -4349,6 +4908,50 @@ var EnhancedSecureP2PChat = () => {
}
}
};
const handleSendVoice = async (blob, dur, bars) => {
if (!blob || !webrtcManagerRef.current) return;
const rawType = String(blob.type || "").toLowerCase();
let mime = "audio/wav";
if (rawType.startsWith("audio/webm")) mime = "audio/webm";
else if (rawType.startsWith("audio/mp4") || rawType.startsWith("audio/x-m4a")) mime = "audio/mp4";
else if (rawType.startsWith("audio/ogg")) mime = "audio/ogg";
const ext = mime === "audio/mp4" ? "m4a" : mime === "audio/ogg" ? "ogg" : mime === "audio/webm" ? "webm" : "wav";
const file = new File([blob], `voice-message.${ext}`, { type: mime });
let localUrl = null;
try {
localUrl = URL.createObjectURL(blob);
} catch (_) {
}
const offlineNow = isOffline || typeof navigator !== "undefined" && navigator.onLine === false || window.pwaOfflineManager && window.pwaOfflineManager.isOnline === false;
const notReady = offlineNow || !webrtcManagerRef.current.isConnected || !webrtcManagerRef.current.isConnected();
if (notReady) {
if (localUrl) {
try {
URL.revokeObjectURL(localUrl);
} catch (_) {
}
}
addMessageWithAutoScroll("Voice message needs an active secure connection. Reconnect and try again.", "system");
return;
}
const voiceMeta = {
dur: Math.max(1, Math.round(dur || 0)),
bars: Array.isArray(bars) ? bars.map((n) => Math.round(n * 1e3) / 1e3) : []
};
const localId = addMessageWithAutoScroll("", "sent", {
voice: { dur: voiceMeta.dur, bars: voiceMeta.bars, url: localUrl, transfer: { dir: "up", pct: 0 } }
});
try {
await webrtcManagerRef.current.sendFile(file, { voice: voiceMeta, uiId: localId });
patchMessageById(localId, (m) => ({ voice: { ...m.voice || {}, transfer: null } }));
} catch (err) {
patchMessageById(localId, (m) => ({ voice: { ...m.voice || {}, transfer: null, error: true } }));
const msg = String(err?.message || err);
if (!/queued for sending|Data channel not ready/i.test(msg)) {
addMessageWithAutoScroll(`Voice message failed: ${msg}`, "system");
}
}
};
const handleUnsendMessage = React.useCallback((mid) => {
if (!mid) return;
setMessages((prev) => prev.filter((m) => String(m.mid) !== String(mid)));
@@ -4604,6 +5207,7 @@ var EnhancedSecureP2PChat = () => {
messageInput,
setMessageInput,
onSendMessage: handleSendMessage,
onSendVoice: handleSendVoice,
onDisconnect: handleDisconnect,
keyFingerprint,
isVerified,
+3 -3
View File
File diff suppressed because one or more lines are too long
+10
View File
@@ -18,6 +18,16 @@
- `setFileTransferCallbacks(onProgress, onReceived, onError, onIncomingRequest)` updates manager fields and any live `EnhancedSecureFileTransfer` instance.
- Passing `null` values detaches callbacks from the active transfer system.
### Voice messages
- `sendFile(file, options)` accepts an optional `options` object. `options.voice`
(`{ dur, bars }`) marks the transfer as a voice note and rides along as unsigned
metadata; `options.uiId` correlates progress events to a UI bubble before the
`fileId` resolves.
- `onProgress` receives `{ fileId, uiId, direction, progress, isVoice, voice }`.
`onIncomingFileRequest` and `onReceived` include `isVoice` and `voice` so the UI
can auto-accept and render a voice bubble instead of a file card.
## EnhancedSecureFileTransfer
### Incoming transfers
+5 -2
View File
@@ -2,7 +2,7 @@
## Release context
- Product release: `v4.8.5`
- Product release: `v5.4.5`
- Protocol version: `4.1`
## Session establishment
@@ -16,6 +16,9 @@ The SAS is deterministic for both peers in the same authenticated session. Users
- encrypted payloads are validated before decryption
- decrypted chat text is sanitized before entering React state or the UI
- replay and ordering controls remain part of the session layer
- voice messages are transported over the file-transfer channel: each is
encrypted with a per-file AES-GCM session key and integrity-checked with a
signed SHA-256 hash before playback
## Local key metadata
@@ -23,4 +26,4 @@ Sensitive IndexedDB metadata is stored in encrypted envelopes. Legacy plaintext
## Scope note
This document describes the current browser implementation behavior relevant to the v4.8.5 hardening release. It does not replace independent cryptographic review.
This document describes the current browser implementation behavior relevant to the v5.4.5 release. It does not replace independent cryptographic review.
+13
View File
@@ -10,6 +10,7 @@
| Privacy mode | optional TURN relay-only mode |
| Message UI safety | incoming decrypted text sanitized before display |
| File transfer | validated metadata, explicit consent, allowlist policy |
| Voice messages | same chunked AES-GCM transfer as files; auto-accepted and played inline |
| Local metadata | encrypted IndexedDB envelopes with migration |
| Lifecycle | unified disconnect cleanup and bounded resource retention |
@@ -42,6 +43,18 @@ The verified state is reached only when both local and remote confirmation flags
5. sender transmits chunks only after acceptance
6. completed received buffers are retained within a bounded window
## Voice messages
Voice notes reuse the file-transfer pipeline, so they inherit its per-file
AES-GCM session key, chunking, and SHA-256 integrity check. Differences from a
regular file:
1. audio is recorded in-browser and encoded as PCM/WAV before sending
2. duration and a downsampled waveform travel as **unsigned** presentation
metadata; the audio bytes remain integrity-protected by the signed file hash
3. the receiver **auto-accepts** voice transfers (no consent prompt) and plays
them inline from an in-memory blob — nothing is written to disk
## Disconnect cleanup
The canonical disconnect path clears:
+43 -31
View File
@@ -7,10 +7,10 @@
content="default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
font-src 'self' https://fonts.gstatic.com data:;
font-src 'self' data:;
connect-src 'self' https: wss: ws: stun: stuns: turn: turns:;
img-src 'self' data: https:;
media-src 'none';
media-src 'self' blob:;
object-src 'none';
frame-src 'none';
worker-src 'self';
@@ -23,7 +23,7 @@
<!-- PWA Manifest -->
<link rel="manifest" href="./manifest.json">
<link rel="icon" type="image/x-icon" href="./logo/favicon.ico?v=1782446255208">
<link rel="icon" type="image/x-icon" href="./logo/favicon.ico?v=1784694927450">
<!-- 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?v=1782446255208">
<link rel="apple-touch-icon" href="./logo/icon-180x180.png?v=1784694927450">
<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?v=1782446255208">
<link rel="apple-touch-icon" sizes="180x180" href="./logo/icon-180x180.png?v=1784694927450">
<!-- Microsoft Tiles -->
<meta name="msapplication-TileColor" content="#ff6b35">
@@ -179,39 +179,51 @@
]
}
</script>
<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>
<!-- Render-blocking JS is deferred: classic deferred scripts and module scripts
both execute in document order after parsing, so React still runs before the
app modules below, but the parser / first paint is no longer blocked. -->
<script defer src="config/ice-servers.js"></script>
<script defer src="libs/react/react.production.min.js"></script>
<script defer src="libs/react-dom/react-dom.production.min.js"></script>
<!-- Prism syntax highlighting (vendored, offline). Tokenizes code as TEXT only —
it never executes the snippet. Loaded in manual mode (no auto-highlight). -->
<link rel="stylesheet" href="libs/prism/prism.css">
<script src="libs/prism/prism.js"></script>
<link rel="stylesheet" href="assets/tailwind.css?v=1782446255208">
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1782446255208">
<link rel="stylesheet" href="/assets/fontawesome/css/all.min.css">
it never executes the snippet. Loaded in manual mode (no auto-highlight).
Its CSS is loaded async via load-async-css.js (not paint-critical). -->
<script defer src="libs/prism/prism.js"></script>
<!-- Critical, paint-defining CSS stays render-blocking (avoids FOUC / layout shift). -->
<link rel="stylesheet" href="assets/tailwind.css?v=1784694927450">
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1784694927450">
<!-- Preload only the fonts needed for first paint. fa-solid covers the bulk of UI
icons; fa-regular/fa-brands are loaded on demand by their CSS (rarely on the
first screen). Inter latin 400/700 cover body text and headings/buttons. -->
<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="preload" href="/assets/fonts/inter/files/inter-latin-400.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/assets/fonts/inter/files/inter-latin-700.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/assets/fonts/inter/inter.css">
<link rel="stylesheet" href="src/styles/main.css?v=1782446255208">
<link rel="stylesheet" href="src/styles/animations.css?v=1782446255208">
<link rel="stylesheet" href="src/styles/components.css?v=1782446255208">
<script src="src/scripts/fa-check.js?v=1782446255208"></script>
<link rel="stylesheet" href="src/styles/main.css?v=1784694927450">
<link rel="stylesheet" href="src/styles/animations.css?v=1784694927450">
<link rel="stylesheet" href="src/styles/components.css?v=1784694927450">
<!-- Non-critical CSS (FontAwesome ~102KB, Prism) loaded async — no longer blocks paint. -->
<script defer src="src/scripts/load-async-css.js?v=1784694927450"></script>
<noscript>
<link rel="stylesheet" href="/assets/fontawesome/css/all.min.css">
<link rel="stylesheet" href="libs/prism/prism.css">
</noscript>
<script defer src="src/scripts/fa-check.js?v=1784694927450"></script>
<!-- Update Manager - система принудительного обновления -->
<script src="src/utils/updateManager.js?v=1782446255208"></script>
<script type="module" src="src/components/UpdateChecker.jsx?v=1782446255208"></script>
<script type="module" src="dist/qr-local.js?v=1782446255208"></script>
<script type="module" src="src/components/QRScanner.js?v=1782446255208"></script>
<script defer src="src/utils/updateManager.js?v=1784694927450"></script>
<script type="module" src="src/components/UpdateChecker.jsx?v=1784694927450"></script>
<script type="module" src="dist/qr-local.js?v=1784694927450"></script>
<script type="module" src="src/components/QRScanner.js?v=1784694927450"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="dist/app-boot.js?v=1782446255208"></script>
<script type="module" src="dist/app.js?v=1782446255208"></script>
<script type="module" src="dist/app-boot.js?v=1784694927450"></script>
<script type="module" src="dist/app.js?v=1784694927450"></script>
<script src="src/scripts/pwa-register.js?v=1782446255208"></script>
<script src="./src/pwa/install-prompt.js?v=1782446255208" type="module"></script>
<script src="./src/pwa/pwa-manager.js?v=1782446255208" type="module"></script>
<script src="./src/scripts/pwa-offline-test.js?v=1782446255208"></script>
<link rel="stylesheet" href="./src/styles/pwa.css?v=1782446255208">
<script defer src="src/scripts/pwa-register.js?v=1784694927450"></script>
<script src="./src/pwa/install-prompt.js?v=1784694927450" type="module"></script>
<script src="./src/pwa/pwa-manager.js?v=1784694927450" type="module"></script>
<script defer src="./src/scripts/pwa-offline-test.js?v=1784694927450"></script>
<link rel="stylesheet" href="./src/styles/pwa.css?v=1784694927450">
</body>
</html>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 482 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

+7 -7
View File
@@ -1,10 +1,10 @@
{
"version": "1782446255208",
"buildVersion": "1782446255208",
"appVersion": "4.10.0",
"buildTime": "2026-06-26T03:57:35.595Z",
"buildId": "1782446255208-db5d6e4",
"gitHash": "db5d6e4",
"version": "1784694927450",
"buildVersion": "1784694927450",
"appVersion": "5.4.5",
"buildTime": "2026-07-22T04:35:27.501Z",
"buildId": "1784694927450-0e3e3a2",
"gitHash": "0e3e3a2",
"generated": true,
"generatedAt": "2026-06-26T03:57:35.602Z"
"generatedAt": "2026-07-22T04:35:27.502Z"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "securebit-chat",
"version": "4.10.0",
"version": "5.4.5",
"description": "Secure P2P Communication Application with End-to-End Encryption",
"main": "index.html",
"scripts": {
+556 -19
View File
@@ -538,8 +538,372 @@ import {
const GRAIN_URL = `url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='100'%20height='100'%3E%3Cfilter%20id='n'%3E%3CfeTurbulence%20type='fractalNoise'%20baseFrequency='0.9'%20numOctaves='2'%20stitchTiles='stitch'/%3E%3C/filter%3E%3Crect%20width='100%25'%20height='100%25'%20filter='url(%23n)'/%3E%3C/svg%3E")`;
const SB_MONO = "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace";
// Deterministic fallback waveform when a peer sent no peaks (older builds).
const sbGenBars = (seed, n) => {
const out = [];
let s = seed || 1;
for (let i = 0; i < n; i++) {
s = (s * 9301 + 49297) % 233280;
const r = s / 233280;
const env = 0.45 + 0.55 * Math.sin((i / n) * Math.PI);
out.push(Math.max(0.16, Math.min(1, (0.3 + r * 0.7) * env)));
}
return out;
};
const sbFmtClock = (sec) => {
sec = Math.max(0, Math.round(sec));
const m = Math.floor(sec / 60), s = sec % 60;
return m + ':' + String(s).padStart(2, '0');
};
// Voice message player (bubble body)
// Play/pause + seekable waveform + duration, plus an upload/download
// progress ring while the encrypted audio is still transferring.
// Ported 1:1 from the "MessageBubble" design, wired to a real <audio>.
const VoicePlayer = ({ voice, isMe }) => {
const h = React.createElement;
const [playing, setPlaying] = React.useState(false);
const [progress, setProgress] = React.useState(0);
const [playErr, setPlayErr] = React.useState(false);
const audioRef = React.useRef(null);
const rafRef = React.useRef(null);
const v = voice || {};
const dur = Number.isFinite(v.dur) && v.dur > 0 ? v.dur : 8;
const bars = (Array.isArray(v.bars) && v.bars.length) ? v.bars : sbGenBars(dur * 37 + 11, 34);
const src = v.url || null;
const transfer = v.transfer || null;
const transferring = !!transfer && !src;
const failed = !!v.error || playErr;
const dir = transfer ? transfer.dir : null;
const pct = transfer ? Math.max(0, Math.min(100, transfer.pct || 0)) : 100;
React.useEffect(() => () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
if (audioRef.current) { try { audioRef.current.pause(); } catch (_) {} audioRef.current = null; }
}, []);
// If the audio URL is swapped/removed, reset playback.
React.useEffect(() => {
if (!src && audioRef.current) { try { audioRef.current.pause(); } catch (_) {} audioRef.current = null; setPlaying(false); setProgress(0); }
}, [src]);
const pause = () => {
if (audioRef.current) { try { audioRef.current.pause(); } catch (_) {} }
if (rafRef.current) cancelAnimationFrame(rafRef.current);
setPlaying(false);
};
const play = () => {
if (!src) return;
setPlayErr(false);
if (!audioRef.current) {
const a = new Audio();
a.preload = 'auto';
a.addEventListener('timeupdate', () => {
const d = a.duration && isFinite(a.duration) ? a.duration : dur;
if (d) setProgress(Math.min(1, a.currentTime / d));
});
a.addEventListener('ended', () => { setPlaying(false); setProgress(0); });
a.addEventListener('error', () => { setPlaying(false); setPlayErr(true); });
a.src = src;
audioRef.current = a;
}
const p = audioRef.current.play();
if (p && typeof p.then === 'function') {
p.then(() => setPlaying(true)).catch((err) => { console.warn('Voice playback failed:', err && err.name, err && err.message); setPlayErr(true); setPlaying(false); });
} else {
setPlaying(true);
}
};
const toggle = () => {
if (transferring || failed || !src) return;
if (playing) pause(); else play();
};
const seek = (e) => {
if (transferring || failed || !src) return;
const rect = e.currentTarget.getBoundingClientRect();
const p = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
if (audioRef.current && audioRef.current.duration) audioRef.current.currentTime = p * audioRef.current.duration;
setProgress(p);
};
const elapsed = progress * dur;
const circ = 2 * Math.PI * 23;
const playBg = isMe ? '#f0892a' : 'rgba(240,137,42,0.14)';
const playColor = isMe ? '#1a0f04' : '#f0892a';
const barEls = bars.map((hgt, i) => {
const played = (i + 0.5) / bars.length <= progress;
let col;
if (transferring) col = isMe ? 'rgba(255,255,255,0.13)' : 'rgba(255,255,255,0.1)';
else col = played ? '#f0892a' : (isMe ? 'rgba(255,255,255,0.22)' : 'rgba(255,255,255,0.16)');
return h('span', { key: i, style: { flex: '1 1 0', minWidth: 0, width: '3px', height: Math.round(6 + hgt * 22) + 'px', borderRadius: '2px', background: col, transition: 'background .12s' } });
});
const ring = transferring && h('svg', { key: 'ring', width: 50, height: 50, viewBox: '0 0 50 50', style: { position: 'absolute', inset: 0, transform: 'rotate(-90deg)' } }, [
h('circle', { key: 'bg', cx: 25, cy: 25, r: 23, fill: 'none', stroke: 'rgba(240,137,42,0.2)', strokeWidth: 2.5 }),
h('circle', { key: 'fg', cx: 25, cy: 25, r: 23, fill: 'none', stroke: '#f0892a', strokeWidth: 2.5, strokeLinecap: 'round', strokeDasharray: circ.toFixed(1), strokeDashoffset: (circ * (1 - pct / 100)).toFixed(1), style: { transition: 'stroke-dashoffset .12s linear' } })
]);
const icon = failed
? h('i', { className: 'fas fa-triangle-exclamation', style: { fontSize: '14px' } })
: (transferring
? h('svg', { width: 15, height: 15, viewBox: '0 0 24 24', fill: 'currentColor', style: { opacity: 0.5 } }, h('path', { d: 'M8 5.2v13.6l11-6.8z' }))
: (playing
? h('svg', { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'currentColor' }, [h('rect', { key: 'a', x: 6, y: 5, width: 4, height: 14, rx: 1.2 }), h('rect', { key: 'b', x: 14, y: 5, width: 4, height: 14, rx: 1.2 })])
: h('svg', { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'currentColor' }, h('path', { d: 'M8 5.2v13.6l11-6.8z' }))));
const label = failed ? 'Failed' : (transferring ? (dir === 'up' ? 'Uploading' : 'Downloading') : 'Voice');
const timeText = transferring ? (pct + '%') : sbFmtClock((playing || progress > 0) ? elapsed : dur);
return h('div', { style: { display: 'flex', alignItems: 'center', gap: '13px', padding: '13px 15px 12px' } }, [
h('div', { key: 'pw', style: { position: 'relative', flex: 'none', width: '50px', height: '50px', display: 'grid', placeItems: 'center' } }, [
ring,
h('button', {
key: 'pb', onClick: toggle, title: transferring ? 'Transferring…' : (playing ? 'Pause' : 'Play'),
style: { width: '42px', height: '42px', borderRadius: '50%', display: 'grid', placeItems: 'center', border: 'none', background: failed ? 'rgba(229,114,122,0.15)' : playBg, color: failed ? '#e5727a' : playColor, cursor: (transferring || failed || !src) ? 'default' : 'pointer', transition: 'transform .15s cubic-bezier(.2,.7,.3,1)' }
}, icon)
]),
h('div', { key: 'body', style: { flex: 1, minWidth: 0 } }, [
h('div', { key: 'wave', onClick: seek, style: { display: 'flex', alignItems: 'center', gap: '2px', height: '30px', cursor: (transferring || failed || !src) ? 'default' : 'pointer' } }, barEls),
h('div', { key: 'meta', style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: '6px' } }, [
h('span', { key: 't', style: { fontFamily: SB_MONO, fontSize: '10.5px', fontWeight: 500, color: transferring ? '#f0b072' : '#9a9aa2' } }, timeText),
h('span', { key: 'l', style: { fontFamily: SB_MONO, fontSize: '9.5px', fontWeight: 600, color: failed ? '#e5727a' : (transferring ? '#f0892a' : '#56565e'), textTransform: 'uppercase', letterSpacing: '0.8px' } }, label)
])
])
]);
};
// Voice recorder (composer)
// Auto-starts on mount; shows a live mic waveform + timer, with
// discard / send controls. Emits (blob, durationSec, waveformPeaks).
const VoiceRecorder = ({ onSend, onCancel }) => {
const h = React.createElement;
const MAX_SECONDS = 300; // hard cap so a note never balloons past the size limit
const NBARS = 40;
const [elapsed, setElapsed] = React.useState(0);
const [liveBars, setLiveBars] = React.useState(() => new Array(NBARS).fill(0.06));
const [micError, setMicError] = React.useState(false);
const R = React.useRef({}).current;
const teardownGraph = () => {
try { if (R.node) { R.node.disconnect(); if (R.node.port) R.node.port.onmessage = null; R.node.onaudioprocess = null; } } catch (_) {}
try { if (R.srcNode) R.srcNode.disconnect(); } catch (_) {}
try { if (R.sink) R.sink.disconnect(); } catch (_) {}
R.node = R.srcNode = R.sink = null;
};
const cleanup = () => {
if (R.timer) clearInterval(R.timer);
R.timer = null;
teardownGraph();
try { if (R.stream) R.stream.getTracks().forEach(t => t.stop()); } catch (_) {}
try { if (R.ac) R.ac.close(); } catch (_) {}
if (R.wurl) { try { URL.revokeObjectURL(R.wurl); } catch (_) {} R.wurl = null; }
R.stream = R.ac = null;
};
const pushPeak = (amp) => {
R.peaks = R.peaks || [];
R.peaks.push(amp);
const now = performance.now();
if (!R.lastDraw || now - R.lastDraw > 55) {
R.lastDraw = now;
setLiveBars(prev => { const live = prev.slice(1); live.push(Math.max(0.08, amp)); return live; });
}
};
const downsample = (peaks, n) => {
if (!peaks || !peaks.length) return new Array(n).fill(0.3);
const out = [];
const step = peaks.length / n;
for (let i = 0; i < n; i++) {
const a = Math.floor(i * step), b = Math.max(a + 1, Math.floor((i + 1) * step));
let m = 0;
for (let j = a; j < b && j < peaks.length; j++) m = Math.max(m, peaks[j]);
out.push(Math.max(0.16, Math.min(1, m)));
}
return out;
};
// Average-decimate captured PCM to a lower rate (voice-grade),
// keeping the WAV small while staying universally decodable.
const resample = (data, from, to) => {
if (!data || !data.length || to >= from) return data || new Float32Array(0);
const ratio = from / to;
const outLen = Math.floor(data.length / ratio);
const out = new Float32Array(outLen);
for (let i = 0; i < outLen; i++) {
const start = Math.floor(i * ratio), end = Math.min(data.length, Math.floor((i + 1) * ratio));
let s = 0, n = 0;
for (let j = start; j < end; j++) { s += data[j]; n++; }
out[i] = n ? s / n : 0;
}
return out;
};
// Encode mono Float32 samples as a 16-bit PCM WAV blob. WAV plays
// on every platform (incl. iOS/Safari), unlike webm/opus.
const encodeWav = (samples, rate) => {
const buf = new ArrayBuffer(44 + samples.length * 2);
const view = new DataView(buf);
const ws = (o, s) => { for (let i = 0; i < s.length; i++) view.setUint8(o + i, s.charCodeAt(i)); };
ws(0, 'RIFF'); view.setUint32(4, 36 + samples.length * 2, true); ws(8, 'WAVE');
ws(12, 'fmt '); view.setUint32(16, 16, true); view.setUint16(20, 1, true); view.setUint16(22, 1, true);
view.setUint32(24, rate, true); view.setUint32(28, rate * 2, true); view.setUint16(32, 2, true); view.setUint16(34, 16, true);
ws(36, 'data'); view.setUint32(40, samples.length * 2, true);
let o = 44;
for (let i = 0; i < samples.length; i++) { const s = Math.max(-1, Math.min(1, samples[i])); view.setInt16(o, s < 0 ? s * 0x8000 : s * 0x7FFF, true); o += 2; }
return new Blob([buf], { type: 'audio/wav' });
};
// Push one captured PCM frame: store it and update the live waveform.
const onPcmFrame = (frame) => {
if (!frame || !frame.length) return;
R.pcm.push(frame);
R.sampleCount = (R.sampleCount || 0) + frame.length;
let sum = 0;
for (let i = 0; i < frame.length; i++) sum += frame[i] * frame[i];
pushPeak(Math.min(1, Math.sqrt(sum / frame.length) * 3.2));
};
const finish = (send) => {
if (R.finished) return;
R.finished = true;
if (R.timer) clearInterval(R.timer);
R.timer = null;
const peaks = (R.peaks || []).slice();
const srcRate = (R.ac && R.ac.sampleRate) || 48000;
teardownGraph();
let outBlob = null, outDur = 1;
const total = R.sampleCount || 0;
// Require a little real audio so we never ship an empty/silent note.
if (send && R.pcm && R.pcm.length && total > srcRate * 0.2) {
const merged = new Float32Array(total);
let off = 0;
for (const c of R.pcm) { merged.set(c, off); off += c.length; }
const targetRate = 24000;
const out = resample(merged, srcRate, targetRate);
outDur = Math.max(1, Math.round(out.length / targetRate));
outBlob = encodeWav(out, targetRate); // always a valid, universally-playable WAV
}
try { if (R.stream) R.stream.getTracks().forEach(t => t.stop()); } catch (_) {}
try { if (R.ac) R.ac.close(); } catch (_) {}
if (R.wurl) { try { URL.revokeObjectURL(R.wurl); } catch (_) {} R.wurl = null; }
R.stream = R.ac = null;
R.pcm = [];
if (send && outBlob && outBlob.size > 44) onSend(outBlob, outDur, downsample(peaks, 34));
else if (send) { setMicError(true); R.finished = false; } // captured nothing let the user retry/close
else onCancel();
};
React.useEffect(() => {
let cancelled = false;
R.finished = false;
R.peaks = [];
R.pcm = [];
R.sampleCount = 0;
R.t0 = performance.now();
R.timer = setInterval(() => {
const e = (performance.now() - R.t0) / 1000;
setElapsed(e);
if (e >= MAX_SECONDS) finish(true);
}, 200);
(async () => {
let stream = null;
try {
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true } });
}
} catch (_) { stream = null; }
if (cancelled) { if (stream) stream.getTracks().forEach(t => t.stop()); return; }
if (!stream) { setMicError(true); if (R.timer) clearInterval(R.timer); return; }
R.stream = stream;
// Capture raw PCM directly (no MediaRecorder / no decodeAudioData),
// so we ALWAYS produce a valid WAV that plays on every platform,
// incl. iOS/Safari. Prefer an AudioWorklet; fall back to a
// ScriptProcessor. A muted sink keeps the graph pulling audio
// without routing the mic back to the speakers.
try {
const AC = window.AudioContext || window.webkitAudioContext;
R.ac = new AC();
if (R.ac.state === 'suspended') { try { await R.ac.resume(); } catch (_) {} }
R.srcNode = R.ac.createMediaStreamSource(stream);
R.sink = R.ac.createGain();
R.sink.gain.value = 0;
let useWorklet = false;
if (R.ac.audioWorklet && typeof R.ac.audioWorklet.addModule === 'function' && window.AudioWorkletNode) {
try {
const code = 'class P extends AudioWorkletProcessor{process(i){const c=i[0]&&i[0][0];if(c){this.port.postMessage(c.slice(0));}return true;}}registerProcessor("sb-pcm",P);';
R.wurl = URL.createObjectURL(new Blob([code], { type: 'application/javascript' }));
await R.ac.audioWorklet.addModule(R.wurl);
if (cancelled) return;
R.node = new AudioWorkletNode(R.ac, 'sb-pcm');
R.node.port.onmessage = (e) => onPcmFrame(e.data);
R.srcNode.connect(R.node);
R.node.connect(R.sink);
R.sink.connect(R.ac.destination);
useWorklet = true;
} catch (_) { useWorklet = false; }
}
if (!useWorklet) {
const bufSize = 4096;
R.node = R.ac.createScriptProcessor(bufSize, 1, 1);
R.node.onaudioprocess = (e) => {
const inBuf = e.inputBuffer.getChannelData(0);
onPcmFrame(new Float32Array(inBuf));
};
R.srcNode.connect(R.node);
R.node.connect(R.sink);
R.sink.connect(R.ac.destination);
}
} catch (_) {
setMicError(true);
if (R.timer) clearInterval(R.timer);
}
})();
return () => { cancelled = true; cleanup(); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (micError) {
return h('div', { style: { display: 'flex', alignItems: 'center', gap: '12px' } }, [
h('div', { key: 'msg', style: { flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', gap: '9px', height: '46px', padding: '0 16px', borderRadius: '13px', background: 'rgba(229,72,72,0.06)', border: '1px solid rgba(229,72,72,0.22)', color: '#e5727a', fontSize: '13.5px' } }, [
h('i', { key: 'i', className: 'fas fa-microphone-slash', style: { fontSize: '14px' } }),
'No audio captured — check microphone permission and try again.'
]),
h('button', { key: 'x', onClick: onCancel, title: 'Close', style: { flex: 'none', width: '46px', height: '46px', borderRadius: '50%', display: 'grid', placeItems: 'center', border: 'none', background: 'rgba(255,255,255,0.05)', color: '#9a9aa2', cursor: 'pointer' } },
h('i', { className: 'fas fa-xmark', style: { fontSize: '16px' } }))
]);
}
const barEls = liveBars.map((hgt, i) => h('span', { key: i, style: { flex: 'none', width: '3px', height: Math.round(4 + hgt * 24) + 'px', borderRadius: '2px', background: '#e5727a', opacity: (0.45 + hgt * 0.55) } }));
return h('div', { style: { display: 'flex', alignItems: 'center', gap: '12px' } }, [
h('button', {
key: 'cancel', onClick: () => finish(false), title: 'Discard',
style: { flex: 'none', width: '42px', height: '42px', borderRadius: '12px', display: 'grid', placeItems: 'center', border: 'none', background: 'rgba(255,255,255,0.04)', color: '#9a9aa2', cursor: 'pointer' }
}, h('i', { className: 'fas fa-trash-can', style: { fontSize: '15px' } })),
h('div', { key: 'bar', style: { flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', gap: '11px', height: '46px', padding: '0 16px', borderRadius: '13px', background: 'rgba(229,72,72,0.06)', border: '1px solid rgba(229,72,72,0.22)' } }, [
h('span', { key: 'dot', style: { position: 'relative', flex: 'none', width: '9px', height: '9px' } },
h('span', { style: { position: 'absolute', inset: 0, borderRadius: '50%', background: '#e5727a', animation: 'vmRec 1.3s ease-in-out infinite' } })),
h('span', { key: 'time', style: { flex: 'none', fontFamily: SB_MONO, fontSize: '13px', fontWeight: 600, color: '#f4f4f6', minWidth: '42px' } }, sbFmtClock(elapsed)),
h('div', { key: 'wave', style: { flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '2px', height: '30px', overflow: 'hidden' } }, barEls)
]),
h('button', {
key: 'send', onClick: () => finish(true), title: 'Send voice message',
style: { flex: 'none', width: '46px', height: '46px', borderRadius: '50%', display: 'grid', placeItems: 'center', border: 'none', background: '#f0892a', color: '#1a0f04', cursor: 'pointer', boxShadow: '0 8px 22px rgba(240,137,42,0.3)', transition: 'transform .15s cubic-bezier(.2,.7,.3,1)' }
}, h('svg', { width: 20, height: 20, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' }, [h('path', { key: 'a', d: 'M22 2L11 13' }), h('path', { key: 'b', d: 'M22 2l-7 20-4-9-9-4 20-7z' })]))
]);
};
// Enhanced Chat Message redesigned bubbles (SecureBit Chat design).
const EnhancedChatMessage = ({ message, type, timestamp, mid, status, viewOnce, viewOnceTtl, expiresAt, expired, nowTick, canUnsend, onUnsend, onExpire }) => {
const EnhancedChatMessage = ({ message, type, timestamp, mid, status, viewOnce, viewOnceTtl, expiresAt, expired, nowTick, canUnsend, onUnsend, onExpire, voice }) => {
const [revealed, setRevealed] = React.useState(false);
const revealTimerRef = React.useRef(null);
@@ -599,7 +963,9 @@ import {
}
let body;
if (isViewOnce && !revealed) {
if (voice) {
body = React.createElement(VoicePlayer, { key: 'voice', voice, isMe });
} else if (isViewOnce && !revealed) {
body = React.createElement('div', {
key: 'cover',
onClick: handleReveal,
@@ -1660,6 +2026,7 @@ import {
messageInput,
setMessageInput,
onSendMessage,
onSendVoice,
onDisconnect,
keyFingerprint,
isVerified,
@@ -1691,6 +2058,16 @@ import {
const [showTimer, setShowTimer] = React.useState(false);
const [showOnce, setShowOnce] = React.useState(false);
const [showHandshake, setShowHandshake] = React.useState(false);
const [isRecording, setIsRecording] = React.useState(false);
// Desktop shows mic + send side by side; mobile toggles between them.
const [isDesktop, setIsDesktop] = React.useState(() => typeof window !== 'undefined' && !!window.matchMedia && window.matchMedia('(min-width:1024px)').matches);
React.useEffect(() => {
if (typeof window === 'undefined' || !window.matchMedia) return;
const mq = window.matchMedia('(min-width:1024px)');
const onCh = () => setIsDesktop(mq.matches);
try { mq.addEventListener('change', onCh); } catch (_) { mq.addListener(onCh); }
return () => { try { mq.removeEventListener('change', onCh); } catch (_) { mq.removeListener(onCh); } };
}, []);
const taRef = React.useRef(null);
// Auto-grow the message textarea (and reset its height after sending).
@@ -1805,6 +2182,14 @@ import {
};
const hasText = !!(messageInput && messageInput.trim());
// Voice recording is offered only over a live, verified channel with a
// usable mic API (it rides the same transfer path as files).
const canRecord = typeof onSendVoice === 'function'
&& !isOffline
&& isFileTransferReady()
&& typeof navigator !== 'undefined'
&& navigator.mediaDevices
&& typeof navigator.mediaDevices.getUserMedia === 'function';
// System notices are surfaced inside the handshake/connection log card
// (matching the design) rather than as bubbles in the message flow.
@@ -1881,7 +2266,8 @@ import {
nowTick: nowTick,
canUnsend: typeof onUnsendMessage === 'function',
onUnsend: onUnsendMessage,
onExpire: () => onMessageExpire && onMessageExpire(msg.id)
onExpire: () => onMessageExpire && onMessageExpire(msg.id),
voice: msg.voice
})))
));
@@ -1940,6 +2326,24 @@ import {
]);
// ---- input row ----
const sendBtn = React.createElement('button', {
key: 'send', onClick: onSendMessage, disabled: !hasText, title: 'Send message', className: 'sb-send',
style: { flex: 'none', width: '44px', height: '44px', borderRadius: '11px', border: 'none', display: 'grid', placeItems: 'center', cursor: hasText ? 'pointer' : 'default', background: hasText ? '#f0892a' : 'rgba(255,255,255,0.05)', color: hasText ? '#1a0f04' : '#56565e', transition: 'all .15s' }
}, React.createElement('i', { className: 'fas fa-paper-plane', style: { fontSize: '15px' } }));
const micBtn = React.createElement('button', {
key: 'mic', onClick: () => setIsRecording(true), title: 'Record voice message', className: 'sb-send',
style: { flex: 'none', width: '44px', height: '44px', borderRadius: '50%', border: 'none', display: 'grid', placeItems: 'center', cursor: 'pointer', background: '#f0892a', color: '#1a0f04', boxShadow: '0 8px 22px rgba(240,137,42,0.3)', transition: 'transform .15s cubic-bezier(.2,.7,.3,1)' }
}, React.createElement('svg', { width: 19, height: 19, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.9, strokeLinecap: 'round', strokeLinejoin: 'round' }, [
React.createElement('rect', { key: 'a', x: 9, y: 3, width: 6, height: 11, rx: 3 }),
React.createElement('path', { key: 'b', d: 'M5 11a7 7 0 0 0 14 0' }),
React.createElement('path', { key: 'c', d: 'M12 18v3' })
]));
// Desktop mic + send together (two buttons). Mobile mic by default,
// swapping to send as soon as the user types text.
const trailingButtons = !canRecord
? [sendBtn]
: (isDesktop ? [micBtn, sendBtn] : (hasText ? [sendBtn] : [micBtn]));
const inputRow = React.createElement('div', {
key: 'input',
style: { display: 'flex', alignItems: 'flex-end', gap: '11px', padding: '11px 11px 11px 16px', border: '1px solid ' + (hasText ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.08)'), background: '#161618', borderRadius: codeMode ? '0 0 14px 14px' : '14px', transition: 'border .15s' }
@@ -1964,17 +2368,24 @@ import {
]),
React.createElement('span', { key: 'cnt', style: { fontFamily: MONO, fontSize: '10.5px', color: '#56565e', marginLeft: 'auto' } }, (messageInput ? messageInput.length : 0) + '/2000')
])
]),
React.createElement('button', {
key: 'send', onClick: onSendMessage, disabled: !hasText, title: 'Send', className: 'sb-send',
style: { flex: 'none', width: '44px', height: '44px', borderRadius: '11px', border: 'none', display: 'grid', placeItems: 'center', cursor: hasText ? 'pointer' : 'default', background: hasText ? '#f0892a' : 'rgba(255,255,255,0.05)', color: hasText ? '#1a0f04' : '#56565e', transition: 'all .15s' }
}, React.createElement('i', { className: 'fas fa-paper-plane', style: { fontSize: '15px' } }))
]);
])
].concat(trailingButtons));
// While recording, the whole input row is replaced by the recorder bar
// (clean row on the composer background, matching the design mockup).
const recordingBar = React.createElement('div', { key: 'recbar' },
React.createElement(VoiceRecorder, {
onCancel: () => setIsRecording(false),
onSend: (blob, dur, bars) => { setIsRecording(false); if (typeof onSendVoice === 'function') onSendVoice(blob, dur, bars); }
})
);
const composer = React.createElement('footer', { key: 'composer', style: { flex: 'none', padding: '12px 20px 18px', background: '#0f0f11', borderTop: '1px solid rgba(255,255,255,0.05)' } },
React.createElement('div', { style: { maxWidth: '1000px', margin: '0 auto' } }, [
timerRow, onceRow, filePanel, chipsRow, codeStrip, inputRow
])
React.createElement('div', { style: { maxWidth: '1000px', margin: '0 auto' } },
isRecording
? [recordingBar]
: [timerRow, onceRow, filePanel, chipsRow, codeStrip, inputRow]
)
);
const scrollBtn = showScrollButton && React.createElement('button', {
@@ -2004,7 +2415,10 @@ import {
status: opts.status,
viewOnce: opts.viewOnce === true,
viewOnceTtl: (typeof opts.viewOnceTtl === 'number') ? opts.viewOnceTtl : 15,
expiresAt: (typeof opts.expiresAt === 'number') ? opts.expiresAt : undefined
expiresAt: (typeof opts.expiresAt === 'number') ? opts.expiresAt : undefined,
// Encrypted voice note descriptor: { url, dur, bars, transfer, fileId }.
voice: opts.voice || undefined,
fileId: opts.fileId || undefined
});
// Left rail listing every open session (design import: "Multi Session
@@ -2505,16 +2919,19 @@ import {
}, []);
const addMessageWithAutoScroll = React.useCallback((message, type, opts = {}) => {
const newId = Date.now() + Math.random();
const newMessage = {
message,
type,
id: Date.now() + Math.random(),
id: newId,
timestamp: (typeof opts.timestamp === 'number') ? opts.timestamp : Date.now(),
mid: opts.mid,
status: opts.status, // WhatsApp-style: sending | sent | delivered | failed
viewOnce: opts.viewOnce === true,
viewOnceTtl: (typeof opts.viewOnceTtl === 'number') ? opts.viewOnceTtl : 15,
expiresAt: (typeof opts.expiresAt === 'number') ? opts.expiresAt : undefined
expiresAt: (typeof opts.expiresAt === 'number') ? opts.expiresAt : undefined,
voice: opts.voice || undefined,
fileId: opts.fileId || undefined
};
setMessages(prev => {
@@ -2546,6 +2963,7 @@ import {
return updated;
});
return newId;
}, []);
// Flip a sent message's delivery state (sending sent delivered, or failed).
@@ -2554,6 +2972,22 @@ import {
setMessages(prev => prev.map(m => (String(m.mid) === String(mid) && m.type === 'sent') ? { ...m, status } : m));
}, []);
// Shallow-merge a partial into one message (matched by local id), used to
// drive voice-note transfer progress and attach the decrypted audio URL.
const patchMessageById = React.useCallback((messageId, patch) => {
if (messageId == null) return;
setMessages(prev => prev.map(m => (String(m.id) === String(messageId))
? { ...m, ...(typeof patch === 'function' ? patch(m) : patch) }
: m));
}, []);
// Same, matched by fileId (receiver side, where we don't hold the local id yet).
const patchMessageByFileId = React.useCallback((fileId, patch) => {
if (!fileId) return;
setMessages(prev => prev.map(m => (m.fileId && String(m.fileId) === String(fileId))
? { ...m, ...(typeof patch === 'function' ? patch(m) : patch) }
: m));
}, []);
// When WE come back online: for EVERY session, transmit anything queued while
// offline and surface (and acknowledge) anything that arrived meanwhile. Each
// session flushes against its own manager and into its own slice.
@@ -2729,6 +3163,9 @@ import {
}
};
const updateMessageStatus = (mid, status) => { if (mid) dispatch({ type: SA.UPDATE_MESSAGE_STATUS, id, mid, status }); };
// Voice-note helpers: merge a partial into a message by local id or by fileId.
const patchMessageById = (messageId, patch) => { if (messageId != null) dispatch({ type: SA.PATCH_MESSAGE, id, messageId, patch }); };
const patchMessageByFileId = (fileId, patch) => { if (fileId) dispatch({ type: SA.PATCH_MESSAGE, id, fileId, patch }); };
const setConnectionStatus = (status) => dispatch({ type: SA.SET_STATUS, id, status });
const setKeyFingerprint = (fingerprint) => dispatch({ type: SA.SET_FINGERPRINT, id, fingerprint });
const setVerificationCode = (code) => dispatch({ type: SA.SET_VERIFICATION, id, code });
@@ -3010,17 +3447,47 @@ import {
}
}
handleMessage(' SecureBit.chat Enhanced Security Edition v4.10.0 - ECDH + DTLS + SAS initialized. Ready to establish a secure connection with ECDH key exchange, DTLS fingerprint verification, and SAS authentication to prevent MITM attacks.', 'system');
handleMessage(' SecureBit.chat Enhanced Security Edition v5.4.5 - ECDH + DTLS + SAS initialized. Ready to establish a secure connection with ECDH key exchange, DTLS fingerprint verification, and SAS authentication to prevent MITM attacks.', 'system');
// Setup file transfer callbacks (id-bound to THIS session's manager).
manager.setFileTransferCallbacks(
// Progress callback
// Progress callback drives the voice-note upload/download ring.
(progress) => {
if (progress && progress.isVoice) {
const pct = Math.max(0, Math.min(100, progress.progress || 0));
const applyPct = (m) => {
if (!m.voice) return {};
// Sender clears the ring once every chunk is out (playable
// from the local copy); receiver keeps it until the audio is
// assembled and decrypted (onFileReceived attaches the URL).
if (progress.direction === 'up' && pct >= 100) {
return { voice: { ...m.voice, transfer: null } };
}
return { voice: { ...m.voice, transfer: { dir: progress.direction, pct } } };
};
if (progress.direction === 'up' && progress.uiId != null) patchMessageById(progress.uiId, applyPct);
else if (progress.fileId) patchMessageByFileId(progress.fileId, applyPct);
return;
}
console.log('File progress:', progress);
},
// File received callback auto-save to disk, no button press needed.
// File received callback. Voice notes play inline (never touch disk);
// ordinary files still auto-save.
(fileData) => {
const isVoice = !!fileData.isVoice || (typeof fileData.mimeType === 'string' && fileData.mimeType.startsWith('audio/'));
if (isVoice) {
fileData.getObjectURL()
.then((url) => {
patchMessageByFileId(fileData.fileId, (m) => ({ voice: { ...(m.voice || {}), url, transfer: null } }));
})
.catch((e) => {
console.error('Voice decode failed:', e);
patchMessageByFileId(fileData.fileId, (m) => ({ voice: { ...(m.voice || {}), transfer: null, error: true } }));
});
return;
}
const sizeMb = Math.max(1, Math.round((fileData.fileSize || 0) / (1024 * 1024)));
const saveToDisk = async () => {
@@ -3057,8 +3524,24 @@ import {
}
},
// Incoming file request callback receiver must explicitly accept before any data is sent
// Incoming file request callback. Voice notes are auto-accepted and
// rendered inline (a receiving bubble appears immediately and tracks
// the download ring); ordinary files still require explicit consent.
(fileRequest) => {
if (fileRequest && fileRequest.isVoice) {
const v = fileRequest.voice || {};
addMessageWithAutoScroll('', 'received', {
voice: {
dur: Number.isFinite(v.dur) ? v.dur : 0,
bars: Array.isArray(v.bars) ? v.bars : null,
transfer: { dir: 'down', pct: 0 },
url: null
},
fileId: fileRequest.fileId
});
try { manager.acceptIncomingFile(fileRequest.fileId); } catch (_) {}
return;
}
setPendingIncomingFiles(prev => {
if (prev.some(f => f.fileId === fileRequest.fileId)) return prev;
return [...prev, fileRequest];
@@ -4541,6 +5024,59 @@ import {
}
};
// Send an encrypted voice note. The recorded audio rides the exact same
// chunked + AES-GCM file-transfer pipeline as files (so it inherits the
// audited E2E crypto); the duration + waveform peaks travel alongside so
// both peers render the designed voice bubble with an upload/download ring.
const handleSendVoice = async (blob, dur, bars) => {
if (!blob || !webrtcManagerRef.current) return;
// Normalise the mime to a bare, allow-listed audio type. The
// recorder emits WAV (universally decodable); we still tolerate
// other encodings defensively.
const rawType = String(blob.type || '').toLowerCase();
let mime = 'audio/wav';
if (rawType.startsWith('audio/webm')) mime = 'audio/webm';
else if (rawType.startsWith('audio/mp4') || rawType.startsWith('audio/x-m4a')) mime = 'audio/mp4';
else if (rawType.startsWith('audio/ogg')) mime = 'audio/ogg';
const ext = mime === 'audio/mp4' ? 'm4a' : (mime === 'audio/ogg' ? 'ogg' : (mime === 'audio/webm' ? 'webm' : 'wav'));
const file = new File([blob], `voice-message.${ext}`, { type: mime });
// Local, instantly-playable copy for the sender's own bubble.
let localUrl = null;
try { localUrl = URL.createObjectURL(blob); } catch (_) {}
const offlineNow = isOffline
|| (typeof navigator !== 'undefined' && navigator.onLine === false)
|| (window.pwaOfflineManager && window.pwaOfflineManager.isOnline === false);
const notReady = offlineNow || !webrtcManagerRef.current.isConnected || !webrtcManagerRef.current.isConnected();
if (notReady) {
if (localUrl) { try { URL.revokeObjectURL(localUrl); } catch (_) {} }
addMessageWithAutoScroll('Voice message needs an active secure connection. Reconnect and try again.', 'system');
return;
}
const voiceMeta = {
dur: Math.max(1, Math.round(dur || 0)),
bars: Array.isArray(bars) ? bars.map(n => Math.round(n * 1000) / 1000) : []
};
const localId = addMessageWithAutoScroll('', 'sent', {
voice: { dur: voiceMeta.dur, bars: voiceMeta.bars, url: localUrl, transfer: { dir: 'up', pct: 0 } }
});
try {
await webrtcManagerRef.current.sendFile(file, { voice: voiceMeta, uiId: localId });
patchMessageById(localId, (m) => ({ voice: { ...(m.voice || {}), transfer: null } }));
} catch (err) {
patchMessageById(localId, (m) => ({ voice: { ...(m.voice || {}), transfer: null, error: true } }));
const msg = String(err?.message || err);
if (!/queued for sending|Data channel not ready/i.test(msg)) {
addMessageWithAutoScroll(`Voice message failed: ${msg}`, 'system');
}
}
};
// Unsend: remove locally and ask the peer to drop it too.
const handleUnsendMessage = React.useCallback((mid) => {
if (!mid) return;
@@ -4861,6 +5397,7 @@ import {
messageInput: messageInput,
setMessageInput: setMessageInput,
onSendMessage: handleSendMessage,
onSendVoice: handleSendVoice,
onDisconnect: handleDisconnect,
keyFingerprint: keyFingerprint,
isVerified: isVerified,
+1 -1
View File
@@ -559,7 +559,7 @@ const EnhancedMinimalHeader = ({
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.1')
React.createElement('span', { key: 'v', style: { fontFamily: MONO, fontSize: '10px', fontWeight: 500, color: '#56565e' } }, 'v5.4.5')
]),
React.createElement('div', { key: 'r2', className: 'hidden sm:block', style: { fontSize: '11px', color: '#6b6b73', fontWeight: 500 } }, 'End-to-end encrypted')
])
+9 -7
View File
@@ -31,17 +31,19 @@ function Roadmap() {
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",
{ v: "v5.0", title: "Desktop Edition", sub: "Native desktop apps for Windows, macOS, and Linux", status: "released", date: "Early 2026",
features: ["Windows desktop app (Tauri v2)", "macOS desktop app (Tauri v2)", "Linux AppImage support (Tauri v2)", "Real-time notifications", "Automatic reconnection", "Cross-device synchronization", "Improved UX/UI", "Support for files up to 100MB"] },
{ v: "v5.0", title: "Mobile Edition", sub: "Native mobile apps for iOS and Android", status: "dev", date: "Q1 2026",
{ v: "v5.5", title: "Secure Voice & Calls", sub: "Encrypted voice messages, audio calls, and video calls", status: "current", date: "Now",
features: ["End-to-end encrypted voice messages", "1:1 encrypted audio calls (WebRTC)", "1:1 encrypted video calls (WebRTC)", "Perfect Forward Secrecy for live media", "SRTP/DTLS-protected media streams", "In-call SAS verification", "Call notifications and auto-reconnection", "Low-latency P2P media"] },
{ v: "v6.0", title: "Mobile Edition", sub: "Native mobile apps for iOS and Android", status: "dev", date: "Q4 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",
{ v: "v6.5", title: "Quantum-Resistant Edition", sub: "Protection against quantum computers", status: "planned", date: "Q2 2027",
features: ["Post-quantum cryptography CRYSTALS-Kyber", "SPHINCS+ digital signatures", "Hybrid scheme: classic + PQ", "Quantum-safe key exchange", "Updated hashing algorithms", "Migration of existing sessions", "Compatibility with v5.x", "Quantum-resistant protocols"] },
{ v: "v7.0", title: "Group Communications", sub: "Group chats with preserved privacy", status: "planned", date: "Q4 2027",
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",
{ v: "v7.5", title: "Decentralized Network", sub: "Fully decentralized network", status: "research", date: "2028",
features: ["Node mesh network", "DHT for peer discovery", "Built-in onion routing", "Tokenomics and node incentives", "Governance via DAO", "Interoperability with other networks", "Cross-platform compatibility", "Self-healing network"] },
{ v: "v7.0", title: "AI Privacy Assistant", sub: "AI for privacy and security", status: "research", date: "2028+",
{ v: "v8.0", title: "AI Privacy Assistant", sub: "AI for privacy and security", status: "research", date: "2028+",
features: ["Local AI threat analysis", "Automatic MITM detection", "Adaptive cryptography", "Personalized security recommendations", "Zero-knowledge machine learning", "Private AI assistant", "Predictive security", "Autonomous attack protection"] }
];
+3 -3
View File
@@ -12345,10 +12345,10 @@ async processMessage(data) {
}
}
// Public method to send files
async sendFile(file) {
async sendFile(file, options = {}) {
// Enforce verification gate for file transfers
this._enforceVerificationGate('sendFile');
if (!this.isConnected()) {
throw new Error('Connection not ready for file transfer. Please ensure the connection is established.');
}
@@ -12371,7 +12371,7 @@ async processMessage(data) {
try {
const fileId = await this.fileTransferSystem.sendFile(file);
const fileId = await this.fileTransferSystem.sendFile(file, options);
return fileId;
} catch (error) {
this._secureLog('error', 'File transfer error:', { errorType: error?.constructor?.name || 'Unknown' });
+21
View File
@@ -0,0 +1,21 @@
// Loads non-render-critical stylesheets asynchronously so they don't block first
// paint. This runs from a deferred <script> (after the document is parsed), and only
// appends <link rel="stylesheet"> elements — no inline event handlers — so it stays
// within the page CSP (script-src 'self', style-src 'self' 'unsafe-inline').
//
// Deferred here (decorative / non-layout, not needed for the initial paint):
// - FontAwesome (~102KB): icon glyphs. Their webfonts are still <link rel=preload>ed
// in index.html, so they download in parallel and apply as soon as this CSS lands.
// - Prism (tiny): syntax-highlighting theme, only used inside code blocks in chat.
(function () {
var sheets = [
'/assets/fontawesome/css/all.min.css',
'/libs/prism/prism.css'
];
for (var i = 0; i < sheets.length; i++) {
var link = document.createElement('link');
link.rel = 'stylesheet';
link.href = sheets[i];
document.head.appendChild(link);
}
})();
+33 -3
View File
@@ -20,6 +20,7 @@ export const SESSION_ACTIONS = Object.freeze({
ADD_MESSAGE: 'ADD_MESSAGE',
SET_MESSAGES: 'SET_MESSAGES',
UPDATE_MESSAGE_STATUS: 'UPDATE_MESSAGE_STATUS',
PATCH_MESSAGE: 'PATCH_MESSAGE',
DELETE_MESSAGE: 'DELETE_MESSAGE',
EXPIRE_MESSAGE: 'EXPIRE_MESSAGE',
INCREMENT_UNREAD: 'INCREMENT_UNREAD',
@@ -180,7 +181,14 @@ export function sessionsReducer(state, action) {
case A.SET_STATUS: {
const session = state.sessions[action.id];
if (!session || session.status === action.status) return state; // no-op if unchanged
return patchSession(state, action.id, { status: action.status });
// Peer presence is only meaningful while connected. Clear it whenever the
// session leaves the connected state, so a later reconnect doesn't briefly
// re-show the peer's stale status before they re-broadcast their presence.
const connected = action.status === 'connected' || action.status === 'verified';
const patch = (!connected && session.peerPresence !== null)
? { status: action.status, peerPresence: null }
: { status: action.status };
return patchSession(state, action.id, patch);
}
case A.SET_FINGERPRINT:
@@ -224,6 +232,28 @@ export function sessionsReducer(state, action) {
return changed ? patchSession(state, action.id, { messages }) : state;
}
case A.PATCH_MESSAGE: {
// Shallow-merge a partial into a single message, matched by local `id`
// or `mid` (whichever the caller provides). Used to drive voice-message
// transfer progress and to attach the decrypted audio URL on completion.
const session = state.sessions[action.id];
if (!session) return state;
const byId = action.messageId != null ? String(action.messageId) : null;
const byMid = action.mid != null ? String(action.mid) : null;
const byFileId = action.fileId != null ? String(action.fileId) : null;
let changed = false;
const messages = session.messages.map((m) => {
const hit = (byId != null && String(m.id) === byId)
|| (byMid != null && String(m.mid) === byMid)
|| (byFileId != null && m.fileId != null && String(m.fileId) === byFileId);
if (!hit) return m;
changed = true;
const patch = typeof action.patch === 'function' ? action.patch(m) : action.patch;
return { ...m, ...patch };
});
return changed ? patchSession(state, action.id, { messages }) : state;
}
case A.DELETE_MESSAGE: {
const session = state.sessions[action.id];
if (!session) return state;
@@ -294,7 +324,7 @@ export function sessionsReducer(state, action) {
// Decorate a session into the shape the sidebar/header rendering consumes (avatar monogram,
// status dot, sub-text, last-message preview, unread badge). Pure derivation — no state.
export function decorateSession(session, activeSessionId) {
const lastMessage = [...session.messages].reverse().find((m) => !m.expired && typeof m.message === 'string' && m.message.trim());
const lastMessage = [...session.messages].reverse().find((m) => !m.expired && ((typeof m.message === 'string' && m.message.trim()) || m.voice));
const s = session.status;
const isUp = s === 'connected' || s === 'verified';
const isPending = s === 'connecting' || s === 'verifying' || s === 'new';
@@ -311,7 +341,7 @@ export function decorateSession(session, activeSessionId) {
dot = '#e5727a';
headerSub = statusSub(s);
}
const preview = lastMessage ? lastMessage.message : headerSub;
const preview = lastMessage ? (lastMessage.voice ? '🎙 Voice message' : lastMessage.message) : headerSub;
return {
id: session.id,
name: session.peerLabel,
+12
View File
@@ -27,3 +27,15 @@
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}
/* ── Voice messages ─────────────────────────────────────────────── */
/* Pulsing red REC dot while recording. */
@keyframes vmRec {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.35; transform: scale(0.82); }
}
/* Expanding ring behind the record button. */
@keyframes vmRing {
0% { transform: scale(1); opacity: 0.5; }
100% { transform: scale(2.1); opacity: 0; }
}
+75 -6
View File
@@ -348,6 +348,29 @@ class EnhancedSecureFileTransfer {
maxSize: 100 * 1024 * 1024, // 100 MB
category: 'Archives',
description: 'ZIP'
},
// Encrypted voice messages. Recorded in-browser via MediaRecorder and
// sent as a normal chunked+AES-GCM transfer, so they inherit the exact
// same end-to-end security as files. The app normalises the mime to a
// bare `audio/webm` (or `audio/mp4` on Safari) before sending so the
// codec-suffixed MediaRecorder type still matches this allow-list.
voice: {
extensions: ['.webm', '.ogg', '.oga', '.opus', '.m4a', '.mp4', '.mp3', '.wav'],
mimeTypes: [
'audio/webm',
'audio/ogg',
'audio/opus',
'audio/mp4',
'audio/mpeg',
'audio/mp3',
'audio/wav',
'audio/x-m4a',
'audio/aac'
],
maxSize: 20 * 1024 * 1024, // 20 MB (well beyond any sane voice note)
category: 'Voice',
description: 'Voice messages'
}
};
this.BLOCKED_EXTENSIONS = new Set([
@@ -893,7 +916,30 @@ class EnhancedSecureFileTransfer {
// FILE TRANSFER IMPLEMENTATION
// ============================================
async sendFile(file) {
// Emit a progress update to the app layer. `direction` is 'up' for the
// sender and 'down' for the receiver. `uiId` (sender only) lets the UI match
// the event to a locally-created bubble before sendFile() has resolved a
// fileId. Voice fields ride along so the receiver can render the waveform.
_emitTransferProgress(state, direction) {
if (typeof this.onProgress !== 'function' || !state) return;
const total = state.totalChunks || 0;
const done = direction === 'up' ? (state.sentChunks || 0) : (state.receivedCount || 0);
const progress = total > 0 ? Math.min(100, Math.round((done / total) * 100)) : 0;
try {
this.onProgress({
fileId: state.fileId,
uiId: state.uiId || null,
direction,
progress,
transferredChunks: done,
totalChunks: total,
isVoice: !!state.isVoice,
voice: state.voice || null
});
} catch (_) { /* progress is best-effort */ }
}
async sendFile(file, options = {}) {
try {
// Validate webrtcManager
if (!this.webrtcManager) {
@@ -944,7 +990,11 @@ class EnhancedSecureFileTransfer {
startTime: Date.now(),
status: 'preparing',
retryCount: 0,
lastChunkTime: Date.now()
lastChunkTime: Date.now(),
// Voice-message extras (undefined for ordinary files).
isVoice: !!(options && options.voice),
voice: (options && options.voice) ? options.voice : null,
uiId: (options && options.uiId) ? options.uiId : null
};
this.activeTransfers.set(fileId, transferState);
@@ -988,11 +1038,20 @@ class EnhancedSecureFileTransfer {
fileHash: transferState.fileHash,
totalChunks: transferState.totalChunks,
chunkSize: this.CHUNK_SIZE,
salt: transferState.salt,
salt: transferState.salt,
timestamp: Date.now(),
version: '2.0'
};
// Voice descriptor (duration + waveform peaks) travels as unsigned
// presentation metadata — the audio bytes themselves stay integrity-
// protected by the signed `fileHash`. `isVoice` lets the receiver
// auto-accept and render a voice bubble instead of a file card.
if (transferState.isVoice) {
metadata.isVoice = true;
if (transferState.voice) metadata.voice = transferState.voice;
}
if (this.signingKey) {
try {
metadata.signature = await FileMetadataSigner.signFileMetadata(metadata, this.signingKey);
@@ -1038,6 +1097,7 @@ class EnhancedSecureFileTransfer {
// Update progress
transferState.sentChunks++;
const progress = Math.round((transferState.sentChunks / totalChunks) * 95) + 5; // 5-100%
this._emitTransferProgress(transferState, 'up');
await this.waitForBackpressure();
}
@@ -1283,7 +1343,10 @@ class EnhancedSecureFileTransfer {
fileId: pendingMetadata.fileId,
fileName: pendingMetadata.fileName,
fileSize: pendingMetadata.fileSize,
mimeType: pendingMetadata.fileType || 'application/octet-stream'
mimeType: pendingMetadata.fileType || 'application/octet-stream',
// Voice notes auto-accept and render inline (no consent card).
isVoice: !!pendingMetadata.isVoice,
voice: pendingMetadata.voice || null
});
} else {
await this.rejectIncomingFile(metadata.fileId, 'User consent unavailable');
@@ -1370,7 +1433,8 @@ class EnhancedSecureFileTransfer {
// Store chunk
receivingState.receivedChunks.set(chunkMessage.chunkIndex, decryptedChunk);
receivingState.receivedCount++;
this._emitTransferProgress(receivingState, 'down');
// Send chunk confirmation
const confirmation = {
type: 'chunk_confirmation',
@@ -1509,6 +1573,9 @@ class EnhancedSecureFileTransfer {
fileSize: receivingState.fileSize,
mimeType: receivingState.fileType,
transferTime: receivingState.endTime - receivingState.startTime,
// Voice notes are played inline, not saved to disk.
isVoice: !!receivingState.isVoice,
voice: receivingState.voice || null,
// backward-compatibility for existing UIs
fileBlob,
getBlob,
@@ -1741,7 +1808,9 @@ class EnhancedSecureFileTransfer {
receivedCount: 0,
startTime: Date.now(),
lastChunkTime: Date.now(),
status: 'receiving'
status: 'receiving',
isVoice: !!metadata.isVoice,
voice: metadata.voice || null
});
this.pendingIncomingTransfers.delete(fileId);
await this.sendSecureMessage({ type: 'file_transfer_response', fileId, accepted: true, timestamp: Date.now() });
+1 -1
View File
@@ -11,7 +11,7 @@ let DYNAMIC_CACHE = 'securebit-pwa-dynamic-v4.7.56';
// Build stamp — rewritten by scripts/post-build.js on every release so this file's
// bytes change each deploy. That is what makes the browser detect a new Service Worker,
// reinstall it, drop stale caches and (via controllerchange) prompt the page to update.
const SW_BUILD_VERSION = '1782446255208';
const SW_BUILD_VERSION = '1784694927450';
// Load version from meta.json on install
async function getAppVersion() {
+23
View File
@@ -54,6 +54,29 @@ function withTwoSessions() {
assert.equal(state.sessions.b.keyFingerprint, '', 'sibling fingerprint untouched');
}
// Peer presence is cleared when the session leaves the connected state, so a reconnect
// never re-shows the peer's stale status before they re-broadcast it.
{
let state = withTwoSessions();
state = sessionsReducer(state, { type: A.SET_STATUS, id: 'a', status: 'connected' });
state = sessionsReducer(state, { type: A.SET_PEER_PRESENCE, id: 'a', presence: 'busy' });
assert.equal(state.sessions.a.peerPresence, 'busy');
// connected -> verified keeps presence (still connected).
state = sessionsReducer(state, { type: A.SET_STATUS, id: 'a', status: 'verified' });
assert.equal(state.sessions.a.peerPresence, 'busy', 'presence kept while still connected');
// verified -> peer_disconnected clears it.
state = sessionsReducer(state, { type: A.SET_STATUS, id: 'a', status: 'peer_disconnected' });
assert.equal(state.sessions.a.peerPresence, null, 'presence cleared on disconnect');
// Reconnecting does not resurrect the old presence; it stays null until re-broadcast.
state = sessionsReducer(state, { type: A.SET_STATUS, id: 'a', status: 'connected' });
assert.equal(state.sessions.a.peerPresence, null, 'no stale presence after reconnect');
state = sessionsReducer(state, { type: A.SET_PEER_PRESENCE, id: 'a', presence: 'available' });
assert.equal(state.sessions.a.peerPresence, 'available', 'fresh presence applies after reconnect');
}
// UPDATE_MESSAGE_STATUS and DELETE_MESSAGE only touch the named session/message.
{
let state = withTwoSessions();