Add end-to-end encrypted voice messages; release v5.4.5
- 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:
Vendored
+635
-31
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user