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