Add 1:1 voice and video calling over the existing SAS-verified peer connection. Audio and video tracks ride the same RTCPeerConnection as the chat, bundled onto one DTLS-SRTP transport, so media inherits the session's end-to-end encryption. SDP offer/answer is renegotiated in-band over the verified data channel — no signalling server, so the media's DTLS fingerprints are authenticated end-to-end. Calls are gated on a connected, SAS-verified session. Codecs & adaptation: - Opus tuned for lossy links (in-band FEC, DTX, RED redundancy); audio is bandwidth-prioritised and never throttled. - VP9/AV1 single-encoding SVC with H.264/VP8 fallback; video degrades by spatial/temporal layer. - Runtime NetworkAdaptationController trims video bitrate on loss/RTT and recovers as the link clears — no renegotiation. Live connection-quality indicator (Excellent/Good/Fair/Weak) in the call UI. In-call controls: mute, camera on/off (voice→video upgrade in-band), camera flip, minimize-to-widget, hang up, and accept/decline for incoming calls. Production logging disabled (DEBUG_MODE=false); temporary call diagnostic logger removed. Codec rationale in docs/webrtc-config.md.
5517 lines
278 KiB
JavaScript
5517 lines
278 KiB
JavaScript
// src/utils/debugWindowHooks.js
|
|
function isSecureBitDebugEnabled(targetWindow = globalThis.window) {
|
|
return targetWindow?.SECUREBIT_DEBUG === true;
|
|
}
|
|
function installDebugWindowHooks({
|
|
targetWindow = globalThis.window,
|
|
webrtcManagerRef,
|
|
onClearData,
|
|
clearConsole = () => {
|
|
if (typeof console.clear === "function") {
|
|
console.clear();
|
|
}
|
|
}
|
|
}) {
|
|
if (!isSecureBitDebugEnabled(targetWindow)) {
|
|
return () => {
|
|
};
|
|
}
|
|
targetWindow.forceCleanup = () => {
|
|
onClearData();
|
|
if (webrtcManagerRef.current) {
|
|
webrtcManagerRef.current.disconnect();
|
|
}
|
|
};
|
|
targetWindow.clearLogs = clearConsole;
|
|
targetWindow.webrtcManagerRef = webrtcManagerRef;
|
|
return () => {
|
|
delete targetWindow.forceCleanup;
|
|
delete targetWindow.clearLogs;
|
|
delete targetWindow.webrtcManagerRef;
|
|
};
|
|
}
|
|
|
|
// src/network/iceSettingsStore.js
|
|
var DB_NAME = "securebit-net";
|
|
var DB_VERSION = 1;
|
|
var STORE = "kv";
|
|
var KEY_RECORD = "ice-device-key";
|
|
var SETTINGS_RECORD = "ice-settings";
|
|
var SETTINGS_VERSION = 1;
|
|
function isSupported() {
|
|
return typeof indexedDB !== "undefined" && typeof crypto !== "undefined" && !!crypto.subtle;
|
|
}
|
|
function openDb() {
|
|
return new Promise((resolve, reject) => {
|
|
let request;
|
|
try {
|
|
request = indexedDB.open(DB_NAME, DB_VERSION);
|
|
} catch (error) {
|
|
reject(error);
|
|
return;
|
|
}
|
|
request.onupgradeneeded = () => {
|
|
const db = request.result;
|
|
if (!db.objectStoreNames.contains(STORE)) {
|
|
db.createObjectStore(STORE);
|
|
}
|
|
};
|
|
request.onsuccess = () => resolve(request.result);
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
}
|
|
function idbGet(db, key) {
|
|
return new Promise((resolve, reject) => {
|
|
const tx = db.transaction(STORE, "readonly");
|
|
const req = tx.objectStore(STORE).get(key);
|
|
req.onsuccess = () => resolve(req.result);
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
}
|
|
function idbPut(db, key, value) {
|
|
return new Promise((resolve, reject) => {
|
|
const tx = db.transaction(STORE, "readwrite");
|
|
tx.objectStore(STORE).put(value, key);
|
|
tx.oncomplete = () => resolve();
|
|
tx.onerror = () => reject(tx.error);
|
|
});
|
|
}
|
|
function idbDelete(db, key) {
|
|
return new Promise((resolve, reject) => {
|
|
const tx = db.transaction(STORE, "readwrite");
|
|
tx.objectStore(STORE).delete(key);
|
|
tx.oncomplete = () => resolve();
|
|
tx.onerror = () => reject(tx.error);
|
|
});
|
|
}
|
|
async function getOrCreateDeviceKey(db) {
|
|
const existing = await idbGet(db, KEY_RECORD);
|
|
if (existing instanceof CryptoKey) {
|
|
return existing;
|
|
}
|
|
const key = await crypto.subtle.generateKey(
|
|
{ name: "AES-GCM", length: 256 },
|
|
false,
|
|
// non-extractable
|
|
["encrypt", "decrypt"]
|
|
);
|
|
await idbPut(db, KEY_RECORD, key);
|
|
return key;
|
|
}
|
|
async function saveIceSettings(settings) {
|
|
if (!isSupported()) throw new Error("Persistent storage is not available in this browser");
|
|
const db = await openDb();
|
|
const key = await getOrCreateDeviceKey(db);
|
|
const payload = JSON.stringify({
|
|
version: SETTINGS_VERSION,
|
|
servers: Array.isArray(settings?.servers) ? settings.servers : [],
|
|
privacyMode: settings?.privacyMode === "relay-only" ? "relay-only" : "standard"
|
|
});
|
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
const ciphertext = await crypto.subtle.encrypt(
|
|
{ name: "AES-GCM", iv },
|
|
key,
|
|
new TextEncoder().encode(payload)
|
|
);
|
|
await idbPut(db, SETTINGS_RECORD, {
|
|
iv: Array.from(iv),
|
|
data: Array.from(new Uint8Array(ciphertext))
|
|
});
|
|
}
|
|
async function loadIceSettings() {
|
|
if (!isSupported()) return null;
|
|
try {
|
|
const db = await openDb();
|
|
const record = await idbGet(db, SETTINGS_RECORD);
|
|
if (!record || !Array.isArray(record.iv) || !Array.isArray(record.data)) {
|
|
return null;
|
|
}
|
|
const key = await idbGet(db, KEY_RECORD);
|
|
if (!(key instanceof CryptoKey)) return null;
|
|
const plaintext = await crypto.subtle.decrypt(
|
|
{ name: "AES-GCM", iv: new Uint8Array(record.iv) },
|
|
key,
|
|
new Uint8Array(record.data)
|
|
);
|
|
const parsed = JSON.parse(new TextDecoder().decode(plaintext));
|
|
return {
|
|
servers: Array.isArray(parsed.servers) ? parsed.servers : [],
|
|
privacyMode: parsed.privacyMode === "relay-only" ? "relay-only" : "standard"
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
async function clearIceSettings() {
|
|
if (!isSupported()) return;
|
|
try {
|
|
const db = await openDb();
|
|
await idbDelete(db, SETTINGS_RECORD);
|
|
} catch {
|
|
}
|
|
}
|
|
|
|
// src/state/sessionsStore.js
|
|
var SESSION_ACTIONS = Object.freeze({
|
|
CREATE_SESSION: "CREATE_SESSION",
|
|
REMOVE_SESSION: "REMOVE_SESSION",
|
|
SET_ACTIVE: "SET_ACTIVE",
|
|
SET_STATUS: "SET_STATUS",
|
|
SET_FINGERPRINT: "SET_FINGERPRINT",
|
|
SET_VERIFICATION: "SET_VERIFICATION",
|
|
SET_SAS: "SET_SAS",
|
|
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",
|
|
CLEAR_UNREAD: "CLEAR_UNREAD",
|
|
SET_PENDING_FILES: "SET_PENDING_FILES",
|
|
PATCH_SETUP: "PATCH_SETUP",
|
|
RENAME: "RENAME",
|
|
SET_PEER_PRESENCE: "SET_PEER_PRESENCE"
|
|
});
|
|
var PRESENCE_DOT = { available: "#3ecf8e", away: "#e3b341", busy: "#e5727a", offline: "#6b6b73" };
|
|
var PRESENCE_WORD = { available: "Available", away: "Away", busy: "Busy", offline: "Offline" };
|
|
var MY_STATUS_OPTIONS = [
|
|
{ key: "available", word: "Available", desc: "Online and reachable", dot: "#3ecf8e" },
|
|
{ key: "away", word: "Away", desc: "Idle \xB7 stepped away", dot: "#e3b341" },
|
|
{ key: "busy", word: "Busy", desc: "Do not disturb", dot: "#e5727a" },
|
|
{ key: "invisible", word: "Invisible", desc: "Appear offline to peers", dot: "#6b6b73" }
|
|
];
|
|
function shortLabelFromId(id) {
|
|
const hex = String(id || "").replace(/[^a-z0-9]/gi, "");
|
|
return "Chat " + (hex.slice(0, 4) || "0000").toUpperCase();
|
|
}
|
|
function monoInitials(label) {
|
|
const words = String(label || "").trim().split(/\s+/).filter(Boolean);
|
|
const a = words[0]?.[0] || "";
|
|
const b = words[1]?.[0] || words[0]?.[1] || "";
|
|
return (a + b).toUpperCase() || "\xB7\xB7";
|
|
}
|
|
function statusSub(status) {
|
|
switch (status) {
|
|
case "connected":
|
|
case "verified":
|
|
return "P2P \xB7 connected";
|
|
case "verifying":
|
|
return "Verifying\u2026";
|
|
case "connecting":
|
|
case "new":
|
|
return "Connecting\u2026";
|
|
case "peer_disconnected":
|
|
return "Peer disconnected";
|
|
default:
|
|
return "Disconnected";
|
|
}
|
|
}
|
|
function emptySetup() {
|
|
return {
|
|
offerData: "",
|
|
answerData: "",
|
|
offerInput: "",
|
|
answerInput: "",
|
|
showOfferStep: false,
|
|
showAnswerStep: false,
|
|
showVerification: false,
|
|
showQRCode: false,
|
|
qrCodeUrl: "",
|
|
isGeneratingKeys: false,
|
|
qrFramesTotal: 0,
|
|
qrFrameIndex: 0,
|
|
qrManualMode: false
|
|
};
|
|
}
|
|
function createSessionEntry(opts = {}) {
|
|
const id = opts.id || (typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : String(Date.now()) + Math.random());
|
|
return {
|
|
id,
|
|
peerLabel: opts.peerLabel || shortLabelFromId(id),
|
|
labelIsCustom: false,
|
|
// becomes true once the user renames; blocks SAS auto-relabel
|
|
createdAt: opts.createdAt || Date.now(),
|
|
role: opts.role || "offer",
|
|
// 'offer' | 'answer'
|
|
status: opts.status || "new",
|
|
keyFingerprint: "",
|
|
verificationCode: "",
|
|
sas: { localConfirmed: false, remoteConfirmed: false, bothConfirmed: false, isVerified: false },
|
|
messages: [],
|
|
unreadCount: 0,
|
|
pendingIncomingFiles: [],
|
|
peerPresence: null,
|
|
// peer's advertised availability ('available'|'away'|'busy'|'offline'); null = unknown
|
|
setup: emptySetup()
|
|
};
|
|
}
|
|
function createInitialState() {
|
|
return { sessions: {}, order: [], activeSessionId: null };
|
|
}
|
|
function patchSession(state, id, patch) {
|
|
const session = state.sessions[id];
|
|
if (!session) return state;
|
|
return {
|
|
...state,
|
|
sessions: { ...state.sessions, [id]: { ...session, ...patch } }
|
|
};
|
|
}
|
|
function sessionsReducer(state, action) {
|
|
const A = SESSION_ACTIONS;
|
|
switch (action.type) {
|
|
case A.CREATE_SESSION: {
|
|
const entry = action.entry || createSessionEntry(action);
|
|
if (state.sessions[entry.id]) return state;
|
|
return {
|
|
sessions: { ...state.sessions, [entry.id]: entry },
|
|
order: [...state.order, entry.id],
|
|
activeSessionId: action.activate === false ? state.activeSessionId : entry.id
|
|
};
|
|
}
|
|
case A.REMOVE_SESSION: {
|
|
const { id } = action;
|
|
if (!state.sessions[id]) return state;
|
|
const sessions = { ...state.sessions };
|
|
delete sessions[id];
|
|
const order = state.order.filter((x) => x !== id);
|
|
let activeSessionId = state.activeSessionId;
|
|
if (activeSessionId === id) {
|
|
const removedIdx = state.order.indexOf(id);
|
|
activeSessionId = order[Math.max(0, removedIdx - 1)] || order[0] || null;
|
|
}
|
|
return { sessions, order, activeSessionId };
|
|
}
|
|
case A.SET_ACTIVE: {
|
|
if (!state.sessions[action.id]) return state;
|
|
if (state.activeSessionId === action.id) return state;
|
|
return { ...state, activeSessionId: action.id };
|
|
}
|
|
case A.SET_STATUS: {
|
|
const session = state.sessions[action.id];
|
|
if (!session || session.status === action.status) return state;
|
|
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 });
|
|
case A.SET_VERIFICATION:
|
|
return patchSession(state, action.id, { verificationCode: action.code });
|
|
case A.SET_SAS: {
|
|
const session = state.sessions[action.id];
|
|
if (!session) return state;
|
|
return patchSession(state, action.id, { sas: { ...session.sas, ...action.sas } });
|
|
}
|
|
case A.ADD_MESSAGE: {
|
|
const session = state.sessions[action.id];
|
|
if (!session) return state;
|
|
return patchSession(state, action.id, { messages: [...session.messages, action.message] });
|
|
}
|
|
case A.SET_MESSAGES: {
|
|
const session = state.sessions[action.id];
|
|
if (!session) return state;
|
|
const next = typeof action.updater === "function" ? action.updater(session.messages) : action.messages;
|
|
return patchSession(state, action.id, { messages: Array.isArray(next) ? next : [] });
|
|
}
|
|
case A.UPDATE_MESSAGE_STATUS: {
|
|
const session = state.sessions[action.id];
|
|
if (!session) return state;
|
|
let changed = false;
|
|
const messages = session.messages.map((m) => {
|
|
if (String(m.mid) === String(action.mid) && m.status !== action.status) {
|
|
changed = true;
|
|
return { ...m, status: action.status };
|
|
}
|
|
return m;
|
|
});
|
|
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;
|
|
const messages = session.messages.filter((m) => String(m.mid) !== String(action.mid));
|
|
if (messages.length === session.messages.length) return state;
|
|
return patchSession(state, action.id, { messages });
|
|
}
|
|
case A.EXPIRE_MESSAGE: {
|
|
const session = state.sessions[action.id];
|
|
if (!session) return state;
|
|
let changed = false;
|
|
const messages = session.messages.map((m) => {
|
|
if (String(m.id) === String(action.messageId) && !m.expired) {
|
|
changed = true;
|
|
return { ...m, expired: true, message: "", expiresAt: void 0 };
|
|
}
|
|
return m;
|
|
});
|
|
return changed ? patchSession(state, action.id, { messages }) : state;
|
|
}
|
|
case A.INCREMENT_UNREAD: {
|
|
const session = state.sessions[action.id];
|
|
if (!session) return state;
|
|
return patchSession(state, action.id, { unreadCount: session.unreadCount + 1 });
|
|
}
|
|
case A.CLEAR_UNREAD: {
|
|
const session = state.sessions[action.id];
|
|
if (!session || session.unreadCount === 0) return state;
|
|
return patchSession(state, action.id, { unreadCount: 0 });
|
|
}
|
|
case A.SET_PENDING_FILES: {
|
|
const session = state.sessions[action.id];
|
|
if (!session) return state;
|
|
const next = typeof action.updater === "function" ? action.updater(session.pendingIncomingFiles) : action.files;
|
|
return patchSession(state, action.id, { pendingIncomingFiles: Array.isArray(next) ? next : [] });
|
|
}
|
|
case A.PATCH_SETUP: {
|
|
const session = state.sessions[action.id];
|
|
if (!session) return state;
|
|
return patchSession(state, action.id, { setup: { ...session.setup, ...action.patch } });
|
|
}
|
|
case A.RENAME: {
|
|
const session = state.sessions[action.id];
|
|
if (!session) return state;
|
|
const label = String(action.label || "").trim() || session.peerLabel;
|
|
return patchSession(state, action.id, { peerLabel: label, labelIsCustom: true });
|
|
}
|
|
case A.SET_PEER_PRESENCE: {
|
|
const session = state.sessions[action.id];
|
|
if (!session || session.peerPresence === action.presence) return state;
|
|
return patchSession(state, action.id, { peerPresence: action.presence });
|
|
}
|
|
default:
|
|
return state;
|
|
}
|
|
}
|
|
function decorateSession(session, activeSessionId) {
|
|
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";
|
|
let dot, headerSub;
|
|
if (isPending) {
|
|
dot = "#e3b341";
|
|
headerSub = statusSub(s);
|
|
} else if (isUp) {
|
|
dot = session.peerPresence ? PRESENCE_DOT[session.peerPresence] || "#6b6b73" : "#3ecf8e";
|
|
headerSub = session.peerPresence ? PRESENCE_WORD[session.peerPresence] || "Online" : "P2P \xB7 connected";
|
|
} else {
|
|
dot = "#e5727a";
|
|
headerSub = statusSub(s);
|
|
}
|
|
const preview = lastMessage ? lastMessage.voice ? "\u{1F399} Voice message" : lastMessage.message : headerSub;
|
|
return {
|
|
id: session.id,
|
|
name: session.peerLabel,
|
|
mono: monoInitials(session.peerLabel),
|
|
dot,
|
|
headerSub,
|
|
status: session.status,
|
|
peerPresence: session.peerPresence,
|
|
preview,
|
|
unread: session.unreadCount > 0 ? session.unreadCount > 99 ? "99+" : String(session.unreadCount) : null,
|
|
verified: !!session.sas.isVerified,
|
|
active: session.id === activeSessionId,
|
|
inactive: session.id !== activeSessionId
|
|
};
|
|
}
|
|
function decorateSessions(state) {
|
|
return state.order.map((id) => state.sessions[id]).filter(Boolean).map((s) => decorateSession(s, state.activeSessionId));
|
|
}
|
|
|
|
// src/app.jsx
|
|
var copyToClipboardSecure = async (text, autoClearMs = 0) => {
|
|
let ok = false;
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
ok = true;
|
|
} catch (e) {
|
|
try {
|
|
const ta = document.createElement("textarea");
|
|
ta.value = text;
|
|
ta.style.position = "fixed";
|
|
ta.style.opacity = "0";
|
|
document.body.appendChild(ta);
|
|
ta.select();
|
|
ok = document.execCommand("copy");
|
|
document.body.removeChild(ta);
|
|
} catch (_) {
|
|
ok = false;
|
|
}
|
|
}
|
|
if (ok && autoClearMs > 0 && navigator.clipboard && navigator.clipboard.writeText) {
|
|
setTimeout(async () => {
|
|
let current = null;
|
|
let readable = true;
|
|
try {
|
|
current = await navigator.clipboard.readText();
|
|
} catch (_) {
|
|
readable = false;
|
|
}
|
|
if (!readable || current === text) {
|
|
try {
|
|
await navigator.clipboard.writeText("");
|
|
} catch (_) {
|
|
}
|
|
}
|
|
}, autoClearMs);
|
|
}
|
|
return ok;
|
|
};
|
|
var parseMessageSegments = (text) => {
|
|
if (typeof text !== "string" || text.indexOf("```") === -1) return null;
|
|
const segments = [];
|
|
const re = /```([a-zA-Z0-9_+#.-]*)\n?([\s\S]*?)```/g;
|
|
let last = 0;
|
|
let m;
|
|
while ((m = re.exec(text)) !== null) {
|
|
if (m.index > last) segments.push({ kind: "text", content: text.slice(last, m.index) });
|
|
segments.push({ kind: "code", lang: (m[1] || "").toLowerCase(), content: m[2].replace(/\n$/, "") });
|
|
last = re.lastIndex;
|
|
}
|
|
if (last < text.length) segments.push({ kind: "text", content: text.slice(last) });
|
|
return segments.some((s) => s.kind === "code") ? segments : null;
|
|
};
|
|
var HL_KEYWORDS = /* @__PURE__ */ new Set([
|
|
"const",
|
|
"let",
|
|
"var",
|
|
"function",
|
|
"return",
|
|
"if",
|
|
"else",
|
|
"for",
|
|
"while",
|
|
"do",
|
|
"switch",
|
|
"case",
|
|
"break",
|
|
"continue",
|
|
"class",
|
|
"extends",
|
|
"new",
|
|
"this",
|
|
"super",
|
|
"import",
|
|
"export",
|
|
"from",
|
|
"as",
|
|
"default",
|
|
"async",
|
|
"await",
|
|
"try",
|
|
"catch",
|
|
"finally",
|
|
"throw",
|
|
"typeof",
|
|
"instanceof",
|
|
"delete",
|
|
"yield",
|
|
"in",
|
|
"of",
|
|
"def",
|
|
"elif",
|
|
"lambda",
|
|
"pass",
|
|
"with",
|
|
"global",
|
|
"public",
|
|
"private",
|
|
"protected",
|
|
"static",
|
|
"final",
|
|
"void",
|
|
"int",
|
|
"long",
|
|
"float",
|
|
"double",
|
|
"char",
|
|
"bool",
|
|
"boolean",
|
|
"string",
|
|
"struct",
|
|
"enum",
|
|
"interface",
|
|
"package",
|
|
"func",
|
|
"fn",
|
|
"type",
|
|
"where",
|
|
"select",
|
|
"update",
|
|
"insert",
|
|
"delete",
|
|
"where",
|
|
"and",
|
|
"or",
|
|
"not",
|
|
"end",
|
|
"then",
|
|
"fi",
|
|
"done",
|
|
"echo",
|
|
"use",
|
|
"mut",
|
|
"impl",
|
|
"trait",
|
|
"match",
|
|
"module",
|
|
"require"
|
|
]);
|
|
var HL_LITERALS = /* @__PURE__ */ new Set(["true", "false", "null", "undefined", "None", "True", "False", "nil", "NaN", "Infinity"]);
|
|
var highlightCode = (code) => {
|
|
const re = /(\/\/[^\n]*|#[^\n]*|\/\*[\s\S]*?\*\/|--[^\n]*)|(`(?:\\.|[^`\\])*`|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')|(\b\d[\d_.]*(?:[eE][+-]?\d+)?\b|\b0[xX][0-9a-fA-F]+\b)|([A-Za-z_$][A-Za-z0-9_$]*)/g;
|
|
const nodes = [];
|
|
let buffer = "";
|
|
let last = 0;
|
|
let key = 0;
|
|
const flush = () => {
|
|
if (buffer) {
|
|
nodes.push(buffer);
|
|
buffer = "";
|
|
}
|
|
};
|
|
let m;
|
|
while ((m = re.exec(code)) !== null) {
|
|
if (m.index > last) buffer += code.slice(last, m.index);
|
|
last = re.lastIndex;
|
|
let cls = null;
|
|
if (m[1]) cls = "text-gray-500 italic";
|
|
else if (m[2]) cls = "text-amber-300";
|
|
else if (m[3]) cls = "text-sky-300";
|
|
else if (m[4]) {
|
|
if (HL_KEYWORDS.has(m[4])) cls = "text-purple-300";
|
|
else if (HL_LITERALS.has(m[4])) cls = "text-sky-300";
|
|
}
|
|
if (cls) {
|
|
flush();
|
|
nodes.push(React.createElement("span", { key: `h${key++}`, className: cls }, m[0]));
|
|
} else {
|
|
buffer += m[0];
|
|
}
|
|
}
|
|
if (last < code.length) buffer += code.slice(last);
|
|
flush();
|
|
return nodes;
|
|
};
|
|
var PRISM_ALIAS = {
|
|
js: "javascript",
|
|
mjs: "javascript",
|
|
javascript: "javascript",
|
|
node: "javascript",
|
|
ts: "typescript",
|
|
typescript: "typescript",
|
|
jsx: "jsx",
|
|
tsx: "tsx",
|
|
py: "python",
|
|
python: "python",
|
|
sh: "bash",
|
|
shell: "bash",
|
|
zsh: "bash",
|
|
bash: "bash",
|
|
"c++": "cpp",
|
|
cpp: "cpp",
|
|
cc: "cpp",
|
|
cxx: "cpp",
|
|
c: "c",
|
|
h: "c",
|
|
cs: "csharp",
|
|
csharp: "csharp",
|
|
java: "java",
|
|
go: "go",
|
|
golang: "go",
|
|
rs: "rust",
|
|
rust: "rust",
|
|
json: "json",
|
|
yml: "yaml",
|
|
yaml: "yaml",
|
|
sql: "sql",
|
|
md: "markdown",
|
|
markdown: "markdown",
|
|
html: "markup",
|
|
xml: "markup",
|
|
svg: "markup",
|
|
css: "css"
|
|
};
|
|
var CodeBlock = ({ code, lang }) => {
|
|
const [copied, setCopied] = React.useState(false);
|
|
const handleCopy = async () => {
|
|
const ok = await copyToClipboardSecure(code, 3e4);
|
|
if (ok) {
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2e3);
|
|
}
|
|
};
|
|
const norm = PRISM_ALIAS[(lang || "").toLowerCase()] || (lang || "").toLowerCase();
|
|
const prism = typeof window !== "undefined" ? window.Prism : null;
|
|
const grammar = prism && prism.languages ? prism.languages[norm] : null;
|
|
const usePrism = !!(prism && grammar && typeof prism.highlight === "function");
|
|
let highlightedHtml = null;
|
|
if (usePrism) {
|
|
try {
|
|
highlightedHtml = prism.highlight(code, grammar, norm);
|
|
} catch (_) {
|
|
highlightedHtml = null;
|
|
}
|
|
}
|
|
const displayLang = usePrism ? norm : lang || "code";
|
|
const codeEl = usePrism && highlightedHtml != null ? React.createElement("code", { className: "language-" + norm, dangerouslySetInnerHTML: { __html: highlightedHtml } }) : React.createElement("code", null, highlightCode(code));
|
|
return React.createElement("div", {
|
|
className: "my-1 rounded-lg overflow-hidden",
|
|
style: { backgroundColor: "#1b1c1b", border: "0 solid #e5e7eb" }
|
|
}, [
|
|
React.createElement("div", {
|
|
key: "hdr",
|
|
className: "flex items-center justify-between px-3 py-1.5",
|
|
style: { backgroundColor: "#222322", border: "0 solid #e5e7eb" }
|
|
}, [
|
|
React.createElement("span", {
|
|
key: "lang",
|
|
className: "text-[11px] uppercase tracking-wide text-gray-500 font-mono"
|
|
}, displayLang),
|
|
React.createElement("button", {
|
|
key: "copy",
|
|
onClick: handleCopy,
|
|
title: "Copy \u2014 clipboard auto-clears in 30s",
|
|
className: "flex items-center text-[11px] text-gray-400 hover:text-green-400 transition-colors"
|
|
}, [
|
|
React.createElement("i", {
|
|
key: "ic",
|
|
className: `${copied ? "fas fa-check text-green-400" : "far fa-copy"} mr-1`
|
|
}),
|
|
copied ? "Copied" : "Copy"
|
|
])
|
|
]),
|
|
React.createElement("pre", {
|
|
key: "pre",
|
|
className: "px-3 py-2 overflow-x-auto text-xs leading-relaxed text-gray-200 custom-scrollbar",
|
|
style: { whiteSpace: "pre", fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace", margin: 0 }
|
|
}, codeEl)
|
|
]);
|
|
};
|
|
var MessageBody = ({ text }) => {
|
|
const segments = parseMessageSegments(text);
|
|
if (!segments) {
|
|
return React.createElement("div", {
|
|
className: "text-sm break-words",
|
|
style: { whiteSpace: "pre-wrap", wordBreak: "break-word" }
|
|
}, text);
|
|
}
|
|
return React.createElement(
|
|
"div",
|
|
{ className: "text-sm" },
|
|
segments.map(
|
|
(seg, i) => seg.kind === "code" ? React.createElement(CodeBlock, { key: i, code: seg.content, lang: seg.lang }) : seg.content.trim() ? React.createElement("div", {
|
|
key: i,
|
|
className: "break-words",
|
|
style: { whiteSpace: "pre-wrap", wordBreak: "break-word" }
|
|
}, seg.content) : null
|
|
)
|
|
);
|
|
};
|
|
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 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" });
|
|
React.useEffect(() => () => {
|
|
if (revealTimerRef.current) clearTimeout(revealTimerRef.current);
|
|
}, []);
|
|
if (type === "system" || type === "notice") {
|
|
const isNotice = type === "notice";
|
|
return React.createElement(
|
|
"div",
|
|
{ className: "message-slide", style: { display: "flex", justifyContent: "center", margin: "4px 0" } },
|
|
React.createElement("div", {
|
|
style: { maxWidth: "80%", padding: "8px 14px", borderRadius: "10px", border: "1px solid " + (isNotice ? "rgba(62,207,142,0.25)" : "rgba(240,137,42,0.22)"), background: isNotice ? "rgba(62,207,142,0.08)" : "rgba(240,137,42,0.08)", color: isNotice ? "#8fe0bb" : "#e8b27a", fontSize: "12.5px", textAlign: "center", lineHeight: 1.5 }
|
|
}, message)
|
|
);
|
|
}
|
|
const isMe = type === "sent";
|
|
const encrypted = isMe;
|
|
const isViewOnce = type === "received" && viewOnce === true;
|
|
const remaining = typeof expiresAt === "number" ? Math.max(0, Math.ceil((expiresAt - (nowTick || Date.now())) / 1e3)) : null;
|
|
const fmtRemaining = (sec) => {
|
|
if (sec == null) return "";
|
|
const h = Math.floor(sec / 3600), m = Math.floor(sec % 3600 / 60), s = sec % 60;
|
|
const pad = (n) => String(n).padStart(2, "0");
|
|
return h > 0 ? h + ":" + pad(m) + ":" + pad(s) : m + ":" + pad(s);
|
|
};
|
|
const handleReveal = () => {
|
|
if (revealed) return;
|
|
setRevealed(true);
|
|
const ms = Math.max(1, typeof viewOnceTtl === "number" ? viewOnceTtl : 15) * 1e3;
|
|
revealTimerRef.current = setTimeout(() => {
|
|
onExpire && onExpire();
|
|
}, ms);
|
|
};
|
|
const radius = isMe ? "14px 14px 4px 14px" : "14px 14px 14px 4px";
|
|
const border = isMe ? "1px solid rgba(255,255,255,0.10)" : "1px solid rgba(255,255,255,0.06)";
|
|
const bg = isMe ? "#26262b" : "#161618";
|
|
const isExpired = expired === true || typeof expiresAt === "number" && (nowTick || Date.now()) >= expiresAt;
|
|
if (isExpired) {
|
|
return React.createElement("div", {
|
|
className: "message-slide",
|
|
style: { display: "flex", width: "100%", justifyContent: isMe ? "flex-end" : "flex-start" }
|
|
}, React.createElement(
|
|
"div",
|
|
{ style: { maxWidth: "74%", minWidth: "170px" } },
|
|
React.createElement("div", { style: { display: "flex", alignItems: "center", gap: "9px", padding: "12px 15px", borderRadius: radius, border: "1px dashed rgba(255,255,255,0.1)", background: "rgba(255,255,255,0.018)" } }, [
|
|
React.createElement("i", { key: "i", className: "fas fa-clock", style: { color: "#6b6b73", fontSize: "13px" } }),
|
|
React.createElement("span", { key: "t", style: { fontSize: "13px", color: "#6b6b73", fontStyle: "italic" } }, "This message has expired")
|
|
])
|
|
));
|
|
}
|
|
let body;
|
|
if (voice) {
|
|
body = React.createElement(VoicePlayer, { key: "voice", voice, isMe });
|
|
} else if (isViewOnce && !revealed) {
|
|
body = React.createElement("div", {
|
|
key: "cover",
|
|
onClick: handleReveal,
|
|
style: { position: "relative", cursor: "pointer", padding: "12px 15px 10px", overflow: "hidden" }
|
|
}, [
|
|
React.createElement("div", { key: "blur", style: { fontSize: "14.5px", lineHeight: 1.55, color: "#b3b3ba", filter: "blur(7px)", userSelect: "none", pointerEvents: "none", wordBreak: "break-word", minHeight: "22px" } }, message),
|
|
React.createElement("div", { key: "grain", style: { position: "absolute", inset: 0, backgroundImage: GRAIN_URL, backgroundSize: "90px", opacity: 0.18, mixBlendMode: "screen", pointerEvents: "none" } }),
|
|
React.createElement("div", { key: "lbl", style: { position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center", gap: "7px", pointerEvents: "none" } }, [
|
|
React.createElement("i", { key: "i", className: "fas fa-eye-slash", style: { color: "#e8e8eb", fontSize: "13px" } }),
|
|
React.createElement("span", { key: "t", style: { fontSize: "12px", fontWeight: 600, color: "#e8e8eb", textShadow: "0 1px 5px rgba(0,0,0,0.75)" } }, "View once \xB7 tap to reveal")
|
|
])
|
|
]);
|
|
} else {
|
|
body = React.createElement(
|
|
"div",
|
|
{ key: "body", style: { padding: "12px 15px 10px", color: "#e9e9ec" } },
|
|
React.createElement(MessageBody, { text: message })
|
|
);
|
|
}
|
|
const metaLeft = [
|
|
React.createElement("span", { key: "time", style: { fontFamily: SB_MONO, fontSize: "10.5px", color: "#6b6b73" } }, formatTime(timestamp))
|
|
];
|
|
if (isMe) {
|
|
const stCfg = {
|
|
sending: { icon: "fa-clock", color: "#6b6b73", label: "Sending" },
|
|
sent: { icon: "fa-check", color: "#8a8a92", label: "Sent" },
|
|
// Two GREY ticks = delivered to the peer's device but not yet read.
|
|
delivered: { icon: "fa-check-double", color: "#8a8a92", label: "Delivered" },
|
|
// Two GREEN ticks = the peer actually opened the chat and read it.
|
|
read: { icon: "fa-check-double", color: "#3ecf8e", label: "Read" },
|
|
failed: { icon: "fa-triangle-exclamation", color: "#e5727a", label: "Not sent" }
|
|
}[status || "sent"] || { icon: "fa-check", color: "#8a8a92", label: "Sent" };
|
|
metaLeft.push(React.createElement("span", {
|
|
key: "dlv",
|
|
title: stCfg.label,
|
|
style: { display: "inline-flex", alignItems: "center", color: stCfg.color }
|
|
}, React.createElement("i", { className: "fas " + stCfg.icon, style: { fontSize: "10.5px" } })));
|
|
}
|
|
if (isViewOnce && revealed) {
|
|
metaLeft.push(React.createElement("span", { key: "vo", style: { display: "inline-flex", alignItems: "center", gap: "4px", fontSize: "10px", fontWeight: 600, color: "#8a8a92" } }, [
|
|
React.createElement("span", { key: "d", style: { width: "4px", height: "4px", borderRadius: "50%", background: "#8a8a92" } }),
|
|
"Viewed once"
|
|
]));
|
|
} else if (remaining !== null) {
|
|
metaLeft.push(React.createElement("span", { key: "ttl", style: { display: "inline-flex", alignItems: "center", gap: "4px", fontFamily: SB_MONO, fontSize: "10.5px", fontWeight: 500, color: "#f0892a" } }, [
|
|
React.createElement("i", { key: "i", className: "fas fa-clock", style: { fontSize: "10px" } }),
|
|
fmtRemaining(remaining)
|
|
]));
|
|
}
|
|
const metaRight = [];
|
|
metaRight.push(React.createElement("span", { key: "status", style: { display: "inline-flex", alignItems: "center", gap: "5px", fontSize: "10.5px", fontWeight: 600, color: "#3ecf8e", flex: "none" } }, [
|
|
React.createElement("i", { key: "i", className: encrypted ? "fas fa-lock" : "fas fa-lock-open", style: { fontSize: "10px" } }),
|
|
encrypted ? "Encrypted" : "Decrypted"
|
|
]));
|
|
if (canUnsend && isMe && mid) {
|
|
metaRight.push(React.createElement("button", {
|
|
key: "unsend",
|
|
onClick: () => onUnsend && onUnsend(mid),
|
|
title: "Delete for everyone",
|
|
className: "sb-unsend",
|
|
style: { background: "none", border: "none", cursor: "pointer", color: "#56565e", fontSize: "11px", padding: 0, lineHeight: 1 }
|
|
}, React.createElement("i", { className: "fas fa-trash-can" })));
|
|
}
|
|
const meta = React.createElement("div", {
|
|
key: "meta",
|
|
style: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: "14px", padding: "0 15px 10px" }
|
|
}, [
|
|
React.createElement("div", { key: "l", style: { display: "flex", alignItems: "center", gap: "9px", minWidth: 0 } }, metaLeft),
|
|
React.createElement("div", { key: "r", style: { display: "flex", alignItems: "center", gap: "9px", flex: "none" } }, metaRight)
|
|
]);
|
|
return React.createElement("div", {
|
|
className: "message-slide",
|
|
style: { display: "flex", width: "100%", justifyContent: isMe ? "flex-end" : "flex-start" }
|
|
}, [
|
|
React.createElement(
|
|
"div",
|
|
{ key: "wrap", style: { maxWidth: "74%", minWidth: "170px" } },
|
|
React.createElement("div", { style: { borderRadius: radius, border, background: bg, overflow: "hidden" } }, [body, meta])
|
|
)
|
|
]);
|
|
};
|
|
var EnhancedConnectionSetup = ({
|
|
messages,
|
|
onCreateOffer,
|
|
onCreateAnswer,
|
|
onConnect,
|
|
onClearData,
|
|
onVerifyConnection,
|
|
connectionStatus,
|
|
offerData,
|
|
answerData,
|
|
offerInput,
|
|
setOfferInput,
|
|
answerInput,
|
|
setAnswerInput,
|
|
showOfferStep,
|
|
showAnswerStep,
|
|
verificationCode,
|
|
showVerification,
|
|
showQRCode,
|
|
qrCodeUrl,
|
|
showQRScanner,
|
|
setShowQRCode,
|
|
setShowQRScanner,
|
|
setShowQRScannerModal,
|
|
offerPassword,
|
|
answerPassword,
|
|
localVerificationConfirmed,
|
|
remoteVerificationConfirmed,
|
|
bothVerificationsConfirmed,
|
|
// QR control props
|
|
qrFramesTotal,
|
|
qrFrameIndex,
|
|
qrManualMode,
|
|
toggleQrManualMode,
|
|
nextQrFrame,
|
|
prevQrFrame,
|
|
markAnswerCreated,
|
|
notificationIntegrationRef,
|
|
isGeneratingKeys,
|
|
setIsGeneratingKeys,
|
|
handleCreateOffer,
|
|
relayOnlyMode,
|
|
setRelayOnlyMode,
|
|
webrtcManagerRef,
|
|
showIceSettings,
|
|
setShowIceSettings,
|
|
iceServersText,
|
|
iceSettingsPersisted,
|
|
customIceServers,
|
|
handleApplyIceSettings,
|
|
handleForgetIceSettings,
|
|
// When true, render ONLY the create/connect card (no marketing landing,
|
|
// no hero) so it slots into the chat column for an additional session.
|
|
compact = false
|
|
}) => {
|
|
const [mode, setMode] = React.useState("create");
|
|
const [notificationPermissionRequested, setNotificationPermissionRequested] = React.useState(false);
|
|
const [qrModalOpen, setQrModalOpen] = React.useState(false);
|
|
const [copied, setCopied] = React.useState(false);
|
|
const [sasInput, setSasInput] = React.useState("");
|
|
const [sasError, setSasError] = React.useState("");
|
|
const [platformsOpen, setPlatformsOpen] = React.useState(false);
|
|
const [codeRevealed, setCodeRevealed] = React.useState(false);
|
|
const [genProgress, setGenProgress] = React.useState(0);
|
|
React.useEffect(() => {
|
|
setSasInput("");
|
|
setSasError("");
|
|
}, [verificationCode]);
|
|
React.useEffect(() => {
|
|
if (!showOfferStep && !showAnswerStep) setQrModalOpen(false);
|
|
setCodeRevealed(false);
|
|
}, [showOfferStep, showAnswerStep]);
|
|
React.useEffect(() => {
|
|
const generating = isGeneratingKeys && !showOfferStep && !showAnswerStep && !showVerification;
|
|
if (!generating) {
|
|
setGenProgress(0);
|
|
return;
|
|
}
|
|
setGenProgress(0);
|
|
let p = 0;
|
|
const id = setInterval(() => {
|
|
p += 1;
|
|
setGenProgress(p);
|
|
if (p >= 3) clearInterval(id);
|
|
}, 520);
|
|
return () => clearInterval(id);
|
|
}, [isGeneratingKeys, showOfferStep, showAnswerStep, showVerification]);
|
|
React.useEffect(() => {
|
|
if (!platformsOpen) return;
|
|
const onDoc = () => setPlatformsOpen(false);
|
|
const id = setTimeout(() => document.addEventListener("click", onDoc), 0);
|
|
return () => {
|
|
clearTimeout(id);
|
|
document.removeEventListener("click", onDoc);
|
|
};
|
|
}, [platformsOpen]);
|
|
const resetToSelect = () => {
|
|
setIsGeneratingKeys(false);
|
|
setQrModalOpen(false);
|
|
onClearData();
|
|
};
|
|
const handleVerificationConfirm = (userCode) => {
|
|
return onVerifyConnection(userCode);
|
|
};
|
|
const handleVerificationReject = () => {
|
|
onVerifyConnection(null, false);
|
|
};
|
|
const requestNotificationPermissionOnInteraction = async () => {
|
|
if (notificationPermissionRequested) {
|
|
return;
|
|
}
|
|
try {
|
|
if (!("Notification" in window)) {
|
|
return;
|
|
}
|
|
if (!window.isSecureContext && window.location.protocol !== "https:" && window.location.hostname !== "localhost") {
|
|
return;
|
|
}
|
|
const currentPermission = typeof Notification !== "undefined" && Notification ? Notification.permission : "denied";
|
|
if (currentPermission === "default" && typeof Notification !== "undefined" && Notification) {
|
|
const permission = await Notification.requestPermission();
|
|
if (permission === "granted") {
|
|
try {
|
|
if (window.NotificationIntegration && webrtcManagerRef.current) {
|
|
const integration = new window.NotificationIntegration(webrtcManagerRef.current);
|
|
await integration.init();
|
|
notificationIntegrationRef.current = integration;
|
|
}
|
|
} catch (error) {
|
|
}
|
|
setTimeout(() => {
|
|
try {
|
|
const welcomeNotification = new Notification("SecureBit Chat", {
|
|
body: "Notifications enabled! You will receive alerts for new messages.",
|
|
icon: "/logo/icon-192x192.png",
|
|
tag: "welcome-notification"
|
|
});
|
|
welcomeNotification.onclick = () => {
|
|
welcomeNotification.close();
|
|
};
|
|
setTimeout(() => {
|
|
welcomeNotification.close();
|
|
}, 5e3);
|
|
} catch (error) {
|
|
}
|
|
}, 1e3);
|
|
}
|
|
} else if (currentPermission === "granted") {
|
|
try {
|
|
if (window.NotificationIntegration && webrtcManagerRef.current && !notificationIntegrationRef.current) {
|
|
const integration = new window.NotificationIntegration(webrtcManagerRef.current);
|
|
await integration.init();
|
|
notificationIntegrationRef.current = integration;
|
|
}
|
|
} catch (error) {
|
|
}
|
|
setTimeout(() => {
|
|
try {
|
|
const testNotification = new Notification("SecureBit Chat", {
|
|
body: "Notifications are working! You will receive alerts for new messages.",
|
|
icon: "/logo/icon-192x192.png",
|
|
tag: "test-notification"
|
|
});
|
|
testNotification.onclick = () => {
|
|
testNotification.close();
|
|
};
|
|
setTimeout(() => {
|
|
testNotification.close();
|
|
}, 5e3);
|
|
} catch (error) {
|
|
}
|
|
}, 1e3);
|
|
}
|
|
setNotificationPermissionRequested(true);
|
|
} catch (error) {
|
|
}
|
|
};
|
|
const h = React.createElement;
|
|
const C_ORANGE = "#f0892a";
|
|
const C_GREEN = "#3ecf8e";
|
|
const MONO = SB_MONO;
|
|
const encode = (data) => {
|
|
try {
|
|
const min = typeof data === "object" ? JSON.stringify(data) : data || "";
|
|
if (!min) return "";
|
|
if (typeof window.encodeBinaryToPrefixed === "function") return window.encodeBinaryToPrefixed(min);
|
|
if (typeof window.compressToPrefixedGzip === "function") return window.compressToPrefixedGzip(min);
|
|
return min;
|
|
} catch {
|
|
return typeof data === "object" ? JSON.stringify(data) : data || "";
|
|
}
|
|
};
|
|
const isCreate = mode === "create";
|
|
const isGenerating = isGeneratingKeys && !showOfferStep && !showAnswerStep && !showVerification;
|
|
const isOfferCred = isCreate && showOfferStep && !showVerification;
|
|
const isAnswerCred = !isCreate && showAnswerStep && !showVerification;
|
|
const atIntro = !showVerification && !isGenerating && !isOfferCred && !isAnswerCred;
|
|
const accent = isCreate ? C_ORANGE : C_GREEN;
|
|
const kicker = showVerification ? "Step 3 \xB7 verification" : isOfferCred || isAnswerCred ? "Step 2 \xB7 exchange" : "Step 1 \xB7 open a channel";
|
|
const credCode = isCreate ? encode(offerData) : encode(answerData);
|
|
const hasInvite = (offerInput || "").trim().length > 0;
|
|
const hasAnswer = (answerInput || "").trim().length > 0;
|
|
const copyCred = async () => {
|
|
try {
|
|
if (typeof copyToClipboardSecure === "function") await copyToClipboardSecure(credCode);
|
|
else await navigator.clipboard.writeText(credCode);
|
|
} catch (e) {
|
|
}
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 1600);
|
|
};
|
|
const normExpected = (verificationCode || "").replace(/[-\s]/g, "").length;
|
|
const normInput = sasInput.replace(/[-\s]/g, "").length;
|
|
const canConfirm = !localVerificationConfirmed && normExpected > 0 && normInput === normExpected;
|
|
const handleSasConfirm = async () => {
|
|
try {
|
|
setSasError("");
|
|
await onVerifyConnection(sasInput);
|
|
} catch (err) {
|
|
setSasInput("");
|
|
setSasError(err?.message === "SAS_MAX_ATTEMPTS" ? "Too many incorrect attempts. Session reset for safety." : "Incorrect code. Check it with your peer and try again.");
|
|
}
|
|
};
|
|
const ICON_DEFS = {
|
|
"fa-user": { sw: 1.9, e: [["circle", { cx: 12, cy: 8, r: 3.6 }], ["path", { d: "M5 20c0-3.5 3-5.5 7-5.5s7 2 7 5.5" }]] },
|
|
"fa-lock": { sw: 2, e: [["path", { d: "M7 11V7a5 5 0 0 1 10 0v4" }], ["rect", { x: 4.5, y: 11, width: 15, height: 9, rx: 2.2 }]] },
|
|
"fa-plus": { sw: 2.1, e: [["path", { d: "M12 5v14M5 12h14" }]] },
|
|
"fa-link": { sw: 2, e: [["path", { d: "M9.5 14.5l5-5M8 11l-2.2 2.2a3.5 3.5 0 0 0 4.95 4.95L13 16M16 13l2.2-2.2a3.5 3.5 0 0 0-4.95-4.95L11 8" }]] },
|
|
"fa-bolt": { sw: 2.1, e: [["path", { d: "M13 2L4.5 13H11l-1 9 8.5-11H12l1-9z" }]] },
|
|
"fa-camera": { sw: 1.8, e: [["path", { d: "M2 8.5V6.5A2.5 2.5 0 0 1 4.5 4h2M17.5 4h2A2.5 2.5 0 0 1 22 6.5v2M22 15.5v2a2.5 2.5 0 0 1-2.5 2.5h-2M6.5 20h-2A2.5 2.5 0 0 1 2 17.5v-2" }], ["circle", { cx: 12, cy: 12, r: 3.2 }]] },
|
|
"fa-qrcode": { sw: 1.9, e: [["rect", { x: 3, y: 3, width: 7, height: 7, rx: 1.3 }], ["rect", { x: 14, y: 3, width: 7, height: 7, rx: 1.3 }], ["rect", { x: 3, y: 14, width: 7, height: 7, rx: 1.3 }], ["path", { d: "M14 14h3v3M21 14v.01M14 21h.01M21 21v-4M17.5 21H21" }]] },
|
|
"fa-chevron-right": { sw: 2, e: [["path", { d: "M9 6l6 6-6 6" }]] },
|
|
"fa-chevron-left": { sw: 2, e: [["path", { d: "M15 6l-6 6 6 6" }]] },
|
|
"fa-chevron-down": { sw: 2, e: [["path", { d: "M6 9l6 6 6-6" }]] },
|
|
"fa-circle-notch": { sw: 2, e: [["path", { d: "M21 12a9 9 0 1 1-6.2-8.6" }]] },
|
|
"fa-check": { sw: 2.4, e: [["path", { d: "M20 6L9 17l-5-5" }]] },
|
|
"fa-check-circle": { sw: 2, e: [["circle", { cx: 12, cy: 12, r: 9 }], ["path", { d: "M8.5 12.4l2.4 2.4 4.6-5" }]] },
|
|
"fa-shield-alt": { sw: 1.9, e: [["path", { d: "M12 2.6l7 3v5.1c0 4.5-3 8.3-7 10.2-4-1.9-7-5.7-7-10.2V5.6l7-3z" }], ["path", { d: "M9 12l2 2 4-4.1" }]] },
|
|
"fa-download": { sw: 2, e: [["path", { d: "M12 3v12M12 15l-4.5-4.5M12 15l4.5-4.5" }], ["path", { d: "M4 20h16" }]] },
|
|
"fa-clock": { sw: 1.8, e: [["circle", { cx: 12, cy: 13, r: 8 }], ["path", { d: "M12 9v4l2.5 2M9 2h6" }]] },
|
|
"fa-times": { sw: 2.2, e: [["path", { d: "M18 6L6 18M6 6l12 12" }]] },
|
|
"fa-eye": { sw: 1.9, e: [["path", { d: "M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7-10-7-10-7z" }], ["circle", { cx: 12, cy: 12, r: 3 }]] }
|
|
};
|
|
const fa = (name, opts) => {
|
|
opts = opts || {};
|
|
const def = ICON_DEFS[name];
|
|
if (!def) {
|
|
const st = {};
|
|
if (opts.color) st.color = opts.color;
|
|
if (opts.fontSize) st.fontSize = opts.fontSize;
|
|
if (opts.animation) st.animation = opts.animation;
|
|
if (opts.style) Object.assign(st, opts.style);
|
|
return h("i", { key: opts.key, className: `fas ${name}`, style: st });
|
|
}
|
|
const size = opts.fontSize ? parseFloat(opts.fontSize) : 16;
|
|
const svgStyle = {};
|
|
if (opts.animation) {
|
|
svgStyle.animation = opts.animation;
|
|
svgStyle.transformOrigin = "center";
|
|
svgStyle.transformBox = "fill-box";
|
|
}
|
|
if (opts.style) Object.assign(svgStyle, opts.style);
|
|
return h("svg", {
|
|
key: opts.key,
|
|
width: size,
|
|
height: size,
|
|
viewBox: "0 0 24 24",
|
|
fill: "none",
|
|
stroke: opts.color || "currentColor",
|
|
strokeWidth: def.sw || 2,
|
|
strokeLinecap: "round",
|
|
strokeLinejoin: "round",
|
|
style: svgStyle
|
|
}, def.e.map((el, i) => h(el[0], Object.assign({ key: i }, el[1]))));
|
|
};
|
|
const leftPanel = h("div", {
|
|
key: "left",
|
|
className: "sb-start-left",
|
|
style: {
|
|
flex: "1.05 1 380px",
|
|
position: "relative",
|
|
overflow: "hidden",
|
|
// Full viewport height even when the panels stack on mobile, so the
|
|
// branding column isn't collapsed/cramped (looked broken otherwise).
|
|
minHeight: "100vh",
|
|
boxSizing: "border-box",
|
|
padding: "46px",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
justifyContent: "space-between",
|
|
gap: "36px",
|
|
borderRight: "1px solid rgba(255,255,255,0.06)",
|
|
background: "radial-gradient(900px 600px at 25% 18%, rgba(240,137,42,0.07), transparent 62%), radial-gradient(800px 700px at 80% 92%, rgba(62,207,142,0.06), transparent 60%), #0c0c0e"
|
|
}
|
|
}, [
|
|
h(
|
|
"div",
|
|
{ key: "herowrap", style: { flex: 1, display: "flex", flexDirection: "column", justifyContent: "center", position: "relative", zIndex: 2 } },
|
|
h("div", { key: "hero", style: { maxWidth: "470px" } }, [
|
|
h("h1", { key: "h1", style: { margin: "0 0 14px", fontSize: "34px", fontWeight: 800, letterSpacing: "-1.1px", lineHeight: 1.1, color: "#f4f4f6" } }, [
|
|
"A direct line",
|
|
h("br", { key: "br" }),
|
|
"only you two can read."
|
|
]),
|
|
h(
|
|
"p",
|
|
{ key: "p", style: { margin: "0 0 38px", fontSize: "14.5px", lineHeight: 1.6, color: "#8a8a92", maxWidth: "390px" } },
|
|
"Keys are generated on your device and exchanged peer-to-peer. No accounts, no servers storing your messages."
|
|
),
|
|
// animated channel
|
|
h("div", { key: "channel", style: { display: "flex", alignItems: "center", height: "74px" } }, [
|
|
h("div", { key: "you", style: { flex: "none", display: "flex", flexDirection: "column", alignItems: "center", gap: "8px", width: "74px" } }, [
|
|
h("div", { key: "n", style: { width: "50px", height: "50px", borderRadius: "15px", display: "grid", placeItems: "center", background: "rgba(240,137,42,0.13)", border: "1px solid rgba(240,137,42,0.3)", animation: "sbNode 3s ease-in-out infinite" } }, fa("fa-user", { color: C_ORANGE, fontSize: "20px" })),
|
|
h("span", { key: "l", style: { fontSize: "11px", fontWeight: 600, color: "#9a9aa2" } }, "You")
|
|
]),
|
|
h("div", { key: "wire", style: { flex: 1, position: "relative", height: "52px", margin: "0 -6px" } }, [
|
|
h("div", { key: "line", style: { position: "absolute", top: "50%", left: 0, right: 0, height: "2px", transform: "translateY(-50%)", background: "linear-gradient(90deg, rgba(240,137,42,0.45), rgba(62,207,142,0.45))" } }),
|
|
h("span", { key: "d1", style: { position: "absolute", top: "50%", transform: "translateY(-50%)", width: "6px", height: "6px", borderRadius: "50%", background: C_ORANGE, boxShadow: `0 0 8px ${C_ORANGE}`, animation: "sbFlowR 2.6s linear infinite" } }),
|
|
h("span", { key: "d2", style: { position: "absolute", top: "50%", transform: "translateY(-50%)", width: "6px", height: "6px", borderRadius: "50%", background: C_GREEN, boxShadow: `0 0 8px ${C_GREEN}`, animation: "sbFlowL 2.6s linear infinite", animationDelay: "0.6s" } }),
|
|
h("div", { key: "hub", style: { position: "absolute", top: "50%", left: "50%", transform: "translate(-50%,-50%)", width: "38px", height: "38px" } }, [
|
|
h("span", { key: "pulse", style: { position: "absolute", top: "50%", left: "50%", width: "38px", height: "38px", borderRadius: "50%", border: "1.5px solid rgba(62,207,142,0.5)", animation: "sbPulse 2.4s ease-out infinite" } }),
|
|
h("div", { key: "core", style: { position: "relative", width: "38px", height: "38px", borderRadius: "50%", display: "grid", placeItems: "center", background: "#121214", border: "1px solid rgba(62,207,142,0.45)", boxShadow: "0 0 18px rgba(62,207,142,0.25)" } }, fa("fa-lock", { color: C_GREEN, fontSize: "14px" }))
|
|
])
|
|
]),
|
|
h("div", { key: "peer", style: { flex: "none", display: "flex", flexDirection: "column", alignItems: "center", gap: "8px", width: "74px" } }, [
|
|
h("div", { key: "n", style: { width: "50px", height: "50px", borderRadius: "15px", display: "grid", placeItems: "center", background: "rgba(62,207,142,0.1)", border: "1px solid rgba(62,207,142,0.3)", animation: "sbNode 3s ease-in-out infinite", animationDelay: "1.5s" } }, fa("fa-user", { color: C_GREEN, fontSize: "20px" })),
|
|
h("span", { key: "l", style: { fontSize: "11px", fontWeight: 600, color: "#9a9aa2" } }, "Peer")
|
|
])
|
|
])
|
|
])
|
|
),
|
|
h(
|
|
"div",
|
|
{ key: "badges", style: { position: "relative", zIndex: 2, display: "flex", flexWrap: "wrap", gap: "8px" } },
|
|
["ECDH P-384", "AES-256-GCM", "Perfect Forward Secrecy"].map(
|
|
(label) => h("span", { key: label, style: { display: "inline-flex", alignItems: "center", gap: "6px", padding: "6px 11px", borderRadius: "8px", border: "1px solid rgba(255,255,255,0.07)", background: "rgba(255,255,255,0.025)", fontFamily: MONO, fontSize: "11px", fontWeight: 500, color: "#9a9aa2" } }, [
|
|
h("span", { key: "dot", style: { width: "5px", height: "5px", borderRadius: "50%", background: C_GREEN } }),
|
|
label
|
|
])
|
|
)
|
|
)
|
|
]);
|
|
const segToggle = atIntro && h("div", { key: "seg", style: { position: "relative", display: "flex", padding: "4px", borderRadius: "12px", border: "1px solid rgba(255,255,255,0.07)", background: "#141416", marginBottom: "26px" } }, [
|
|
h("div", { key: "ind", style: { position: "absolute", top: "4px", bottom: "4px", left: "4px", width: "calc(50% - 4px)", borderRadius: "9px", background: "rgba(255,255,255,0.07)", border: "1px solid rgba(255,255,255,0.08)", transform: isCreate ? "translateX(0%)" : "translateX(100%)", transition: "transform .26s cubic-bezier(.3,.8,.3,1)" } }),
|
|
h("button", { key: "c", className: "sb-seg-btn", onClick: () => setMode("create"), style: { position: "relative", zIndex: 1, flex: 1, display: "flex", alignItems: "center", justifyContent: "center", gap: "8px", padding: "11px", border: "none", background: "transparent", color: isCreate ? "#f4f4f6" : "#7b7b83", fontFamily: "inherit", fontSize: "14px", fontWeight: 700, cursor: "pointer" } }, [fa("fa-plus", { key: "i" }), "Create"]),
|
|
h("button", { key: "j", className: "sb-seg-btn", onClick: () => setMode("join"), style: { position: "relative", zIndex: 1, flex: 1, display: "flex", alignItems: "center", justifyContent: "center", gap: "8px", padding: "11px", border: "none", background: "transparent", color: !isCreate ? "#f4f4f6" : "#7b7b83", fontFamily: "inherit", fontSize: "14px", fontWeight: 700, cursor: "pointer" } }, [fa("fa-link", { key: "i" }), "Join"])
|
|
]);
|
|
const backButton = (key) => h("button", { key: key || "back", className: "sb-soft-btn", onClick: resetToSelect, style: { display: "inline-flex", alignItems: "center", gap: "6px", marginBottom: "14px", padding: "6px 11px 6px 8px", borderRadius: "8px", border: "1px solid rgba(255,255,255,0.08)", background: "transparent", color: "#9a9aa2", fontFamily: "inherit", fontSize: "12.5px", fontWeight: 600, cursor: "pointer" } }, [fa("fa-chevron-left", { key: "i" }), "Back"]);
|
|
const credBlock = h("div", { key: "codeblock", style: { borderRadius: "13px", border: "1px solid rgba(255,255,255,0.08)", background: "#141416", overflow: "hidden", marginBottom: "16px" } }, [
|
|
h("div", { key: "bar", style: { display: "flex", alignItems: "center", gap: "8px", padding: "9px 12px", borderBottom: "1px solid rgba(255,255,255,0.06)", background: "rgba(0,0,0,0.2)" } }, [
|
|
h("span", { key: "dot", style: { width: "7px", height: "7px", borderRadius: "50%", background: accent } }),
|
|
h("span", { key: "tag", style: { fontFamily: MONO, fontSize: "10.5px", fontWeight: 600, color: "#8a8a92" } }, `${isCreate ? "offer" : "answer"} \xB7 or copy text`),
|
|
h("button", { key: "copy", onClick: copyCred, style: { marginLeft: "auto", padding: "4px 9px", borderRadius: "6px", border: `1px solid ${copied ? "rgba(62,207,142,0.4)" : "rgba(255,255,255,0.1)"}`, background: copied ? "rgba(62,207,142,0.1)" : "rgba(255,255,255,0.04)", color: copied ? C_GREEN : "#b3b3ba", fontFamily: "inherit", fontSize: "11px", fontWeight: 600, cursor: "pointer", transition: "all .14s" } }, copied ? "Copied" : "Copy")
|
|
]),
|
|
// The handshake code is sensitive — keep it blurred until the
|
|
// user deliberately reveals it, underscoring that it must be
|
|
// shared only over a channel they trust.
|
|
h("div", { key: "codewrap", style: { position: "relative" } }, [
|
|
h("div", { key: "code", className: "sb-sc", style: { fontFamily: MONO, fontSize: "11px", lineHeight: 1.55, color: "#c9ccd8", wordBreak: "break-all", padding: "11px 12px", maxHeight: "72px", overflowY: "auto", filter: codeRevealed ? "none" : "blur(6px)", userSelect: codeRevealed ? "text" : "none", transition: "filter .2s" } }, credCode),
|
|
!codeRevealed && h("button", { key: "reveal", onClick: () => setCodeRevealed(true), style: { position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center", gap: "8px", border: "none", background: "rgba(20,20,22,0.25)", color: "#cfcfd4", fontFamily: "inherit", fontSize: "12px", fontWeight: 600, cursor: "pointer" } }, [
|
|
fa("fa-eye", { key: "i", fontSize: "15px" }),
|
|
"Click to reveal \u2014 keep this code private"
|
|
])
|
|
])
|
|
]);
|
|
const showQrButton = qrCodeUrl && h("button", { key: "showqr", onClick: () => setQrModalOpen(true), style: { width: "100%", display: "flex", alignItems: "center", gap: "13px", padding: "15px 16px", borderRadius: "14px", border: `1px solid ${isCreate ? "rgba(240,137,42,0.3)" : "rgba(62,207,142,0.3)"}`, background: isCreate ? "rgba(240,137,42,0.06)" : "rgba(62,207,142,0.06)", color: "inherit", fontFamily: "inherit", cursor: "pointer", textAlign: "left", marginBottom: "14px" } }, [
|
|
h("span", { key: "ic", style: { flex: "none", width: "42px", height: "42px", borderRadius: "12px", display: "grid", placeItems: "center", background: isCreate ? "rgba(240,137,42,0.12)" : "rgba(62,207,142,0.12)", border: `1px solid ${isCreate ? "rgba(240,137,42,0.28)" : "rgba(62,207,142,0.28)"}` } }, fa("fa-qrcode", { color: accent, fontSize: "18px" })),
|
|
h("span", { key: "tx", style: { flex: 1 } }, [
|
|
h("span", { key: "t", style: { display: "block", fontSize: "14.5px", fontWeight: 700, color: "#f4f4f6" } }, "Show QR code"),
|
|
h("span", { key: "s", style: { display: "block", fontSize: "12.5px", color: "#8a8a92", marginTop: "1px" } }, `Full-screen \xB7 let your peer scan${(qrFramesTotal || 0) > 1 ? ` all ${qrFramesTotal} frames` : ""}`)
|
|
]),
|
|
fa("fa-chevron-right", { color: "#6b6b73" })
|
|
]);
|
|
let inner;
|
|
if (showVerification) {
|
|
const verified = bothVerificationsConfirmed;
|
|
const cells = (verificationCode || "").split("").map((ch, i) => h("div", { key: i, style: { flex: 1, maxWidth: "46px", aspectRatio: "0.82", display: "grid", placeItems: "center", borderRadius: "10px", border: "1px solid rgba(62,207,142,0.25)", background: "rgba(62,207,142,0.05)", fontFamily: MONO, fontSize: "22px", fontWeight: 700, color: C_GREEN } }, ch));
|
|
inner = h("div", { key: "verify", style: { animation: "sbUp .3s ease" } }, [
|
|
!verified && backButton("vback"),
|
|
h("div", { key: "head", style: { display: "flex", alignItems: "center", gap: "11px", marginBottom: "8px" } }, [
|
|
h("div", { key: "i", style: { width: "34px", height: "34px", flex: "none", borderRadius: "10px", display: "grid", placeItems: "center", background: "rgba(62,207,142,0.1)", border: "1px solid rgba(62,207,142,0.25)" } }, fa("fa-shield-alt", { color: C_GREEN })),
|
|
h("h2", { key: "t", style: { margin: 0, fontSize: "21px", fontWeight: 800, letterSpacing: "-0.4px", color: "#f4f4f6" } }, "Security verification")
|
|
]),
|
|
h("p", { key: "sub", style: { margin: "0 0 18px", fontSize: "13.5px", lineHeight: 1.55, color: "#8a8a92" } }, "Compare this safety code with your peer over a separate channel (voice / in person), then type it to unlock the chat."),
|
|
h("div", { key: "cells", style: { display: "flex", gap: "6px", justifyContent: "center", marginBottom: "20px", flexWrap: "wrap" } }, cells),
|
|
verified ? h("div", { key: "ok", style: { display: "flex", flexDirection: "column", alignItems: "center", textAlign: "center", padding: "24px 16px", borderRadius: "16px", border: "1px solid rgba(62,207,142,0.25)", background: "rgba(62,207,142,0.06)", animation: "sbUp .3s ease" } }, [
|
|
h("div", { key: "i", style: { width: "54px", height: "54px", borderRadius: "16px", display: "grid", placeItems: "center", background: "rgba(62,207,142,0.14)", border: "1px solid rgba(62,207,142,0.35)", marginBottom: "14px" } }, fa("fa-check", { color: C_GREEN, fontSize: "24px" })),
|
|
h("div", { key: "t", style: { fontSize: "18px", fontWeight: 800, color: "#f4f4f6" } }, "Channel verified"),
|
|
h("div", { key: "s", style: { fontSize: "13.5px", color: "#8a8a92", marginTop: "5px" } }, "Both parties confirmed. Opening the secure chat\u2026")
|
|
]) : h("div", { key: "form" }, [
|
|
h("div", { key: "lbl", style: { fontSize: "12.5px", fontWeight: 600, color: "#9a9aa2", marginBottom: "8px" } }, "Enter the verified code"),
|
|
h("input", { key: "in", value: sasInput, onChange: (e) => {
|
|
setSasInput(e.target.value.toUpperCase());
|
|
if (sasError) setSasError("");
|
|
}, disabled: localVerificationConfirmed, autoFocus: true, autoComplete: "off", spellCheck: false, placeholder: verificationCode ? "Type code here" : "Waiting for code\u2026", style: { width: "100%", textAlign: "center", letterSpacing: "6px", borderRadius: "12px", border: `1px solid ${sasInput.length ? canConfirm || localVerificationConfirmed ? "rgba(62,207,142,0.5)" : "rgba(255,255,255,0.14)" : "rgba(255,255,255,0.08)"}`, background: "#141416", color: "#f4f4f6", fontFamily: MONO, fontSize: "20px", fontWeight: 700, padding: "14px", outline: "none", textTransform: "uppercase", marginBottom: sasError ? "8px" : "16px" } }),
|
|
sasError && h("p", { key: "err", style: { color: "#e5727a", fontSize: "12.5px", margin: "0 0 16px" } }, sasError),
|
|
h("div", { key: "status", style: { display: "flex", flexDirection: "column", gap: "8px", marginBottom: "16px" } }, [
|
|
h("div", { key: "you", style: { display: "flex", alignItems: "center", justifyContent: "space-between", padding: "11px 14px", borderRadius: "11px", border: "1px solid rgba(255,255,255,0.06)", background: "#141416" } }, [
|
|
h("span", { key: "l", style: { fontSize: "13px", color: "#cfcfd4", fontWeight: 600 } }, "Your confirmation"),
|
|
h("span", { key: "v", style: { display: "inline-flex", alignItems: "center", gap: "6px", fontSize: "12.5px", fontWeight: 600, color: localVerificationConfirmed ? C_GREEN : "#7b7b83" } }, [fa(localVerificationConfirmed ? "fa-check-circle" : "fa-clock", { key: "i" }), localVerificationConfirmed ? "Confirmed" : "Pending"])
|
|
]),
|
|
h("div", { key: "peer", style: { display: "flex", alignItems: "center", justifyContent: "space-between", padding: "11px 14px", borderRadius: "11px", border: "1px solid rgba(255,255,255,0.06)", background: "#141416" } }, [
|
|
h("span", { key: "l", style: { fontSize: "13px", color: "#cfcfd4", fontWeight: 600 } }, "Peer confirmation"),
|
|
h("span", { key: "v", style: { display: "inline-flex", alignItems: "center", gap: "6px", fontSize: "12.5px", fontWeight: 600, color: remoteVerificationConfirmed ? C_GREEN : "#7b7b83" } }, [fa(remoteVerificationConfirmed ? "fa-check-circle" : "fa-clock", { key: "i" }), remoteVerificationConfirmed ? "Confirmed" : "Pending"])
|
|
])
|
|
]),
|
|
h("div", { key: "btns", style: { display: "flex", gap: "10px" } }, [
|
|
h("button", { key: "ok", onClick: handleSasConfirm, disabled: !canConfirm, style: { flex: 1, display: "flex", alignItems: "center", justifyContent: "center", gap: "8px", padding: "14px", borderRadius: "13px", border: "none", background: canConfirm ? C_GREEN : "rgba(255,255,255,0.05)", color: canConfirm ? "#08160e" : "#56565e", fontFamily: "inherit", fontSize: "14.5px", fontWeight: 700, cursor: canConfirm ? "pointer" : "not-allowed", boxShadow: canConfirm ? "0 8px 24px rgba(62,207,142,0.25)" : "none" } }, [fa(localVerificationConfirmed ? "fa-check-circle" : "fa-check", { key: "i" }), localVerificationConfirmed ? "Confirmed" : "Confirm code"]),
|
|
h("button", { key: "no", onClick: handleVerificationReject, style: { flex: "none", display: "flex", alignItems: "center", justifyContent: "center", gap: "7px", padding: "14px 16px", borderRadius: "13px", border: "1px solid rgba(229,114,122,0.3)", background: "transparent", color: "#e5727a", fontFamily: "inherit", fontSize: "13.5px", fontWeight: 600, cursor: "pointer" } }, [fa("fa-times", { key: "i" }), "Don't match"])
|
|
])
|
|
])
|
|
]);
|
|
} else if (isGenerating) {
|
|
const genSteps = ["Generating ECDH P-384 key pair", "Deriving verification code", "Pinning Perfect Forward Secrecy"];
|
|
inner = h("div", { key: "gen", style: { animation: "sbUp .28s ease" } }, [
|
|
h("div", { key: "head", style: { display: "flex", alignItems: "center", gap: "13px", marginBottom: "22px" } }, [
|
|
h("div", { key: "sp", style: { width: "44px", height: "44px", flex: "none", display: "grid", placeItems: "center" } }, fa("fa-circle-notch", { color: C_ORANGE, fontSize: "32px", animation: "sbSpin 1s linear infinite" })),
|
|
h("div", { key: "tx" }, [
|
|
h("h2", { key: "t", style: { margin: 0, fontSize: "20px", fontWeight: 800, letterSpacing: "-0.4px", color: "#f4f4f6" } }, isCreate ? "Securing your channel" : "Building your answer"),
|
|
h("p", { key: "s", style: { margin: "3px 0 0", fontSize: "13px", color: "#8a8a92" } }, "Forging keys strong enough to resist tampering.")
|
|
])
|
|
]),
|
|
h(
|
|
"div",
|
|
{ key: "steps", style: { display: "flex", flexDirection: "column", borderRadius: "13px", border: "1px solid rgba(255,255,255,0.07)", background: "#141416", overflow: "hidden" } },
|
|
genSteps.map((label, i) => {
|
|
const done = genProgress > i;
|
|
const active = genProgress === i;
|
|
return h("div", { key: i, style: { display: "flex", alignItems: "center", gap: "12px", padding: "13px 15px", borderTop: i ? "1px solid rgba(255,255,255,0.05)" : "none", transition: "background .3s", background: done ? "rgba(62,207,142,0.04)" : "transparent" } }, [
|
|
h(
|
|
"div",
|
|
{ key: "d", style: { flex: "none", width: "20px", height: "20px", borderRadius: "50%", display: "grid", placeItems: "center", background: done ? "rgba(62,207,142,0.12)" : active ? "rgba(240,137,42,0.12)" : "rgba(255,255,255,0.04)", border: `1px solid ${done ? "rgba(62,207,142,0.3)" : active ? "rgba(240,137,42,0.3)" : "rgba(255,255,255,0.1)"}`, transition: "all .3s" } },
|
|
done ? fa("fa-check", { color: C_GREEN, fontSize: "11px" }) : h("span", { style: { width: "6px", height: "6px", borderRadius: "50%", background: active ? C_ORANGE : "#56565e", animation: active ? "sbBlink 1s ease-in-out infinite" : "none" } })
|
|
),
|
|
h("span", { key: "l", style: { fontSize: "13.5px", color: done ? "#cfcfd4" : active ? "#e8e8eb" : "#6b6b73", transition: "color .3s" } }, label)
|
|
]);
|
|
})
|
|
)
|
|
]);
|
|
} else if (isOfferCred || isAnswerCred) {
|
|
inner = h("div", { key: "cred", style: { animation: "sbUp .3s ease" } }, [
|
|
backButton("cback"),
|
|
h("h2", { key: "h", style: { margin: "0 0 6px", fontSize: "23px", fontWeight: 800, letterSpacing: "-0.5px", color: "#f4f4f6" } }, isCreate ? "Share your invitation" : "Send back your answer"),
|
|
h("p", { key: "p", style: { margin: "0 0 18px", fontSize: "14px", lineHeight: 1.55, color: "#8a8a92" } }, isCreate ? "Show the QR or send the code to your peer. It is one-time and expires shortly." : "Give this answer to the channel creator so they can finish the handshake."),
|
|
showQrButton,
|
|
credBlock,
|
|
isOfferCred && h("div", { key: "offerextra", style: { marginTop: "4px" } }, [
|
|
h("div", { key: "lbl", style: { fontSize: "12.5px", fontWeight: 600, color: "#9a9aa2", marginBottom: "8px" } }, "Then receive the answer your peer sends back"),
|
|
h(
|
|
"div",
|
|
{ key: "ta", style: { borderRadius: "12px", border: `1px solid ${hasAnswer ? "rgba(255,255,255,0.18)" : "rgba(255,255,255,0.07)"}`, background: "#141416", padding: "11px 14px", marginBottom: "10px" } },
|
|
h("textarea", { value: answerInput, onChange: (e) => {
|
|
setAnswerInput(e.target.value);
|
|
if (e.target.value.trim().length > 0 && typeof markAnswerCreated === "function") markAnswerCreated();
|
|
}, rows: 2, placeholder: "Paste peer's answer code\u2026", style: { width: "100%", resize: "none", border: "none", outline: "none", background: "transparent", color: "#d7d7db", fontFamily: MONO, fontSize: "12px", lineHeight: 1.55, minHeight: "44px" } })
|
|
),
|
|
h("div", { key: "btns", style: { display: "flex", gap: "10px" } }, [
|
|
h("button", { key: "scan", className: "sb-scan-btn", onClick: () => setShowQRScannerModal(true), style: { flex: "none", display: "inline-flex", alignItems: "center", justifyContent: "center", gap: "8px", padding: "14px 16px", borderRadius: "13px", border: "1px solid rgba(255,255,255,0.1)", background: "rgba(255,255,255,0.04)", color: "#cfcfd4", fontFamily: "inherit", fontSize: "14px", fontWeight: 700, cursor: "pointer" } }, [fa("fa-camera", { key: "i" }), "Scan"]),
|
|
h("button", { key: "est", onClick: onConnect, disabled: !hasAnswer, style: { flex: 1, display: "flex", alignItems: "center", justifyContent: "center", gap: "9px", padding: "14px", borderRadius: "13px", border: "none", background: hasAnswer ? C_ORANGE : "rgba(255,255,255,0.05)", color: hasAnswer ? "#1a0f04" : "#56565e", fontFamily: "inherit", fontSize: "14.5px", fontWeight: 700, cursor: hasAnswer ? "pointer" : "not-allowed", boxShadow: hasAnswer ? "0 8px 24px rgba(240,137,42,0.28)" : "none" } }, "Establish connection")
|
|
])
|
|
]),
|
|
isAnswerCred && h("div", { key: "answerextra", style: { marginTop: "4px", display: "flex", alignItems: "center", gap: "10px", padding: "12px 14px", borderRadius: "12px", border: "1px solid rgba(62,207,142,0.18)", background: "rgba(62,207,142,0.05)" } }, [
|
|
fa("fa-circle-notch", { key: "i", color: C_GREEN, animation: "sbSpin 1.4s linear infinite" }),
|
|
h("span", { key: "t", style: { fontSize: "13px", color: "#cfcfd4", fontWeight: 500 } }, "Send this answer to the creator, then wait \u2014 the chat opens once they connect.")
|
|
])
|
|
]);
|
|
} else if (isCreate) {
|
|
inner = h("div", { key: "introC", style: { animation: "sbUp .28s ease" } }, [
|
|
h("h2", { key: "h", style: { margin: "0 0 6px", fontSize: "23px", fontWeight: 800, letterSpacing: "-0.5px", color: "#f4f4f6" } }, "Create a new channel"),
|
|
h("p", { key: "p", style: { margin: "0 0 22px", fontSize: "14px", lineHeight: 1.55, color: "#8a8a92" } }, "Your device generates the keys and a one-time invitation. Nothing touches a server."),
|
|
h("button", { key: "gen", className: "sb-gen-btn", onClick: () => {
|
|
requestNotificationPermissionOnInteraction();
|
|
if (webrtcManagerRef.current) handleCreateOffer();
|
|
}, style: { width: "100%", display: "flex", alignItems: "center", justifyContent: "center", gap: "9px", padding: "15px", borderRadius: "13px", border: "none", background: C_ORANGE, color: "#1a0f04", fontFamily: "inherit", fontSize: "15px", fontWeight: 700, cursor: "pointer", boxShadow: "0 8px 24px rgba(240,137,42,0.28)" } }, [fa("fa-bolt", { key: "i" }), "Generate keys & invitation"])
|
|
]);
|
|
} else {
|
|
inner = h("div", { key: "introJ", style: { animation: "sbUp .28s ease" } }, [
|
|
h("h2", { key: "h", style: { margin: "0 0 6px", fontSize: "23px", fontWeight: 800, letterSpacing: "-0.5px", color: "#f4f4f6" } }, "Join a channel"),
|
|
h("p", { key: "p", style: { margin: "0 0 16px", fontSize: "14px", lineHeight: 1.55, color: "#8a8a92" } }, "Scan your peer's QR with your camera, or paste their invitation code."),
|
|
h("button", { key: "scan", className: "sb-scan-btn", onClick: () => {
|
|
requestNotificationPermissionOnInteraction();
|
|
setShowQRScannerModal(true);
|
|
}, style: { width: "100%", display: "flex", alignItems: "center", gap: "13px", padding: "15px 16px", borderRadius: "14px", border: "1px solid rgba(62,207,142,0.3)", background: "rgba(62,207,142,0.06)", color: "inherit", fontFamily: "inherit", cursor: "pointer", textAlign: "left", marginBottom: "14px" } }, [
|
|
h("span", { key: "ic", style: { flex: "none", width: "42px", height: "42px", borderRadius: "12px", display: "grid", placeItems: "center", background: "rgba(62,207,142,0.12)", border: "1px solid rgba(62,207,142,0.28)" } }, fa("fa-camera", { color: C_GREEN, fontSize: "18px" })),
|
|
h("span", { key: "tx", style: { flex: 1 } }, [
|
|
h("span", { key: "t", style: { display: "block", fontSize: "14.5px", fontWeight: 700, color: "#f4f4f6" } }, "Scan QR with camera"),
|
|
h("span", { key: "s", style: { display: "block", fontSize: "12.5px", color: "#8a8a92", marginTop: "1px" } }, "Fastest \u2014 point at your peer's screen")
|
|
]),
|
|
fa("fa-chevron-right", { color: "#6b6b73" })
|
|
]),
|
|
h("div", { key: "or", style: { display: "flex", alignItems: "center", gap: "12px", marginBottom: "14px" } }, [
|
|
h("span", { key: "a", style: { flex: 1, height: "1px", background: "rgba(255,255,255,0.07)" } }),
|
|
h("span", { key: "m", style: { fontSize: "11px", fontWeight: 600, color: "#56565e", textTransform: "uppercase", letterSpacing: "0.7px" } }, "or paste code"),
|
|
h("span", { key: "b", style: { flex: 1, height: "1px", background: "rgba(255,255,255,0.07)" } })
|
|
]),
|
|
h(
|
|
"div",
|
|
{ key: "ta", style: { borderRadius: "13px", border: `1px solid ${hasInvite ? "rgba(255,255,255,0.18)" : "rgba(255,255,255,0.07)"}`, background: "#141416", padding: "13px 15px", marginBottom: "12px" } },
|
|
h("textarea", { value: offerInput, onChange: (e) => {
|
|
setOfferInput(e.target.value);
|
|
if (e.target.value.trim().length > 0 && typeof markAnswerCreated === "function") markAnswerCreated();
|
|
}, rows: 3, placeholder: "Paste invitation code here\u2026", style: { width: "100%", resize: "none", border: "none", outline: "none", background: "transparent", color: "#d7d7db", fontFamily: MONO, fontSize: "12.5px", lineHeight: 1.6, minHeight: "66px" } })
|
|
),
|
|
h("button", { key: "connect", onClick: () => {
|
|
requestNotificationPermissionOnInteraction();
|
|
onCreateAnswer();
|
|
}, disabled: !hasInvite || connectionStatus === "connecting", style: { width: "100%", display: "inline-flex", alignItems: "center", justifyContent: "center", gap: "9px", padding: "14px", borderRadius: "13px", border: "none", background: hasInvite && connectionStatus !== "connecting" ? C_ORANGE : "rgba(255,255,255,0.05)", color: hasInvite && connectionStatus !== "connecting" ? "#1a0f04" : "#56565e", fontFamily: "inherit", fontSize: "15px", fontWeight: 700, cursor: hasInvite && connectionStatus !== "connecting" ? "pointer" : "not-allowed", boxShadow: hasInvite && connectionStatus !== "connecting" ? "0 8px 24px rgba(240,137,42,0.28)" : "none" } }, connectionStatus === "connecting" ? "Processing\u2026" : "Connect")
|
|
]);
|
|
}
|
|
const DOWNLOADS = {
|
|
mac: { name: "macOS", format: ".dmg \xB7 Apple Silicon & Intel", icon: "fab fa-apple", url: "https://github.com/SecureBitChat/securebit-desktop/releases/download/v0.1.0/SecureBit.Chat_0.1.0_x64.dmg" },
|
|
win: { name: "Windows", format: ".exe \xB7 64-bit installer", icon: "fab fa-windows", url: "https://github.com/SecureBitChat/securebit-desktop/releases/latest/download/SecureBit.Chat_0.1.0_x64-setup.exe" },
|
|
linux: { name: "Linux", format: ".AppImage", icon: "fab fa-linux", url: "https://github.com/SecureBitChat/securebit-desktop/releases/latest/download/SecureBit.Chat_0.1.0_amd64.AppImage" }
|
|
};
|
|
const detectOS = () => {
|
|
const ua = (navigator.userAgent || "") + " " + (navigator.platform || "");
|
|
if (/Mac|iPhone|iPad|iPod/i.test(ua) && !/Android/i.test(ua)) return "mac";
|
|
if (/Win/i.test(ua)) return "win";
|
|
if (/Linux/i.test(ua) && !/Android/i.test(ua)) return "linux";
|
|
return "win";
|
|
};
|
|
const detectedOS = detectOS();
|
|
const otherOS = ["mac", "win", "linux"].filter((k) => k !== detectedOS);
|
|
const dlLink = (url) => {
|
|
try {
|
|
window.open(url, "_blank", "noopener");
|
|
} catch (e) {
|
|
}
|
|
};
|
|
const platformsMenu = platformsOpen && h("div", { key: "platmenu", className: "sb-platforms-menu", style: { position: "absolute", left: 0, bottom: "calc(100% + 10px)", width: "344px", maxWidth: "100%", borderRadius: "16px", border: "1px solid rgba(255,255,255,0.1)", background: "#161618", boxShadow: "0 24px 60px rgba(0,0,0,0.55)", overflow: "hidden", zIndex: 25, animation: "sbUp .2s ease" } }, [
|
|
h("div", { key: "mh", style: { display: "flex", alignItems: "center", gap: "10px", padding: "14px 16px", borderBottom: "1px solid rgba(255,255,255,0.06)" } }, [
|
|
h("div", { key: "t", style: { flex: 1, lineHeight: 1.2 } }, [
|
|
h("div", { key: "a", style: { fontSize: "14px", fontWeight: 800, color: "#f4f4f6" } }, "Download SecureBit"),
|
|
h("div", { key: "b", style: { fontSize: "11.5px", color: "#7b7b83" } }, "Free \xB7 open source")
|
|
]),
|
|
h("span", { key: "pill", style: { fontFamily: MONO, fontSize: "10px", fontWeight: 600, color: C_GREEN, padding: "3px 8px", borderRadius: "6px", background: "rgba(62,207,142,0.1)", border: "1px solid rgba(62,207,142,0.22)" } }, "You're on Web")
|
|
]),
|
|
h(
|
|
"div",
|
|
{ key: "rec", style: { padding: "12px 12px 6px" } },
|
|
h("button", { key: "b", onClick: () => dlLink(DOWNLOADS[detectedOS].url), style: { width: "100%", display: "flex", alignItems: "center", gap: "12px", padding: "13px 14px", borderRadius: "12px", border: "1px solid rgba(240,137,42,0.4)", background: "rgba(240,137,42,0.08)", color: "inherit", fontFamily: "inherit", cursor: "pointer", textAlign: "left" } }, [
|
|
h("span", { key: "ic", style: { flex: "none", display: "grid", placeItems: "center", width: "38px", height: "38px", borderRadius: "11px", background: "rgba(240,137,42,0.14)", border: "1px solid rgba(240,137,42,0.3)", color: C_ORANGE } }, h("i", { className: DOWNLOADS[detectedOS].icon, style: { fontSize: "17px" } })),
|
|
h("span", { key: "tx", style: { flex: 1, minWidth: 0 } }, [
|
|
h("span", { key: "n", style: { display: "block", fontSize: "13.5px", fontWeight: 700, color: "#f4f4f6" } }, DOWNLOADS[detectedOS].name),
|
|
h("span", { key: "f", style: { display: "block", fontSize: "11px", color: "#f0b072", marginTop: "1px" } }, `Recommended for this device \xB7 ${DOWNLOADS[detectedOS].format}`)
|
|
]),
|
|
fa("fa-download", { color: C_ORANGE })
|
|
])
|
|
),
|
|
h(
|
|
"div",
|
|
{ key: "others", style: { padding: "0 12px 8px", display: "flex", flexDirection: "column", gap: "2px" } },
|
|
otherOS.map((k) => h("button", { key: k, onClick: () => dlLink(DOWNLOADS[k].url), style: { width: "100%", display: "flex", alignItems: "center", gap: "12px", padding: "11px 14px", borderRadius: "11px", border: "none", background: "transparent", color: "inherit", fontFamily: "inherit", cursor: "pointer", textAlign: "left" } }, [
|
|
h("span", { key: "ic", style: { flex: "none", display: "grid", placeItems: "center", width: "34px", height: "34px", borderRadius: "10px", background: "rgba(255,255,255,0.04)", border: "1px solid rgba(255,255,255,0.08)", color: "#cfcfd4" } }, h("i", { className: DOWNLOADS[k].icon, style: { fontSize: "15px" } })),
|
|
h("span", { key: "tx", style: { flex: 1, minWidth: 0 } }, [
|
|
h("span", { key: "n", style: { display: "block", fontSize: "13px", fontWeight: 600, color: "#e8e8eb" } }, DOWNLOADS[k].name),
|
|
h("span", { key: "f", style: { display: "block", fontSize: "11px", color: "#7b7b83", marginTop: "1px" } }, DOWNLOADS[k].format)
|
|
]),
|
|
fa("fa-download", { color: "#8a8a92" })
|
|
]))
|
|
),
|
|
h("div", { key: "soon", style: { display: "flex", alignItems: "center", gap: "9px", padding: "12px 16px", borderTop: "1px solid rgba(255,255,255,0.06)", background: "rgba(255,255,255,0.015)" } }, [
|
|
fa("fa-clock", { key: "i", color: "#6b6b73" }),
|
|
h("span", { key: "t", style: { fontSize: "11.5px", lineHeight: 1.45, color: "#7b7b83" } }, "Mobile (iOS, Android) and browser extensions (Chrome, Firefox, Opera) are coming soon.")
|
|
])
|
|
]);
|
|
const footer = h("div", { key: "footer", className: "sb-conn-footer", style: { position: "relative", marginTop: "30px", paddingTop: "18px", borderTop: "1px solid rgba(255,255,255,0.06)", display: "flex", alignItems: "center", justifyContent: "space-between", gap: "12px", flexWrap: "wrap" } }, [
|
|
h("button", { key: "dl", onClick: () => setPlatformsOpen((v) => !v), style: { display: "inline-flex", alignItems: "center", gap: "9px", padding: "8px 13px 8px 9px", borderRadius: "10px", border: `1px solid ${platformsOpen ? "rgba(240,137,42,0.4)" : "rgba(255,255,255,0.08)"}`, background: platformsOpen ? "rgba(240,137,42,0.06)" : "rgba(255,255,255,0.02)", color: "inherit", fontFamily: "inherit", cursor: "pointer", transition: "all .15s" } }, [
|
|
fa("fa-download", { key: "i", color: C_ORANGE }),
|
|
h("span", { key: "t", style: { fontSize: "12.5px", fontWeight: 700, color: "#e8e8eb" } }, "Download desktop app"),
|
|
fa("fa-chevron-down", { key: "c", color: "#6b6b73", style: { fontSize: "11px", transform: platformsOpen ? "rotate(180deg)" : "rotate(0deg)", transition: "transform .2s" } })
|
|
]),
|
|
h("button", { key: "settings", className: "sb-link", onClick: () => setShowIceSettings && setShowIceSettings(true), style: { display: "inline-flex", alignItems: "center", gap: "7px", background: "none", border: "none", color: "#8a8a92", fontFamily: "inherit", fontSize: "12.5px", fontWeight: 600, cursor: "pointer" } }, [fa("fa-sliders-h", { key: "i" }), "Advanced settings"]),
|
|
platformsMenu
|
|
]);
|
|
const settingsOverlay = showIceSettings && typeof window !== "undefined" && window.IceServerSettings ? h(window.IceServerSettings, {
|
|
key: "ice-settings",
|
|
isOpen: true,
|
|
embedded: true,
|
|
onClose: () => setShowIceSettings(false),
|
|
initial: {
|
|
useCustom: Array.isArray(customIceServers) && customIceServers.length > 0,
|
|
serversText: iceServersText,
|
|
privacyMode: relayOnlyMode ? "relay-only" : "standard",
|
|
persisted: iceSettingsPersisted
|
|
},
|
|
hasSaved: iceSettingsPersisted,
|
|
onApply: handleApplyIceSettings,
|
|
onForget: handleForgetIceSettings
|
|
}) : null;
|
|
const rightPanel = h("div", { key: "right", style: compact ? { flex: 1, minWidth: 0, width: "100%", position: "relative", overflow: "hidden", display: "flex", flexDirection: "column", height: "100%" } : { flex: "0.95 1 460px", minWidth: "min(100%, 320px)", position: "relative", overflow: "hidden", display: "flex", flexDirection: "column", height: "100vh" } }, [
|
|
h(
|
|
"div",
|
|
{ key: "scroll", className: "custom-scrollbar", style: { flex: 1, overflowY: "auto", display: "flex", flexDirection: "column", padding: "42px 44px" } },
|
|
h("div", { style: { maxWidth: "430px", width: "100%", margin: "auto" } }, [
|
|
h("div", { key: "kicker", style: { fontFamily: MONO, fontSize: "11px", fontWeight: 600, color: "#6b6b73", textTransform: "uppercase", letterSpacing: "1px", marginBottom: "10px" } }, kicker),
|
|
segToggle,
|
|
inner,
|
|
footer
|
|
])
|
|
),
|
|
settingsOverlay
|
|
]);
|
|
const qrModal = qrModalOpen && qrCodeUrl && h(
|
|
"div",
|
|
{ key: "qrmodal", onClick: () => setQrModalOpen(false), style: { position: "fixed", inset: 0, zIndex: 50, display: "flex", alignItems: "center", justifyContent: "center", padding: "32px", background: "rgba(6,6,8,0.82)", backdropFilter: "blur(10px)", animation: "sbUp .2s ease" } },
|
|
h("div", { onClick: (e) => e.stopPropagation(), style: { width: "100%", maxWidth: "460px", borderRadius: "22px", border: "1px solid rgba(255,255,255,0.1)", background: "#111113", boxShadow: "0 30px 90px rgba(0,0,0,0.6)", overflow: "hidden" } }, [
|
|
h("div", { key: "head", style: { display: "flex", alignItems: "center", gap: "11px", padding: "18px 20px", borderBottom: "1px solid rgba(255,255,255,0.06)" } }, [
|
|
h("span", { key: "d", style: { width: "9px", height: "9px", borderRadius: "50%", background: accent } }),
|
|
h("div", { key: "tx", style: { flex: 1, lineHeight: 1.2 } }, [
|
|
h("div", { key: "t", style: { fontSize: "15.5px", fontWeight: 800, color: "#f4f4f6" } }, isCreate ? "Share your invitation" : "Send back your answer"),
|
|
h("div", { key: "s", style: { fontSize: "12px", color: "#7b7b83" } }, `${isCreate ? "offer" : "answer"} \xB7 one-time`)
|
|
]),
|
|
h("button", { key: "x", onClick: () => setQrModalOpen(false), style: { width: "32px", height: "32px", display: "grid", placeItems: "center", borderRadius: "9px", border: "none", background: "rgba(255,255,255,0.05)", color: "#9a9aa2", cursor: "pointer" } }, fa("fa-times"))
|
|
]),
|
|
h("div", { key: "body", style: { padding: "22px 24px 24px" } }, [
|
|
h(
|
|
"div",
|
|
{ key: "qr", style: { position: "relative", width: "100%", aspectRatio: "1", borderRadius: "18px", overflow: "hidden", background: "#fff", padding: "18px", display: "grid", placeItems: "center" } },
|
|
h("img", { src: qrCodeUrl, alt: "QR code", style: { width: "100%", height: "100%", objectFit: "contain", display: "block" } })
|
|
),
|
|
h("div", { key: "ctrls", style: { display: "flex", flexDirection: "column", alignItems: "center", gap: "12px", marginTop: "18px" } }, [
|
|
(qrFramesTotal || 0) >= 1 && h("div", { key: "frame", style: { display: "flex", alignItems: "center", gap: "9px" } }, [
|
|
h("span", { key: "l", style: { fontFamily: MONO, fontSize: "12px", fontWeight: 600, color: "#9a9aa2" } }, `Frame ${Math.max(1, qrFrameIndex || 1)} / ${qrFramesTotal || 1}`),
|
|
h("div", { key: "dots", style: { display: "flex", gap: "5px" } }, Array.from({ length: qrFramesTotal || 1 }, (_, i) => h("span", { key: i, style: { width: "7px", height: "7px", borderRadius: "50%", background: i + 1 === (qrFrameIndex || 1) ? accent : "rgba(255,255,255,0.14)", transition: "background .25s" } })))
|
|
]),
|
|
(qrFramesTotal || 0) > 1 && h("div", { key: "nav", style: { display: "flex", alignItems: "center", gap: "6px" } }, [
|
|
h("button", { key: "prev", onClick: prevQrFrame, style: { width: "40px", height: "36px", display: "grid", placeItems: "center", borderRadius: "10px", border: "1px solid rgba(255,255,255,0.1)", background: "rgba(255,255,255,0.04)", color: "#cfcfd4", cursor: "pointer" } }, fa("fa-chevron-left")),
|
|
h("button", { key: "auto", onClick: toggleQrManualMode, style: { display: "inline-flex", alignItems: "center", gap: "7px", padding: "9px 18px", borderRadius: "10px", border: `1px solid ${qrManualMode ? "rgba(255,255,255,0.1)" : "rgba(240,137,42,0.45)"}`, background: qrManualMode ? "rgba(255,255,255,0.04)" : "rgba(240,137,42,0.08)", color: qrManualMode ? "#9a9aa2" : C_ORANGE, fontFamily: "inherit", fontSize: "13px", fontWeight: 600, cursor: "pointer" } }, qrManualMode ? "Manual" : "Auto"),
|
|
h("button", { key: "next", onClick: nextQrFrame, style: { width: "40px", height: "36px", display: "grid", placeItems: "center", borderRadius: "10px", border: "1px solid rgba(255,255,255,0.1)", background: "rgba(255,255,255,0.04)", color: "#cfcfd4", cursor: "pointer" } }, fa("fa-chevron-right"))
|
|
]),
|
|
h("p", { key: "hint", style: { margin: "2px 0 0", textAlign: "center", fontSize: "12px", lineHeight: 1.5, color: "#6b6b73" } }, (qrFramesTotal || 0) > 1 ? `The handshake is split across ${qrFramesTotal} frames \u2014 keep this open until your peer captures all of them.` : "Keep this open until your peer captures the code.")
|
|
])
|
|
])
|
|
])
|
|
);
|
|
const hero = h("div", { key: "hero", style: { display: "flex", flexWrap: "wrap", minHeight: "100vh", width: "100%", background: "#0f0f11", color: "#e8e8eb" } }, [leftPanel, rightPanel]);
|
|
const uniqueSection = atIntro && h(UniqueFeatureSlider, { key: "unique-features-slider" });
|
|
const partnersSection = atIntro && h(BecomePartner, { key: "become-partner" });
|
|
const roadmapSection = atIntro && h(Roadmap, { key: "roadmap" });
|
|
const communitySection = atIntro && h(CommunityCTA, { key: "community-cta" });
|
|
const keyframeStyle = h("style", { key: "kf", dangerouslySetInnerHTML: {
|
|
__html: "@keyframes sbFlowR{0%{left:4%;opacity:0}12%{opacity:1}88%{opacity:1}100%{left:96%;opacity:0}}@keyframes sbFlowL{0%{left:96%;opacity:0}12%{opacity:1}88%{opacity:1}100%{left:4%;opacity:0}}@keyframes sbPulse{0%,100%{transform:translate(-50%,-50%) scale(1);opacity:.5}50%{transform:translate(-50%,-50%) scale(1.5);opacity:0}}@keyframes sbSpin{to{transform:rotate(360deg)}}@keyframes sbUp{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}@keyframes sbNode{0%,100%{box-shadow:0 0 0 0 rgba(62,207,142,0)}50%{box-shadow:0 0 0 6px rgba(62,207,142,.06)}}@keyframes sbScan{0%{top:8%}100%{top:88%}}@keyframes sbBlink{0%,100%{opacity:1}50%{opacity:.35}}"
|
|
} });
|
|
if (compact) {
|
|
return h("div", { className: "sb-start", style: { flex: 1, minHeight: 0, width: "100%", display: "flex", flexDirection: "column", background: "#0f0f11", color: "#e8e8eb" } }, [keyframeStyle, rightPanel, qrModal]);
|
|
}
|
|
return h("div", { className: "sb-start", style: { width: "100%" } }, [keyframeStyle, hero, uniqueSection, partnersSection, roadmapSection, communitySection, qrModal]);
|
|
};
|
|
var createScrollToBottomFunction = (chatMessagesRef) => {
|
|
return () => {
|
|
if (chatMessagesRef && chatMessagesRef.current) {
|
|
const scrollAttempt = () => {
|
|
if (chatMessagesRef.current) {
|
|
chatMessagesRef.current.scrollTo({
|
|
top: chatMessagesRef.current.scrollHeight,
|
|
behavior: "smooth"
|
|
});
|
|
}
|
|
};
|
|
scrollAttempt();
|
|
setTimeout(scrollAttempt, 50);
|
|
setTimeout(scrollAttempt, 150);
|
|
setTimeout(scrollAttempt, 300);
|
|
requestAnimationFrame(() => {
|
|
setTimeout(scrollAttempt, 100);
|
|
});
|
|
}
|
|
};
|
|
};
|
|
var runSecurityReport = async (webrtcManager) => {
|
|
let securityData = null;
|
|
try {
|
|
if (webrtcManager && window.EnhancedSecureCryptoUtils) {
|
|
securityData = await window.EnhancedSecureCryptoUtils.calculateSecurityLevel(webrtcManager);
|
|
}
|
|
} catch (e) {
|
|
}
|
|
if (!securityData) {
|
|
alert("Security verification in progress\u2026\nPlease wait for real-time cryptographic verification to complete.");
|
|
return;
|
|
}
|
|
const MONO = "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace";
|
|
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
|
const accent = securityData.color === "orange" ? "#f0892a" : securityData.color === "yellow" ? "#e3c84e" : securityData.color === "red" ? "#e5727a" : "#3ecf8e";
|
|
const accentRGB = securityData.color === "orange" ? "240,137,42" : securityData.color === "yellow" ? "227,200,78" : securityData.color === "red" ? "229,114,122" : "62,207,142";
|
|
const score = Math.max(0, Math.min(100, Math.round(securityData.score || 0)));
|
|
const circ = 2 * Math.PI * 56;
|
|
const dashArray = `${(circ * Math.min(1, score / 100)).toFixed(1)} ${circ.toFixed(1)}`;
|
|
const level = String(securityData.level || "SECURE").toUpperCase();
|
|
const isReal = securityData.isRealData !== false;
|
|
const entries = securityData.verificationResults ? Object.entries(securityData.verificationResults) : [];
|
|
const passedCount = Number.isFinite(securityData.passedChecks) ? securityData.passedChecks : entries.filter(([, r]) => r && r.passed).length;
|
|
const totalCount = Number.isFinite(securityData.totalChecks) ? securityData.totalChecks : entries.length;
|
|
const verifiedAt = new Date(securityData.timestamp || Date.now()).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false });
|
|
const pretty = (k) => {
|
|
let s = String(k).replace(/^verify/i, "").replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2").trim();
|
|
s = s.replace(/\b(ecdh|ecdsa|aes|gcm|hmac|pfs|sas|mitm|asn|dtls|hkdf|spki|oid|p384)\b/gi, (m) => m.toUpperCase());
|
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
};
|
|
const checkIcon = `<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#3ecf8e" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M5 13l4 4 10-11"/></svg>`;
|
|
const xIcon = `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#e5727a" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6L6 18M6 6l12 12"/></svg>`;
|
|
const testsHTML = entries.map(([k, r], i) => {
|
|
const passed = !!(r && r.passed);
|
|
const desc = r && r.details || (passed ? "Test passed" : "Test failed or unavailable");
|
|
const bg = passed ? "#161618" : "#121214";
|
|
const border = passed ? "rgba(62,207,142,0.16)" : "rgba(229,114,122,0.18)";
|
|
const iconBg = passed ? "rgba(62,207,142,0.12)" : "rgba(229,114,122,0.1)";
|
|
const iconBorder = passed ? "rgba(62,207,142,0.26)" : "rgba(229,114,122,0.24)";
|
|
const titleColor = passed ? "#f4f4f6" : "#cfcfd4";
|
|
return `<div style="display:flex; align-items:flex-start; gap:13px; padding:16px 18px; border-radius:14px; background:${bg}; border:1px solid ${border}; animation:svIn .3s cubic-bezier(.2,.7,.3,1) both; animation-delay:${(i * 0.04).toFixed(2)}s;">
|
|
<span style="flex:none; width:34px; height:34px; border-radius:9px; display:grid; place-items:center; background:${iconBg}; border:1px solid ${iconBorder};">${passed ? checkIcon : xIcon}</span>
|
|
<div style="flex:1; min-width:0;">
|
|
<div style="font-size:14.5px; font-weight:700; letter-spacing:-0.2px; color:${titleColor}; margin-bottom:3px;">${esc(pretty(k))}</div>
|
|
<div style="font-family:${MONO}; font-size:11.5px; line-height:1.45; color:#8a8a92;">${esc(desc)}</div>
|
|
</div>
|
|
</div>`;
|
|
}).join("");
|
|
const modal = document.createElement("div");
|
|
modal.id = "sb-security-report";
|
|
modal.style.cssText = "position:fixed; inset:0; z-index:10000; display:flex; align-items:center; justify-content:center; padding:24px; background:rgba(8,8,10,0.62); backdrop-filter:blur(4px); -webkit-backdrop-filter:blur(4px); font-family:'Manrope',system-ui,-apple-system,sans-serif; overflow:auto;";
|
|
modal.innerHTML = `
|
|
<style>@keyframes svIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}@keyframes svPulse{0%,100%{opacity:1}50%{opacity:.4}}</style>
|
|
<div style="position:relative; max-width:960px; width:100%; border-radius:20px; background:radial-gradient(1100px 640px at 50% -6%, rgba(${accentRGB},0.05), transparent 62%), #0f0f11; border:1px solid rgba(255,255,255,0.08); color:#e8e8eb; padding:30px; box-shadow:0 30px 70px rgba(0,0,0,0.55); animation:svIn .3s cubic-bezier(.2,.7,.3,1); max-height:calc(100vh - 48px); overflow:auto;">
|
|
<button class="sv-close" type="button" title="Close" style="position:absolute; top:16px; right:16px; width:32px; height:32px; border-radius:9px; display:grid; place-items:center; border:1px solid rgba(255,255,255,0.08); background:rgba(255,255,255,0.02); color:#8a8a92; cursor:pointer; z-index:2;">
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" style="pointer-events:none;"><path d="M6 6l12 12M18 6L6 18"/></svg>
|
|
</button>
|
|
<div style="position:relative; overflow:hidden; border-radius:18px; background:#141416; border:1px solid rgba(255,255,255,0.07); padding:28px 30px; display:flex; align-items:center; gap:30px; flex-wrap:wrap; margin-bottom:20px;">
|
|
<div style="position:absolute; top:0; left:0; right:0; height:1px; background:linear-gradient(90deg, transparent, rgba(${accentRGB},0.6), transparent);"></div>
|
|
<div style="position:relative; flex:none; width:128px; height:128px;">
|
|
<svg width="128" height="128" viewBox="0 0 128 128" style="transform:rotate(-90deg);">
|
|
<circle cx="64" cy="64" r="56" fill="none" stroke="rgba(255,255,255,0.06)" stroke-width="9"/>
|
|
<circle cx="64" cy="64" r="56" fill="none" stroke="${accent}" stroke-width="9" stroke-linecap="round" stroke-dasharray="${dashArray}"/>
|
|
</svg>
|
|
<div style="position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center;">
|
|
<span style="font-size:30px; font-weight:800; letter-spacing:-1px; color:#f4f4f6; line-height:1;">${score}</span>
|
|
<span style="font-family:${MONO}; font-size:10px; font-weight:600; color:#6b6b73; text-transform:uppercase; letter-spacing:1px; margin-top:4px;">/ 100 pts</span>
|
|
</div>
|
|
</div>
|
|
<div style="flex:1; min-width:240px;">
|
|
<div style="font-family:${MONO}; font-size:11px; font-weight:600; color:#6b6b73; text-transform:uppercase; letter-spacing:1.6px; margin-bottom:10px;">Real-time security verification</div>
|
|
<div style="display:flex; align-items:center; gap:12px; margin-bottom:14px; flex-wrap:wrap;">
|
|
<h2 style="margin:0; font-size:26px; font-weight:800; letter-spacing:-0.7px; color:#f4f4f6;">Security level: ${esc(level)}</h2>
|
|
<span style="display:inline-flex; align-items:center; gap:8px; padding:6px 12px; border-radius:9px; background:rgba(${accentRGB},0.12); border:1px solid rgba(${accentRGB},0.3); font-family:${MONO}; font-size:11px; font-weight:700; color:${accent}; text-transform:uppercase; letter-spacing:0.6px;"><span style="width:7px; height:7px; border-radius:50%; background:${accent}; animation:svPulse 2s ease-in-out infinite;"></span>Active</span>
|
|
</div>
|
|
<div style="display:flex; gap:28px; flex-wrap:wrap;">
|
|
<div><div style="font-family:${MONO}; font-size:10px; font-weight:600; color:#56565e; text-transform:uppercase; letter-spacing:1px; margin-bottom:4px;">Tests passed</div><div style="font-size:15px; font-weight:700; color:#e8e8eb;"><span style="color:${accent};">${passedCount}</span> / ${totalCount}</div></div>
|
|
<div><div style="font-family:${MONO}; font-size:10px; font-weight:600; color:#56565e; text-transform:uppercase; letter-spacing:1px; margin-bottom:4px;">Verified at</div><div style="font-family:${MONO}; font-size:15px; font-weight:600; color:#e8e8eb;">${esc(verifiedAt)}</div></div>
|
|
<div><div style="font-family:${MONO}; font-size:10px; font-weight:600; color:#56565e; text-transform:uppercase; letter-spacing:1px; margin-bottom:4px;">Source</div><div style="font-size:15px; font-weight:600; color:#e8e8eb;">${isReal ? "Real cryptographic tests" : "Simulated data"}</div></div>
|
|
</div>
|
|
</div>
|
|
<button class="sv-rerun" type="button" style="flex:none; display:inline-flex; align-items:center; gap:9px; padding:12px 18px; border-radius:11px; border:1px solid rgba(255,255,255,0.1); background:rgba(255,255,255,0.025); color:#cfcfd4; font-family:'Manrope',sans-serif; font-size:14px; font-weight:700; cursor:pointer; transition:all .2s;">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="pointer-events:none;"><path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/></svg>
|
|
Re-run
|
|
</button>
|
|
</div>
|
|
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(248px, 1fr)); gap:12px;">${testsHTML}</div>
|
|
<div style="display:flex; align-items:center; gap:12px; margin-top:18px; padding:16px 20px; border-radius:14px; background:rgba(${accentRGB},0.06); border:1px solid rgba(${accentRGB},0.18);">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="${accent}" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="flex:none;"><path d="M12 3l8 4v5c0 4.5-3.2 7.8-8 9-4.8-1.2-8-4.5-8-9V7l8-4z"/><path d="M9.2 12.2l2 2 3.6-3.8"/></svg>
|
|
<span style="font-size:14px; font-weight:600; color:#e8e8eb;">${isReal ? "Real-time verification using actual cryptographic functions \u2014 no mock data." : "Warning: connection may not be fully established \u2014 values may be simulated."}</span>
|
|
</div>
|
|
</div>`;
|
|
const onKey = (e) => {
|
|
if (e.key === "Escape") close();
|
|
};
|
|
const close = () => {
|
|
if (modal.parentNode) modal.remove();
|
|
document.removeEventListener("keydown", onKey);
|
|
};
|
|
modal.querySelector(".sv-close").addEventListener("click", close);
|
|
modal.addEventListener("click", (e) => {
|
|
if (e.target === modal) close();
|
|
});
|
|
document.addEventListener("keydown", onKey);
|
|
const rerun = modal.querySelector(".sv-rerun");
|
|
rerun.addEventListener("mouseenter", () => {
|
|
rerun.style.borderColor = "rgba(240,137,42,0.45)";
|
|
rerun.style.color = "#f0892a";
|
|
});
|
|
rerun.addEventListener("mouseleave", () => {
|
|
rerun.style.borderColor = "rgba(255,255,255,0.1)";
|
|
rerun.style.color = "#cfcfd4";
|
|
});
|
|
rerun.addEventListener("click", () => {
|
|
close();
|
|
runSecurityReport(webrtcManager);
|
|
});
|
|
document.body.appendChild(modal);
|
|
};
|
|
var SecureBitChatHeader = ({ status, onDisconnect, webrtcManager, title, isOffline, peerPresence, onRenameTitle }) => {
|
|
const MONO = "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace";
|
|
const [showNetwork, setShowNetwork] = React.useState(false);
|
|
const [sec, setSec] = React.useState(null);
|
|
const [editingName, setEditingName] = React.useState(false);
|
|
const [nameDraft, setNameDraft] = React.useState("");
|
|
React.useEffect(() => {
|
|
let alive = true;
|
|
const fetchSec = async () => {
|
|
try {
|
|
if (!webrtcManager) return;
|
|
let data = null;
|
|
if (typeof webrtcManager.getRealSecurityLevel === "function") data = await webrtcManager.getRealSecurityLevel();
|
|
else if (typeof webrtcManager.calculateAndReportSecurityLevel === "function") data = await webrtcManager.calculateAndReportSecurityLevel();
|
|
else if (window.EnhancedSecureCryptoUtils) data = await window.EnhancedSecureCryptoUtils.calculateSecurityLevel(webrtcManager);
|
|
if (alive && data && data.isRealData !== false) setSec(data);
|
|
} catch (e) {
|
|
}
|
|
};
|
|
fetchSec();
|
|
const onCalc = (e) => {
|
|
if (alive && e.detail && e.detail.securityData) setSec(e.detail.securityData);
|
|
};
|
|
document.addEventListener("real-security-calculated", onCalc);
|
|
const iv = setInterval(fetchSec, 15e3);
|
|
return () => {
|
|
alive = false;
|
|
clearInterval(iv);
|
|
document.removeEventListener("real-security-calculated", onCalc);
|
|
};
|
|
}, [webrtcManager]);
|
|
const onlineConnected = status === "connected" || status === "verified";
|
|
const dropped = status === "disconnected" || status === "peer_disconnected";
|
|
const connected = onlineConnected && !isOffline;
|
|
const connDot = isOffline || dropped ? "#e5727a" : onlineConnected ? "#3ecf8e" : "#e3c84e";
|
|
const connLabel = isOffline ? "Offline" : onlineConnected ? "Connected" : status === "peer_disconnected" ? "Peer disconnected" : status === "disconnected" ? "Disconnected" : "Connecting\u2026";
|
|
const connGlow = isOffline || dropped ? "0 0 0 3px rgba(229,114,122,0.16)" : onlineConnected ? "0 0 0 3px rgba(62,207,142,0.16)" : "0 0 0 3px rgba(227,200,78,0.16)";
|
|
const peerDot = onlineConnected && !isOffline ? PRESENCE_DOT[peerPresence] || "#3ecf8e" : connDot;
|
|
const peerPresenceWord = onlineConnected && !isOffline && peerPresence ? PRESENCE_WORD[peerPresence] || null : null;
|
|
const startRename = () => {
|
|
setNameDraft(title || "");
|
|
setEditingName(true);
|
|
};
|
|
const commitRename = () => {
|
|
if (typeof onRenameTitle === "function") onRenameTitle(nameDraft);
|
|
setEditingName(false);
|
|
};
|
|
const renameKey = (e) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
commitRename();
|
|
} else if (e.key === "Escape") {
|
|
setEditingName(false);
|
|
}
|
|
};
|
|
const passed = sec && Number.isFinite(sec.passedChecks) ? sec.passedChecks : null;
|
|
const total = sec && Number.isFinite(sec.totalChecks) ? sec.totalChecks : null;
|
|
const scoreLabel = passed != null && total ? passed + "/" + total : sec ? sec.score + "%" : "\u2014";
|
|
const accent = sec ? sec.color === "green" ? "#3ecf8e" : sec.color === "orange" ? "#f0892a" : sec.color === "yellow" ? "#e3c84e" : "#e5727a" : "#3ecf8e";
|
|
const secBtn = React.createElement("div", {
|
|
key: "sec",
|
|
title: "Run security verification",
|
|
onClick: () => runSecurityReport(webrtcManager),
|
|
className: "sb-secpill",
|
|
style: { display: "flex", alignItems: "center", gap: "9px", padding: "7px 13px", borderRadius: "9px", border: "1px solid " + (showNetwork ? "rgba(255,255,255,0.16)" : "rgba(255,255,255,0.07)"), background: showNetwork ? "rgba(255,255,255,0.05)" : "rgba(255,255,255,0.02)", cursor: "pointer", fontFamily: "inherit", transition: "all .15s" }
|
|
}, [
|
|
React.createElement("i", { key: "i", className: "fas fa-shield-halved", style: { color: accent, fontSize: "13px" } }),
|
|
React.createElement("span", { key: "l", className: "sb-sec-label", style: { fontSize: "13px", fontWeight: 600, color: "#e8e8eb" } }, sec ? sec.level || "Secure" : "Secure"),
|
|
React.createElement("span", { key: "d", className: "sb-sec-div", style: { width: "1px", height: "13px", background: "rgba(255,255,255,0.12)" } }),
|
|
React.createElement("span", { key: "s", className: "sb-sec-score", style: { fontFamily: MONO, fontSize: "11.5px", fontWeight: 500, color: "#8a8a92" } }, scoreLabel),
|
|
React.createElement("button", {
|
|
key: "c",
|
|
type: "button",
|
|
title: "Network & crypto details",
|
|
onClick: (e) => {
|
|
e.stopPropagation();
|
|
setShowNetwork((v) => !v);
|
|
},
|
|
style: { background: "none", border: "none", padding: 0, margin: 0, cursor: "pointer", display: "grid", placeItems: "center" }
|
|
}, React.createElement("i", { className: "fas fa-chevron-down", style: { color: "#6b6b73", fontSize: "11px", transform: showNetwork ? "rotate(180deg)" : "rotate(0deg)", transition: "transform .2s" } }))
|
|
]);
|
|
const headerResponsiveCss = React.createElement("style", { key: "hdr-css", dangerouslySetInnerHTML: {
|
|
__html: (
|
|
// Encrypted call buttons — green hover per the design.
|
|
".sb-call-btn:not(.sb-call-off):hover{border-color:rgba(62,207,142,0.45) !important;color:#3ecf8e !important;background:rgba(62,207,142,0.07) !important;}@media (max-width:768px){.sb-chat-header{padding-left:58px !important;gap:8px !important;}.sb-chat-header .sb-secpill{display:none !important;}.sb-chat-header .sb-conn-text{display:none !important;}.sb-chat-header .sb-conn{padding:9px !important;}.sb-chat-header .sb-hdr-sub{display:none !important;}.sb-chat-header .sb-disconnect{width:40px !important;height:40px !important;padding:0 !important;gap:0 !important;justify-content:center !important;border-radius:9px !important;color:#e5727a !important;border-color:rgba(229,114,122,0.28) !important;}}@media (max-width:600px){.sb-chips{flex-wrap:nowrap !important;justify-content:flex-start !important;overflow-x:auto;-webkit-overflow-scrolling:touch;scrollbar-width:none;}.sb-chips::-webkit-scrollbar{display:none;}.sb-chips>*{flex:0 0 auto !important;}.sb-chips .sb-chips-right{flex-wrap:nowrap !important;}.sb-chips .sb-chip{white-space:nowrap;}}@media (max-width:480px){.sb-chat-header{padding-right:12px !important;}}"
|
|
)
|
|
} });
|
|
const header = React.createElement("header", {
|
|
key: "hdr",
|
|
className: "sb-chat-header",
|
|
style: { flex: "none", display: "flex", alignItems: "center", justifyContent: "space-between", gap: "24px", padding: "0 20px", height: "64px", borderBottom: "1px solid rgba(255,255,255,0.06)", background: "rgba(18,18,20,0.72)", backdropFilter: "blur(14px)", WebkitBackdropFilter: "blur(14px)" }
|
|
}, [
|
|
headerResponsiveCss,
|
|
// The SecureBit brand/logo lives in the left rail; this header identifies the
|
|
// ACTIVE conversation — avatar monogram + local label + connection status.
|
|
React.createElement("div", { key: "left", style: { display: "flex", alignItems: "center", gap: "12px", minWidth: 0 } }, [
|
|
React.createElement("div", { key: "avatar", style: { position: "relative", flex: "none", width: "36px", height: "36px", borderRadius: "10px", display: "grid", placeItems: "center", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.09)", fontSize: "13px", fontWeight: 700, letterSpacing: "-0.3px", color: "#e8e8eb" } }, [
|
|
monoInitials(title || "Chat"),
|
|
React.createElement("span", { key: "dot", style: { position: "absolute", right: "-2px", bottom: "-2px", width: "11px", height: "11px", borderRadius: "50%", background: peerDot, border: "2px solid #121214" } })
|
|
]),
|
|
editingName ? React.createElement("div", { key: "edit", style: { display: "flex", flexDirection: "column", gap: "4px", minWidth: 0 } }, [
|
|
React.createElement("div", { key: "row", style: { display: "flex", alignItems: "center", gap: "6px" } }, [
|
|
React.createElement("input", { key: "in", autoFocus: true, value: nameDraft, maxLength: 32, placeholder: "Name this chat", onChange: (e) => setNameDraft(e.target.value), onKeyDown: renameKey, onBlur: commitRename, style: { width: "210px", padding: "5px 10px", borderRadius: "8px", border: "1px solid rgba(240,137,42,0.55)", background: "#0f0f11", color: "#f4f4f6", fontFamily: "inherit", fontSize: "14px", fontWeight: 700, outline: "none" } }),
|
|
React.createElement("button", { key: "ok", onMouseDown: (e) => e.preventDefault(), onClick: commitRename, title: "Save", style: { flex: "none", width: "28px", height: "28px", borderRadius: "8px", display: "grid", placeItems: "center", border: "none", background: "#f0892a", color: "#1a0f04", cursor: "pointer" } }, React.createElement("i", { className: "fas fa-check", style: { fontSize: "12px" } }))
|
|
]),
|
|
React.createElement("div", { key: "hint", style: { fontSize: "11px", color: "#56565e" } }, "Local label \xB7 stored only on this device")
|
|
]) : React.createElement("div", { key: "txt", style: { lineHeight: 1.2, minWidth: 0 } }, [
|
|
React.createElement("div", { key: "r1", style: { display: "flex", alignItems: "center", gap: "7px" } }, [
|
|
React.createElement("span", { key: "n", style: { fontSize: "15px", fontWeight: 800, letterSpacing: "-0.3px", color: "#f4f4f6", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, title || "Secure chat"),
|
|
React.createElement("button", { key: "edit", className: "sb-rename-btn", onClick: startRename, title: "Rename chat (local only)", style: { flex: "none", width: "24px", height: "24px", borderRadius: "7px", display: "grid", placeItems: "center", border: "none", background: "transparent", color: "#56565e", cursor: "pointer" } }, React.createElement("i", { className: "fas fa-pen", style: { fontSize: "11px" } }))
|
|
]),
|
|
React.createElement("div", { key: "r2", className: "sb-hdr-sub", style: { fontSize: "11px", color: "#6b6b73", fontWeight: 500, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, isOffline ? "No network \xB7 reconnecting" : peerPresenceWord || (onlineConnected ? "P2P \xB7 end-to-end encrypted" : status === "peer_disconnected" ? "Peer disconnected" : status === "disconnected" ? "Disconnected" : "Connecting\u2026"))
|
|
])
|
|
]),
|
|
secBtn,
|
|
React.createElement("div", { key: "right", className: "sb-hdr-right", style: { display: "flex", alignItems: "center", gap: "9px" } }, [
|
|
// Encrypted call buttons — enabled only once the session is
|
|
// connected AND SAS-verified (the manager enforces the same
|
|
// gate; this just reflects it). Media rides the verified
|
|
// DTLS-SRTP transport, so calls inherit the E2E encryption.
|
|
...(() => {
|
|
const callReady = connected && webrtcManager && webrtcManager.isVerified === true;
|
|
const startCall = (video) => {
|
|
if (!callReady || !webrtcManager) return;
|
|
try {
|
|
const p = webrtcManager.startCall(video);
|
|
if (p && p.catch) p.catch(() => {
|
|
});
|
|
} catch (_) {
|
|
}
|
|
};
|
|
const callBtnStyle = {
|
|
width: "40px",
|
|
height: "40px",
|
|
borderRadius: "9px",
|
|
display: "grid",
|
|
placeItems: "center",
|
|
border: "1px solid rgba(255,255,255,0.08)",
|
|
background: "rgba(255,255,255,0.02)",
|
|
color: callReady ? "#9a9aa2" : "#3f3f47",
|
|
cursor: callReady ? "pointer" : "not-allowed",
|
|
transition: "all .15s"
|
|
};
|
|
const PHONE_SVG = '<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.8 19.8 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6A19.8 19.8 0 0 1 2.12 4.18 2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.34 1.85.57 2.81.7A2 2 0 0 1 22 16.92z"/></svg>';
|
|
const VIDEO_SVG = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2.5"/></svg>';
|
|
return [
|
|
React.createElement("button", { key: "call-audio", className: callReady ? "sb-call-btn" : "sb-call-btn sb-call-off", disabled: !callReady, onClick: () => startCall(false), title: callReady ? "Start encrypted voice call" : "Verify the session to enable calls", style: callBtnStyle, dangerouslySetInnerHTML: { __html: PHONE_SVG } }),
|
|
React.createElement("button", { key: "call-video", className: callReady ? "sb-call-btn" : "sb-call-btn sb-call-off", disabled: !callReady, onClick: () => startCall(true), title: callReady ? "Start encrypted video call" : "Verify the session to enable calls", style: callBtnStyle, dangerouslySetInnerHTML: { __html: VIDEO_SVG } })
|
|
];
|
|
})(),
|
|
React.createElement("div", { key: "conn", className: "sb-conn", style: { display: "flex", alignItems: "center", gap: "8px", padding: "8px 13px", borderRadius: "9px", border: "1px solid rgba(255,255,255,0.07)", background: "rgba(255,255,255,0.02)" } }, [
|
|
React.createElement("span", { key: "dot", style: { flex: "none", width: "7px", height: "7px", borderRadius: "50%", background: connDot, boxShadow: connGlow } }),
|
|
React.createElement("span", { key: "t", className: "sb-conn-text", style: { fontSize: "13px", fontWeight: 600, color: "#cfcfd4" } }, connLabel)
|
|
]),
|
|
React.createElement("button", { key: "dc", onClick: onDisconnect, className: "sb-disconnect", style: { display: "flex", alignItems: "center", gap: "7px", padding: "8px 14px", borderRadius: "9px", border: "1px solid rgba(255,255,255,0.08)", background: "transparent", color: "#9a9aa2", fontFamily: "inherit", fontSize: "13px", fontWeight: 600, cursor: "pointer", transition: "all .15s" } }, [
|
|
React.createElement("i", { key: "i", className: "fas fa-power-off", style: { fontSize: "12px" } }),
|
|
React.createElement("span", { key: "t", className: "sb-hide-sm" }, "Disconnect")
|
|
])
|
|
])
|
|
]);
|
|
const netPanel = showNetwork && React.createElement("div", {
|
|
key: "net",
|
|
style: { flex: "none", padding: "13px 20px", borderBottom: "1px solid rgba(255,255,255,0.06)", background: "rgba(18,18,20,0.72)", backdropFilter: "blur(14px)", WebkitBackdropFilter: "blur(14px)" }
|
|
}, React.createElement(
|
|
"div",
|
|
{ style: { maxWidth: "1000px", margin: "0 auto", display: "grid", gridTemplateColumns: "repeat(auto-fit,minmax(140px,1fr))", gap: "14px", fontFamily: MONO } },
|
|
[
|
|
["Transport", "WebRTC \xB7 DTLS"],
|
|
["Cipher", "AES-256-GCM"],
|
|
["Key exchange", "ECDH P-384"],
|
|
["Security", scoreLabel + (sec ? " \xB7 " + sec.score + "%" : "")]
|
|
].map(([k, v], i) => React.createElement("div", { key: "nf" + i }, [
|
|
React.createElement("div", { key: "k", style: { fontSize: "10px", color: "#6b6b73", textTransform: "uppercase", letterSpacing: "0.6px", marginBottom: "4px" } }, k),
|
|
React.createElement("div", { key: "v", style: { fontSize: "12.5px", color: i === 3 ? accent : "#cfcfd4", fontWeight: 500 } }, v)
|
|
]))
|
|
));
|
|
return React.createElement("div", { style: { flex: "none" } }, [header, netPanel]);
|
|
};
|
|
var EnhancedChatInterface = ({
|
|
title,
|
|
isOffline,
|
|
peerPresence,
|
|
onRenameTitle,
|
|
messages,
|
|
messageInput,
|
|
setMessageInput,
|
|
onSendMessage,
|
|
onSendVoice,
|
|
onDisconnect,
|
|
keyFingerprint,
|
|
isVerified,
|
|
chatMessagesRef,
|
|
scrollToBottom,
|
|
webrtcManager,
|
|
status,
|
|
pendingIncomingFiles = [],
|
|
onIncomingDecision,
|
|
// Secure chat extras
|
|
codeMode,
|
|
setCodeMode,
|
|
viewOnceMode,
|
|
setViewOnceMode,
|
|
viewOnceTtl,
|
|
setViewOnceTtl,
|
|
disappearTtl,
|
|
setDisappearTtl,
|
|
nowTick,
|
|
onUnsendMessage,
|
|
onMessageExpire
|
|
}) => {
|
|
const [showScrollButton, setShowScrollButton] = React.useState(false);
|
|
const [showFileTransfer, setShowFileTransfer] = React.useState(false);
|
|
const [fileSendMode, setFileSendMode] = React.useState(false);
|
|
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;
|
|
if (!el || codeMode) return;
|
|
el.style.height = "auto";
|
|
el.style.height = Math.min(el.scrollHeight, 240) + "px";
|
|
}, [messageInput, codeMode]);
|
|
React.useEffect(() => {
|
|
if (pendingIncomingFiles.length > 0) {
|
|
setShowFileTransfer(true);
|
|
}
|
|
}, [pendingIncomingFiles.length]);
|
|
React.useEffect(() => {
|
|
if (chatMessagesRef.current && messages.length > 0) {
|
|
const { scrollTop, scrollHeight, clientHeight } = chatMessagesRef.current;
|
|
const isNearBottom = scrollHeight - scrollTop - clientHeight < 100;
|
|
if (isNearBottom) {
|
|
const smoothScroll = () => {
|
|
if (chatMessagesRef.current) {
|
|
chatMessagesRef.current.scrollTo({
|
|
top: chatMessagesRef.current.scrollHeight,
|
|
behavior: "smooth"
|
|
});
|
|
}
|
|
};
|
|
smoothScroll();
|
|
setTimeout(smoothScroll, 50);
|
|
setTimeout(smoothScroll, 150);
|
|
}
|
|
}
|
|
}, [messages, chatMessagesRef]);
|
|
const handleScroll = () => {
|
|
if (chatMessagesRef.current) {
|
|
const { scrollTop, scrollHeight, clientHeight } = chatMessagesRef.current;
|
|
const isNearBottom = scrollHeight - scrollTop - clientHeight < 100;
|
|
setShowScrollButton(!isNearBottom);
|
|
}
|
|
};
|
|
const handleScrollToBottom = () => {
|
|
if (typeof scrollToBottom === "function") {
|
|
scrollToBottom();
|
|
setShowScrollButton(false);
|
|
} else if (chatMessagesRef.current) {
|
|
chatMessagesRef.current.scrollTo({ top: chatMessagesRef.current.scrollHeight, behavior: "smooth" });
|
|
setShowScrollButton(false);
|
|
}
|
|
};
|
|
const handleKeyPress = (e) => {
|
|
if (e.key !== "Enter") return;
|
|
if (codeMode) {
|
|
if (e.metaKey || e.ctrlKey) {
|
|
e.preventDefault();
|
|
onSendMessage();
|
|
}
|
|
} else if (!e.shiftKey) {
|
|
e.preventDefault();
|
|
onSendMessage();
|
|
}
|
|
};
|
|
const isFileTransferReady = () => {
|
|
if (!webrtcManager) return false;
|
|
const connected = webrtcManager.isConnected ? webrtcManager.isConnected() : false;
|
|
const verified = webrtcManager.isVerified || false;
|
|
const hasDataChannel = webrtcManager.dataChannel && webrtcManager.dataChannel.readyState === "open";
|
|
return connected && verified && hasDataChannel;
|
|
};
|
|
const MONO = "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace";
|
|
const fmtShort = (s) => {
|
|
if (!s) return "";
|
|
if (s >= 86400 && s % 86400 === 0) return s / 86400 + "d";
|
|
if (s >= 3600 && s % 3600 === 0) return s / 3600 + "h";
|
|
if (s >= 60) return Math.round(s / 60) + "m";
|
|
return s + "s";
|
|
};
|
|
const chipStyle = (active) => ({
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "6px",
|
|
padding: "7px 11px",
|
|
borderRadius: "8px",
|
|
border: "1px solid " + (active ? "rgba(255,255,255,0.18)" : "rgba(255,255,255,0.07)"),
|
|
background: active ? "rgba(255,255,255,0.06)" : "transparent",
|
|
color: active ? "#fff" : "#9a9aa2",
|
|
fontFamily: "inherit",
|
|
fontSize: "12.5px",
|
|
fontWeight: 600,
|
|
cursor: "pointer",
|
|
transition: "all .15s"
|
|
});
|
|
const optStyle = (sel) => ({
|
|
padding: "6px 12px",
|
|
borderRadius: "8px",
|
|
border: "1px solid " + (sel ? "rgba(255,255,255,0.22)" : "rgba(255,255,255,0.07)"),
|
|
background: sel ? "rgba(255,255,255,0.07)" : "transparent",
|
|
color: sel ? "#fff" : "#8a8a92",
|
|
fontFamily: MONO,
|
|
fontSize: "12px",
|
|
fontWeight: 500,
|
|
cursor: "pointer",
|
|
transition: "all .14s"
|
|
});
|
|
const timerDefs = [
|
|
{ label: "Off", v: 0 },
|
|
{ label: "5s", v: 5 },
|
|
{ label: "30s", v: 30 },
|
|
{ label: "1m", v: 60 },
|
|
{ label: "1h", v: 3600 },
|
|
{ label: "24h", v: 86400 }
|
|
];
|
|
const onceDefs = [
|
|
{ label: "Off", v: 0 },
|
|
{ label: "5s", v: 5 },
|
|
{ label: "10s", v: 10 },
|
|
{ label: "30s", v: 30 },
|
|
{ label: "1m", v: 60 }
|
|
];
|
|
const onceSelected = viewOnceMode ? viewOnceTtl : 0;
|
|
const pickTimer = (v) => {
|
|
setDisappearTtl(v);
|
|
setShowTimer(false);
|
|
};
|
|
const pickOnce = (v) => {
|
|
if (v === 0) setViewOnceMode(false);
|
|
else {
|
|
setViewOnceTtl(v);
|
|
setViewOnceMode(true);
|
|
}
|
|
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" });
|
|
} catch (e) {
|
|
return "";
|
|
}
|
|
};
|
|
const systemMessages = messages.filter((m) => m.type === "system" && typeof m.message === "string" && m.message.trim());
|
|
const chatMessages = messages.filter((m) => m.type !== "system");
|
|
const handshakeCard = (isVerified || systemMessages.length > 0) && React.createElement("div", {
|
|
key: "handshake",
|
|
style: { border: "1px solid rgba(255,255,255,0.07)", borderRadius: "12px", background: "#161618", overflow: "hidden" }
|
|
}, [
|
|
React.createElement("button", {
|
|
key: "hs-btn",
|
|
onClick: () => setShowHandshake((v) => !v),
|
|
style: { width: "100%", display: "flex", alignItems: "center", gap: "13px", padding: "14px 16px", background: "transparent", border: "none", color: "inherit", cursor: "pointer", textAlign: "left", fontFamily: "inherit" }
|
|
}, [
|
|
React.createElement(
|
|
"div",
|
|
{ key: "ic", style: { flex: "none", width: "30px", height: "30px", display: "grid", placeItems: "center" } },
|
|
React.createElement("i", { className: "fas fa-check", style: { color: "#3ecf8e", fontSize: "16px" } })
|
|
),
|
|
React.createElement("div", { key: "tx", style: { flex: 1, minWidth: 0 } }, [
|
|
React.createElement("div", { key: "t1", style: { fontSize: "13.5px", fontWeight: 600, color: "#e8e8eb", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, "Secure channel established"),
|
|
React.createElement("div", { key: "t2", style: { fontSize: "12px", color: "#7b7b83", marginTop: "1px", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, "Verified \xB7 Perfect Forward Secrecy" + (systemMessages.length ? " \xB7 " + systemMessages.length + (systemMessages.length === 1 ? " event" : " events") : ""))
|
|
]),
|
|
React.createElement("i", { key: "chev", className: "fas fa-chevron-down", style: { flex: "none", color: "#6b6b73", fontSize: "13px", transform: showHandshake ? "rotate(180deg)" : "rotate(0deg)", transition: "transform .2s" } })
|
|
]),
|
|
showHandshake && React.createElement("div", { key: "hs-body", style: { padding: "2px 16px 14px 59px" } }, [
|
|
systemMessages.length > 0 && React.createElement(
|
|
"div",
|
|
{ key: "steps", className: "sb-scroll", style: { marginBottom: "12px", maxHeight: "220px", overflowY: "auto", paddingRight: "6px" } },
|
|
systemMessages.map((m, i) => React.createElement("div", { key: "s" + i, style: { display: "flex", gap: "11px", padding: "6px 0", borderTop: i === 0 ? "none" : "1px solid rgba(255,255,255,0.04)" } }, [
|
|
React.createElement("span", { key: "d", style: { flex: "none", width: "5px", height: "5px", borderRadius: "50%", background: "#3ecf8e", marginTop: "7px", opacity: 0.6 } }),
|
|
React.createElement("span", { key: "t", style: { flex: 1, fontSize: "12.5px", color: "#9a9aa2", lineHeight: 1.5, wordBreak: "break-word" } }, String(m.message || "").trim()),
|
|
React.createElement("span", { key: "tm", style: { flex: "none", fontFamily: MONO, fontSize: "10.5px", color: "#56565e" } }, fmtT(m.timestamp))
|
|
]))
|
|
),
|
|
keyFingerprint && React.createElement("div", { key: "sn", style: { display: "flex", alignItems: "center", gap: "9px", padding: "10px 12px", borderRadius: "9px", background: "rgba(255,255,255,0.025)", border: "1px solid rgba(255,255,255,0.06)" } }, [
|
|
React.createElement("i", { key: "i", className: "fas fa-lock", style: { color: "#8a8a92", fontSize: "12px" } }),
|
|
React.createElement("span", { key: "l", style: { fontSize: "11.5px", color: "#8a8a92" } }, "Safety number"),
|
|
React.createElement("span", { key: "v", style: { fontFamily: MONO, fontSize: "12px", color: "#cfcfd4", letterSpacing: "0.8px", fontWeight: 500, wordBreak: "break-all" } }, keyFingerprint)
|
|
])
|
|
])
|
|
]);
|
|
const emptyState = React.createElement(
|
|
"div",
|
|
{ key: "empty", style: { display: "flex", alignItems: "center", justifyContent: "center", flex: 1, minHeight: "40vh" } },
|
|
React.createElement("div", { style: { textAlign: "center", maxWidth: "420px" } }, [
|
|
React.createElement("img", { key: "ic", src: "/logo/securebit-mark.svg", alt: "SecureBit", style: { width: "60px", height: "60px", objectFit: "contain", display: "block", margin: "0 auto 16px" } }),
|
|
React.createElement("h3", { key: "t", style: { fontSize: "17px", fontWeight: 700, color: "#e8e8eb", margin: "0 0 6px" } }, "Secure channel is ready"),
|
|
React.createElement("p", { key: "p", style: { fontSize: "13px", color: "#7b7b83", margin: 0 } }, "Every message is end-to-end encrypted on your device before it leaves.")
|
|
])
|
|
);
|
|
const messagesArea = React.createElement("main", {
|
|
key: "main",
|
|
ref: chatMessagesRef,
|
|
onScroll: handleScroll,
|
|
className: "sb-scroll",
|
|
style: { flex: 1, overflowY: "auto", padding: "20px 20px 22px" }
|
|
}, React.createElement(
|
|
"div",
|
|
{ style: { width: "100%", maxWidth: "1000px", margin: "0 auto", display: "flex", flexDirection: "column", gap: "16px", minHeight: "100%" } },
|
|
chatMessages.length === 0 ? [handshakeCard, emptyState] : [handshakeCard].concat(chatMessages.map((msg) => React.createElement(EnhancedChatMessage, {
|
|
key: msg.id,
|
|
message: msg.message,
|
|
type: msg.type,
|
|
timestamp: msg.timestamp,
|
|
mid: msg.mid,
|
|
status: msg.status,
|
|
viewOnce: msg.viewOnce,
|
|
viewOnceTtl: msg.viewOnceTtl,
|
|
expiresAt: msg.expiresAt,
|
|
expired: msg.expired,
|
|
nowTick,
|
|
canUnsend: typeof onUnsendMessage === "function",
|
|
onUnsend: onUnsendMessage,
|
|
onExpire: () => onMessageExpire && onMessageExpire(msg.id),
|
|
voice: msg.voice
|
|
})))
|
|
));
|
|
const timerRow = showTimer && React.createElement(
|
|
"div",
|
|
{ key: "timer-row", style: { display: "flex", flexWrap: "wrap", alignItems: "center", gap: "8px", padding: "10px 12px", marginBottom: "10px", borderRadius: "11px", border: "1px solid rgba(255,255,255,0.07)", background: "#161618" } },
|
|
[React.createElement("span", { key: "lbl", style: { fontSize: "12px", color: "#8a8a92", fontWeight: 600, marginRight: "4px" } }, "Disappear after")].concat(
|
|
timerDefs.map((d) => React.createElement("button", { key: "td" + d.v, onClick: () => pickTimer(d.v), style: optStyle(disappearTtl === d.v) }, d.label))
|
|
)
|
|
);
|
|
const onceRow = showOnce && React.createElement(
|
|
"div",
|
|
{ key: "once-row", style: { display: "flex", flexWrap: "wrap", alignItems: "center", gap: "8px", padding: "10px 12px", marginBottom: "10px", borderRadius: "11px", border: "1px solid rgba(255,255,255,0.07)", background: "#161618" } },
|
|
[React.createElement("span", { key: "lbl", style: { fontSize: "12px", color: "#8a8a92", fontWeight: 600, marginRight: "4px" } }, "Visible for")].concat(
|
|
onceDefs.map((d) => React.createElement("button", { key: "od" + d.v, onClick: () => pickOnce(d.v), style: optStyle(onceSelected === d.v) }, d.label))
|
|
)
|
|
);
|
|
const filePanel = showFileTransfer && React.createElement(
|
|
"div",
|
|
{ key: "file-panel", style: { marginBottom: "10px" } },
|
|
React.createElement(window.FileTransferComponent || (() => React.createElement("div", { style: { padding: "16px", textAlign: "center", color: "#e5727a" } }, "FileTransferComponent not loaded")), {
|
|
webrtcManager,
|
|
isConnected: isFileTransferReady(),
|
|
pendingIncomingFiles,
|
|
onIncomingDecision,
|
|
showDropzone: fileSendMode
|
|
})
|
|
);
|
|
const chipsRow = React.createElement("div", { key: "chips", className: "sb-chips", style: { display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: "8px", marginBottom: "10px" } }, [
|
|
React.createElement("button", { key: "files", onClick: () => {
|
|
if (showFileTransfer && fileSendMode) {
|
|
setShowFileTransfer(false);
|
|
setFileSendMode(false);
|
|
} else {
|
|
setShowFileTransfer(true);
|
|
setFileSendMode(true);
|
|
}
|
|
}, className: "sb-chip", style: chipStyle(showFileTransfer && fileSendMode) }, [
|
|
React.createElement("i", { key: "i", className: "fas fa-paperclip", style: { fontSize: "13px" } }),
|
|
showFileTransfer && fileSendMode ? "Hide files" : "Send files"
|
|
]),
|
|
React.createElement("div", { key: "right", className: "sb-chips-right", style: { display: "flex", alignItems: "center", gap: "6px", flexWrap: "wrap" } }, [
|
|
React.createElement("button", { key: "code", onClick: () => setCodeMode((v) => !v), className: "sb-chip", style: chipStyle(codeMode) }, [
|
|
React.createElement("i", { key: "i", className: "fas fa-code", style: { fontSize: "13px" } }),
|
|
"Code"
|
|
]),
|
|
React.createElement("button", { key: "once", onClick: () => {
|
|
setShowOnce((v) => !v);
|
|
setShowTimer(false);
|
|
}, className: "sb-chip", style: chipStyle(showOnce || viewOnceMode) }, [
|
|
React.createElement("i", { key: "i", className: "fas fa-eye-slash", style: { fontSize: "13px" } }),
|
|
viewOnceMode ? "View once \xB7 " + fmtShort(viewOnceTtl) : "View once"
|
|
]),
|
|
React.createElement("button", { key: "timer", onClick: () => {
|
|
setShowTimer((v) => !v);
|
|
setShowOnce(false);
|
|
}, className: "sb-chip", style: chipStyle(showTimer || disappearTtl > 0) }, [
|
|
React.createElement("i", { key: "i", className: "fas fa-stopwatch", style: { fontSize: "13px" } }),
|
|
disappearTtl > 0 ? "Timer \xB7 " + fmtShort(disappearTtl) : "Timer"
|
|
])
|
|
])
|
|
]);
|
|
const codeStrip = codeMode && React.createElement("div", { key: "code-strip", style: { display: "flex", alignItems: "center", gap: "8px", padding: "8px 14px", border: "1px solid rgba(255,255,255,0.08)", borderBottom: "none", borderRadius: "14px 14px 0 0", background: "#161618" } }, [
|
|
React.createElement("i", { key: "i", className: "fas fa-code", style: { color: "#8a8a92", fontSize: "12px" } }),
|
|
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" }
|
|
}, [
|
|
React.createElement("div", { key: "ta-wrap", style: { flex: 1, minWidth: 0 } }, [
|
|
React.createElement("textarea", {
|
|
key: "ta",
|
|
value: messageInput,
|
|
ref: taRef,
|
|
onChange: (e) => setMessageInput(e.target.value),
|
|
onKeyDown: handleKeyPress,
|
|
rows: 1,
|
|
maxLength: 2e3,
|
|
placeholder: codeMode ? "Paste or write code\u2026" : "Type an encrypted message\u2026",
|
|
className: "sb-textarea",
|
|
style: { width: "100%", minHeight: codeMode ? "120px" : "22px", maxHeight: "240px", resize: "none", border: "none", outline: "none", background: "transparent", color: "#e8e8eb", fontFamily: codeMode ? MONO : "inherit", fontSize: codeMode ? "13px" : "14.5px", lineHeight: 1.55, padding: "6px 0" }
|
|
}),
|
|
React.createElement("div", { key: "foot", style: { display: "flex", alignItems: "center", gap: "12px", marginTop: "3px" } }, [
|
|
React.createElement("span", { key: "enc", style: { display: "inline-flex", alignItems: "center", gap: "5px", fontSize: "11px", color: "#56565e" } }, [
|
|
React.createElement("i", { key: "i", className: "fas fa-lock", style: { color: "#3ecf8e", fontSize: "10px" } }),
|
|
"Encrypted on your device"
|
|
]),
|
|
React.createElement("span", { key: "cnt", style: { fontFamily: MONO, fontSize: "10.5px", color: "#56565e", marginLeft: "auto" } }, (messageInput ? messageInput.length : 0) + "/2000")
|
|
])
|
|
])
|
|
].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 calc(18px + env(safe-area-inset-bottom, 0px))", background: "#0f0f11", borderTop: "1px solid rgba(255,255,255,0.05)" } },
|
|
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",
|
|
onClick: handleScrollToBottom,
|
|
style: { position: "fixed", right: "24px", bottom: "150px", width: "44px", height: "44px", borderRadius: "50%", border: "1px solid rgba(255,255,255,0.1)", background: "#26262b", color: "#cfcfd4", display: "grid", placeItems: "center", cursor: "pointer", zIndex: 50, boxShadow: "0 6px 20px rgba(0,0,0,0.4)" }
|
|
}, React.createElement("i", { className: "fas fa-arrow-down", style: { fontSize: "15px" } }));
|
|
const chatHeader = React.createElement(SecureBitChatHeader, {
|
|
key: "chat-header",
|
|
status,
|
|
onDisconnect,
|
|
webrtcManager,
|
|
title,
|
|
isOffline,
|
|
peerPresence,
|
|
onRenameTitle
|
|
});
|
|
const callOverlay = window.CallUIComponent && React.createElement(window.CallUIComponent, {
|
|
key: "call-overlay",
|
|
webrtcManager,
|
|
peerTitle: title
|
|
});
|
|
return React.createElement("div", {
|
|
className: "chat-container",
|
|
style: { position: "relative", display: "flex", flexDirection: "column", height: "100vh", background: "#0f0f11", color: "#e8e8eb" }
|
|
}, [chatHeader, messagesArea, scrollBtn, composer, callOverlay]);
|
|
};
|
|
var buildSessionMessage = (message, type, opts = {}) => ({
|
|
message,
|
|
type,
|
|
id: Date.now() + Math.random(),
|
|
timestamp: typeof opts.timestamp === "number" ? opts.timestamp : Date.now(),
|
|
mid: opts.mid,
|
|
status: opts.status,
|
|
viewOnce: opts.viewOnce === true,
|
|
viewOnceTtl: typeof opts.viewOnceTtl === "number" ? opts.viewOnceTtl : 15,
|
|
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>',
|
|
chevR: '<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="M9 6l6 6-6 6"/></svg>',
|
|
plus: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>',
|
|
users: '<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M16 19v-1.5a3.5 3.5 0 0 0-3.5-3.5h-5A3.5 3.5 0 0 0 4 17.5V19"/><circle cx="10" cy="8" r="3.2"/><path d="M20 19v-1.5a3.5 3.5 0 0 0-2.6-3.4"/><path d="M15.5 5.3a3.2 3.2 0 0 1 0 5.4"/></svg>',
|
|
burger: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M4 12h16M4 17h16"/></svg>'
|
|
};
|
|
var SessionsSidebar = ({ chats, collapsed, drawerOpen, onToggleCollapse, onSelect, onNewChat, onRename, onCloseDrawer, myStatus, onSetStatus }) => {
|
|
const h = React.createElement;
|
|
const [editingId, setEditingId] = React.useState(null);
|
|
const [draft, setDraft] = React.useState("");
|
|
const [presenceOpen, setPresenceOpen] = React.useState(false);
|
|
const startEdit = (c) => (e) => {
|
|
e.stopPropagation();
|
|
setEditingId(c.id);
|
|
setDraft(c.name);
|
|
};
|
|
const commitEdit = () => {
|
|
if (editingId) {
|
|
onRename(editingId, draft);
|
|
setEditingId(null);
|
|
}
|
|
};
|
|
const editKey = (e) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
commitEdit();
|
|
} else if (e.key === "Escape") {
|
|
setEditingId(null);
|
|
}
|
|
};
|
|
const renameInput = (extra = {}) => h("input", {
|
|
autoFocus: true,
|
|
value: draft,
|
|
onChange: (e) => setDraft(e.target.value),
|
|
onKeyDown: editKey,
|
|
onBlur: commitEdit,
|
|
onClick: (e) => e.stopPropagation(),
|
|
style: Object.assign({ width: "100%", background: "rgba(255,255,255,0.06)", border: "1px solid rgba(240,137,42,0.5)", borderRadius: "6px", color: "#f4f4f6", fontFamily: "inherit", fontSize: "14px", fontWeight: 700, padding: "2px 6px", outline: "none" }, extra)
|
|
});
|
|
const icon = (svg, style) => h("span", { style: Object.assign({ display: "grid", placeItems: "center" }, style || {}), dangerouslySetInnerHTML: { __html: svg } });
|
|
const avatar = (c, size, ring) => h("div", {
|
|
style: { position: "relative", flex: "none", width: size + "px", height: size + "px", borderRadius: (size >= 44 ? 12 : 11) + "px", display: "grid", placeItems: "center", background: c.active ? "rgba(255,255,255,0.06)" : "rgba(255,255,255,0.035)", border: "1px solid rgba(255,255,255," + (c.active ? "0.14" : "0.07") + ")", fontSize: "13px", fontWeight: 700, letterSpacing: "-0.3px", color: c.active ? "#f4f4f6" : "#9a9aa2" }
|
|
}, [c.mono, h("span", { key: "dot", style: { position: "absolute", right: "-2px", bottom: "-2px", width: "11px", height: "11px", borderRadius: "50%", background: c.dot, border: "2px solid " + ring } })]);
|
|
const expandedRow = (c) => h("div", {
|
|
key: c.id,
|
|
onClick: () => onSelect(c.id),
|
|
style: { position: "relative", display: "flex", alignItems: "center", gap: "12px", padding: "11px 12px", marginBottom: "4px", borderRadius: "11px", background: c.active ? "#161618" : "transparent", border: "1px solid " + (c.active ? "rgba(255,255,255,0.08)" : "transparent"), cursor: "pointer" }
|
|
}, [
|
|
c.active && h("span", { key: "bar", style: { position: "absolute", left: 0, top: "12px", bottom: "12px", width: "3px", borderRadius: "0 3px 3px 0", background: "#f0892a" } }),
|
|
avatar(c, 38, c.active ? "#161618" : "#0c0c0e"),
|
|
h("div", { key: "body", style: { flex: 1, minWidth: 0 } }, [
|
|
h("div", { key: "top", style: { display: "flex", alignItems: "center", gap: "7px" } }, [
|
|
editingId === c.id ? renameInput() : h("span", {
|
|
key: "name",
|
|
onDoubleClick: startEdit(c),
|
|
title: "Double-click to rename",
|
|
style: { flex: 1, minWidth: 0, fontSize: "14px", fontWeight: c.active ? 700 : 600, letterSpacing: "-0.2px", color: c.active ? "#f4f4f6" : "#cfcfd4", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }
|
|
}, c.name),
|
|
c.unread && editingId !== c.id && h("span", { key: "u", style: { flex: "none", minWidth: "18px", height: "18px", padding: "0 5px", borderRadius: "9px", display: "grid", placeItems: "center", background: "#f0892a", color: "#1a0f04", fontFamily: "'JetBrains Mono',monospace", fontSize: "10px", fontWeight: 700 } }, c.unread)
|
|
]),
|
|
h("div", { key: "prev", style: { fontSize: "12px", color: c.active ? "#8a8a92" : "#6b6b73", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, c.preview)
|
|
])
|
|
]);
|
|
const dockItem = (c) => h("div", { key: c.id, style: { position: "relative" } }, [
|
|
c.active && h("span", { key: "bar", style: { position: "absolute", left: "-13px", top: "9px", bottom: "9px", width: "3px", borderRadius: "0 3px 3px 0", background: "#f0892a" } }),
|
|
h("div", {
|
|
key: "tile",
|
|
onClick: () => onSelect(c.id),
|
|
title: c.name,
|
|
style: { position: "relative", width: "44px", height: "44px", borderRadius: "12px", display: "grid", placeItems: "center", cursor: "pointer", background: c.active ? "rgba(255,255,255,0.06)" : "rgba(255,255,255,0.03)", border: "1px solid rgba(255,255,255," + (c.active ? "0.14" : "0.07") + ")", fontSize: "13px", fontWeight: 700, letterSpacing: "-0.3px", color: c.active ? "#f4f4f6" : "#9a9aa2" }
|
|
}, [
|
|
c.mono,
|
|
h("span", { key: "dot", style: { position: "absolute", right: "-2px", bottom: "-2px", width: "11px", height: "11px", borderRadius: "50%", background: c.dot, border: "2.5px solid #0c0c0e" } }),
|
|
c.unread && h("span", { key: "u", style: { position: "absolute", left: "-5px", top: "-5px", minWidth: "17px", height: "17px", padding: "0 4px", borderRadius: "9px", display: "grid", placeItems: "center", background: "#f0892a", color: "#1a0f04", fontFamily: "'JetBrains Mono',monospace", fontSize: "9.5px", fontWeight: 700, border: "2px solid #0c0c0e" } }, c.unread)
|
|
])
|
|
]);
|
|
const brandMark = (size) => h(
|
|
"div",
|
|
{ style: { width: size + "px", height: size + "px", flex: "none", display: "grid", placeItems: "center" } },
|
|
h("img", { src: "/logo/securebit-mark.svg", alt: "SecureBit", style: { width: "100%", height: "100%", objectFit: "contain", display: "block" } })
|
|
);
|
|
const collapseBtn = (svg, title) => h("button", { className: "sb-collapse-btn", onClick: onToggleCollapse, title, style: { width: "30px", height: "30px", borderRadius: "8px", display: "grid", placeItems: "center", border: "1px solid rgba(255,255,255,0.07)", background: "transparent", color: "#8a8a92", cursor: "pointer" }, dangerouslySetInnerHTML: { __html: svg } });
|
|
const myMeta = MY_STATUS_OPTIONS.find((o) => o.key === myStatus) || MY_STATUS_OPTIONS[0];
|
|
const PRES_SVG = {
|
|
user: '<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M18 20v-1.5a4 4 0 0 0-4-4h-4a4 4 0 0 0-4 4V20"/><circle cx="12" cy="8" r="3.6"/></svg>',
|
|
check: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#f0892a" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round"><path d="M5 13l4 4 10-11"/></svg>',
|
|
chevUp: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#6b6b73" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 15l6-6 6 6"/></svg>',
|
|
lock: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#3ecf8e" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M7 11V7a5 5 0 0 1 10 0v4"/><rect x="4.5" y="11" width="15" height="9" rx="2.2"/></svg>'
|
|
};
|
|
const presenceMenu = (pos) => presenceOpen ? h("div", {
|
|
key: "pmenu",
|
|
style: Object.assign({ position: "absolute", zIndex: 30, borderRadius: "14px", background: "#161618", border: "1px solid rgba(255,255,255,0.1)", boxShadow: "0 16px 40px rgba(0,0,0,0.55)", padding: "6px" }, pos)
|
|
}, [
|
|
h("div", { key: "h", style: { padding: "9px 10px 7px", fontFamily: "'JetBrains Mono',monospace", fontSize: "10px", fontWeight: 600, color: "#56565e", textTransform: "uppercase", letterSpacing: "1.2px" } }, "Set your status"),
|
|
...MY_STATUS_OPTIONS.map((o) => h("button", {
|
|
key: o.key,
|
|
onClick: () => {
|
|
onSetStatus(o.key);
|
|
setPresenceOpen(false);
|
|
},
|
|
style: { width: "100%", display: "flex", alignItems: "center", gap: "11px", padding: "9px 10px", borderRadius: "9px", border: "none", background: "transparent", cursor: "pointer", textAlign: "left" }
|
|
}, [
|
|
h("span", { key: "d", style: { flex: "none", width: "10px", height: "10px", borderRadius: "50%", background: o.dot } }),
|
|
h("span", { key: "t", style: { flex: 1, minWidth: 0 } }, [
|
|
h("span", { key: "w", style: { display: "block", fontSize: "13.5px", fontWeight: 600, color: "#e8e8eb" } }, o.word),
|
|
h("span", { key: "de", style: { display: "block", fontSize: "11.5px", color: "#6b6b73" } }, o.desc)
|
|
]),
|
|
o.key === myStatus && h("span", { key: "c", style: { flex: "none", display: "grid", placeItems: "center" }, dangerouslySetInnerHTML: { __html: PRES_SVG.check } })
|
|
])),
|
|
h("div", { key: "note", style: { display: "flex", alignItems: "flex-start", gap: "8px", margin: "6px 6px 4px", padding: "9px 10px", borderRadius: "9px", background: "rgba(62,207,142,0.06)", border: "1px solid rgba(62,207,142,0.16)" } }, [
|
|
h("span", { key: "i", style: { flex: "none", marginTop: "1px", display: "grid" }, dangerouslySetInnerHTML: { __html: PRES_SVG.lock } }),
|
|
h("span", { key: "t", style: { fontSize: "11px", lineHeight: 1.45, color: "#8a8a92" } }, "Sent end-to-end to connected peers only \u2014 never stored on a server.")
|
|
])
|
|
]) : null;
|
|
const presencePanelExpanded = h("div", { key: "you", style: { flex: "none", position: "relative", marginTop: "10px", borderTop: "1px solid rgba(255,255,255,0.06)", padding: "10px 12px 12px" } }, [
|
|
presenceMenu({ left: "12px", right: "12px", bottom: "64px" }),
|
|
h("button", { key: "btn", onClick: () => setPresenceOpen((v) => !v), style: { width: "100%", display: "flex", alignItems: "center", gap: "11px", padding: "7px 8px", borderRadius: "11px", border: "1px solid rgba(255,255,255,0.06)", background: "rgba(255,255,255,0.02)", cursor: "pointer" } }, [
|
|
h("div", { key: "av", style: { position: "relative", flex: "none", width: "36px", height: "36px", borderRadius: "10px", display: "grid", placeItems: "center", background: "rgba(240,137,42,0.12)", border: "1px solid rgba(240,137,42,0.24)", color: "#f0892a" } }, [
|
|
h("span", { key: "i", style: { display: "grid" }, dangerouslySetInnerHTML: { __html: PRES_SVG.user } }),
|
|
h("span", { key: "dot", style: { position: "absolute", right: "-2px", bottom: "-2px", width: "11px", height: "11px", borderRadius: "50%", background: myMeta.dot, border: "2px solid #0c0c0e" } })
|
|
]),
|
|
h("div", { key: "tx", style: { flex: 1, minWidth: 0, textAlign: "left" } }, [
|
|
h("div", { key: "y", style: { fontSize: "13.5px", fontWeight: 700, color: "#f4f4f6" } }, "You"),
|
|
h("div", { key: "w", style: { fontSize: "12px", color: "#8a8a92" } }, myMeta.word)
|
|
]),
|
|
h("span", { key: "ch", style: { display: "grid", placeItems: "center" }, dangerouslySetInnerHTML: { __html: PRES_SVG.chevUp } })
|
|
])
|
|
]);
|
|
const presencePanelCollapsed = h("div", { key: "you", style: { flex: "none", position: "relative", display: "flex", flexDirection: "column", alignItems: "center", padding: "0 0 13px" } }, [
|
|
presenceMenu({ left: "60px", bottom: "8px", width: "248px" }),
|
|
h("button", { key: "btn", onClick: () => setPresenceOpen((v) => !v), title: "Your status \u2014 " + myMeta.word, style: { position: "relative", width: "44px", height: "44px", borderRadius: "12px", display: "grid", placeItems: "center", cursor: "pointer", background: "rgba(240,137,42,0.12)", border: "1px solid rgba(240,137,42,0.24)", color: "#f0892a" } }, [
|
|
h("span", { key: "i", style: { display: "grid" }, dangerouslySetInnerHTML: { __html: PRES_SVG.user } }),
|
|
h("span", { key: "dot", style: { position: "absolute", right: "-2px", bottom: "-2px", width: "12px", height: "12px", borderRadius: "50%", background: myMeta.dot, border: "2.5px solid #0c0c0e" } })
|
|
])
|
|
]);
|
|
const expandedInner = [
|
|
h("div", { key: "head", style: { flex: "none", display: "flex", alignItems: "center", justifyContent: "space-between", padding: "0 12px 0 16px", height: "64px", borderBottom: "1px solid rgba(255,255,255,0.06)" } }, [
|
|
h("div", { key: "brand", style: { display: "flex", alignItems: "center", gap: "10px" } }, [brandMark(30), h("span", { key: "t", style: { fontSize: "15px", fontWeight: 800, letterSpacing: "-0.3px", color: "#f4f4f6" } }, "SecureBit")]),
|
|
collapseBtn(SB_SVG.chevL, "Collapse")
|
|
]),
|
|
h("div", { key: "label", style: { flex: "none", display: "flex", alignItems: "center", justifyContent: "space-between", padding: "16px 16px 9px" } }, [
|
|
h("span", { key: "l", style: { fontFamily: "'JetBrains Mono',monospace", fontSize: "10px", fontWeight: 600, color: "#56565e", textTransform: "uppercase", letterSpacing: "1.3px" } }, "Chats"),
|
|
h("span", { key: "c", style: { fontFamily: "'JetBrains Mono',monospace", fontSize: "10px", fontWeight: 600, color: "#6b6b73" } }, String(chats.length))
|
|
]),
|
|
h("div", { key: "list", className: "msc-scroll", style: { flex: 1, overflowY: "auto", padding: "0 10px" } }, [
|
|
...chats.map(expandedRow),
|
|
h("div", { key: "gh", style: { marginTop: "14px", padding: "0 2px 6px" } }, h("span", { style: { fontFamily: "'JetBrains Mono',monospace", fontSize: "10px", fontWeight: 600, color: "#56565e", textTransform: "uppercase", letterSpacing: "1.3px" } }, "Group chats")),
|
|
h("div", { key: "gph", title: "Coming in v6.0", style: { display: "flex", alignItems: "center", gap: "12px", padding: "11px 12px", borderRadius: "11px", background: "transparent", border: "1px dashed rgba(255,255,255,0.09)", cursor: "not-allowed" } }, [
|
|
h("div", { key: "i", style: { flex: "none", width: "38px", height: "38px", borderRadius: "11px", display: "grid", placeItems: "center", background: "rgba(255,255,255,0.025)", border: "1px solid rgba(255,255,255,0.06)", color: "#56565e" }, dangerouslySetInnerHTML: { __html: SB_SVG.users } }),
|
|
h("div", { key: "b", style: { flex: 1, minWidth: 0 } }, [
|
|
h("div", { key: "t", style: { fontSize: "14px", fontWeight: 600, color: "#8a8a92" } }, "Group chats"),
|
|
h("div", { key: "s", style: { fontSize: "11.5px", color: "#56565e" } }, "Up to 8 peers \xB7 P2P mesh")
|
|
]),
|
|
h("span", { key: "soon", style: { flex: "none", padding: "4px 9px", borderRadius: "7px", background: "rgba(240,137,42,0.1)", border: "1px solid rgba(240,137,42,0.24)", fontFamily: "'JetBrains Mono',monospace", fontSize: "9.5px", fontWeight: 700, color: "#f0892a", textTransform: "uppercase", letterSpacing: "0.8px" } }, "Soon")
|
|
])
|
|
]),
|
|
h("div", { key: "new", style: { flex: "none", padding: "12px" } }, h("button", {
|
|
onClick: onNewChat,
|
|
style: { width: "100%", display: "inline-flex", alignItems: "center", justifyContent: "center", gap: "9px", padding: "12px", borderRadius: "11px", border: "none", background: "#f0892a", color: "#1a0f04", fontFamily: "inherit", fontSize: "14px", fontWeight: 700, cursor: "pointer", boxShadow: "0 8px 24px rgba(240,137,42,0.28)" }
|
|
}, [icon(SB_SVG.plus, { key: "p" }), "New chat"])),
|
|
presencePanelExpanded
|
|
];
|
|
const collapsedInner = [
|
|
h("div", { key: "head", style: { flex: "none", display: "flex", flexDirection: "column", alignItems: "center", gap: "10px", padding: "13px 0", width: "100%", borderBottom: "1px solid rgba(255,255,255,0.06)" } }, [brandMark(32), collapseBtn(SB_SVG.chevR, "Expand")]),
|
|
h("div", { key: "list", className: "msc-scroll", style: { flex: 1, overflowY: "auto", display: "flex", flexDirection: "column", alignItems: "center", gap: "10px", padding: "14px 0", width: "100%" } }, [
|
|
...chats.map(dockItem),
|
|
h("div", { key: "sep", style: { width: "30px", height: "1px", background: "rgba(255,255,255,0.07)", margin: "2px 0" } }),
|
|
h("div", { key: "gph", title: "Group chats \u2014 coming in v6.0", style: { position: "relative", width: "44px", height: "44px", borderRadius: "12px", display: "grid", placeItems: "center", cursor: "not-allowed", background: "transparent", border: "1px dashed rgba(255,255,255,0.1)", color: "#56565e" }, dangerouslySetInnerHTML: { __html: SB_SVG.users } })
|
|
]),
|
|
h("div", { key: "new", style: { flex: "none", padding: "13px 0" } }, h("button", {
|
|
onClick: onNewChat,
|
|
title: "New chat",
|
|
style: { width: "44px", height: "44px", borderRadius: "12px", display: "grid", placeItems: "center", border: "none", background: "#f0892a", color: "#1a0f04", cursor: "pointer", boxShadow: "0 8px 24px rgba(240,137,42,0.28)" },
|
|
dangerouslySetInnerHTML: { __html: SB_SVG.plus }
|
|
})),
|
|
presencePanelCollapsed
|
|
];
|
|
const railWidth = collapsed ? "72px" : "292px";
|
|
const railStyle = { flex: "none", width: railWidth, display: "flex", flexDirection: "column", alignItems: collapsed ? "center" : "stretch", background: "#0c0c0e", borderRight: "1px solid rgba(255,255,255,0.06)" };
|
|
const inner = collapsed ? collapsedInner : expandedInner;
|
|
return h(React.Fragment, null, [
|
|
// Responsive behaviour (inline styles can't express media queries).
|
|
h("style", { key: "css", dangerouslySetInnerHTML: { __html: "@media (max-width:1023px){.sb-rail{display:none !important;}.sb-burger{display:grid !important;}}@media (min-width:1024px){.sb-drawer-overlay{display:none !important;}}.sb-mobile-drawer .sb-collapse-btn{display:none !important;}html,body{background:#0f0f11 !important;overscroll-behavior:none;}.sb-app-shell{height:var(--sb-vh,100dvh) !important;}.sb-app-col{height:var(--sb-vh,100dvh) !important;}.chat-container{height:var(--sb-vh,100dvh) !important;}@media (max-width:768px){textarea,input,select{font-size:16px !important;}}@media (max-width:768px){.sb-rename-btn{display:none !important;}}" } }),
|
|
// Desktop rail
|
|
h("aside", { key: "rail", className: "sb-rail", style: railStyle }, inner),
|
|
// Mobile drawer overlay
|
|
h("div", {
|
|
key: "drawer",
|
|
className: "sb-drawer-overlay",
|
|
onClick: onCloseDrawer,
|
|
style: { position: "fixed", inset: 0, zIndex: 60, background: "rgba(6,6,8,0.6)", backdropFilter: "blur(4px)", WebkitBackdropFilter: "blur(4px)", display: drawerOpen ? "block" : "none" }
|
|
}, h("aside", { className: "sb-mobile-drawer", onClick: (e) => e.stopPropagation(), style: { position: "absolute", left: 0, top: 0, bottom: 0, width: "min(292px, 86vw)", display: "flex", flexDirection: "column", background: "#0c0c0e", borderRight: "1px solid rgba(255,255,255,0.06)", boxShadow: "0 0 60px rgba(0,0,0,0.6)" } }, [
|
|
// Explicit close button — the drawer's own header only has a
|
|
// "collapse" chevron (a desktop-rail action), so on mobile there was
|
|
// no obvious way to dismiss it. This X closes the drawer reliably.
|
|
h("button", { key: "x", onClick: onCloseDrawer, title: "Close menu", "aria-label": "Close menu", style: { position: "absolute", top: "15px", right: "13px", zIndex: 2, width: "34px", height: "34px", borderRadius: "9px", display: "grid", placeItems: "center", border: "1px solid rgba(255,255,255,0.1)", background: "rgba(255,255,255,0.05)", color: "#cfcfd4", cursor: "pointer" } }, h("i", { className: "fas fa-xmark", style: { fontSize: "16px" } })),
|
|
expandedInner
|
|
]))
|
|
]);
|
|
};
|
|
var EnhancedSecureP2PChat = () => {
|
|
const [sessionsState, dispatch] = React.useReducer(sessionsReducer, void 0, createInitialState);
|
|
const activeSessionId = sessionsState.activeSessionId;
|
|
const activeIdRef = React.useRef(null);
|
|
activeIdRef.current = activeSessionId;
|
|
const active = activeSessionId ? sessionsState.sessions[activeSessionId] : null;
|
|
const EMPTY_ARR = React.useRef([]).current;
|
|
const managersRef = React.useRef(/* @__PURE__ */ new Map());
|
|
const integrationsRef = React.useRef(/* @__PURE__ */ new Map());
|
|
const queuesRef = React.useRef(/* @__PURE__ */ new Map());
|
|
const dispatchActive = React.useCallback((build) => {
|
|
const id = activeIdRef.current;
|
|
if (!id) return;
|
|
dispatch(build(id));
|
|
}, []);
|
|
const messages = active ? active.messages : EMPTY_ARR;
|
|
const setMessages = React.useCallback((updaterOrArr) => {
|
|
const id = activeIdRef.current;
|
|
if (!id) return;
|
|
if (typeof updaterOrArr === "function") dispatch({ type: SESSION_ACTIONS.SET_MESSAGES, id, updater: updaterOrArr });
|
|
else dispatch({ type: SESSION_ACTIONS.SET_MESSAGES, id, messages: updaterOrArr });
|
|
}, []);
|
|
const connectionStatus = active ? active.status : "disconnected";
|
|
const setConnectionStatus = React.useCallback((status) => dispatchActive((id) => ({ type: SESSION_ACTIONS.SET_STATUS, id, status })), [dispatchActive]);
|
|
const keyFingerprint = active ? active.keyFingerprint : "";
|
|
const setKeyFingerprint = React.useCallback((fingerprint) => dispatchActive((id) => ({ type: SESSION_ACTIONS.SET_FINGERPRINT, id, fingerprint })), [dispatchActive]);
|
|
const verificationCode = active ? active.verificationCode : "";
|
|
const setVerificationCode = React.useCallback((code) => dispatchActive((id) => ({ type: SESSION_ACTIONS.SET_VERIFICATION, id, code })), [dispatchActive]);
|
|
const isVerified = active ? active.sas.isVerified : false;
|
|
const setIsVerified = React.useCallback((v) => dispatchActive((id) => ({ type: SESSION_ACTIONS.SET_SAS, id, sas: { isVerified: !!v } })), [dispatchActive]);
|
|
const localVerificationConfirmed = active ? active.sas.localConfirmed : false;
|
|
const setLocalVerificationConfirmed = React.useCallback((v) => dispatchActive((id) => ({ type: SESSION_ACTIONS.SET_SAS, id, sas: { localConfirmed: !!v } })), [dispatchActive]);
|
|
const remoteVerificationConfirmed = active ? active.sas.remoteConfirmed : false;
|
|
const setRemoteVerificationConfirmed = React.useCallback((v) => dispatchActive((id) => ({ type: SESSION_ACTIONS.SET_SAS, id, sas: { remoteConfirmed: !!v } })), [dispatchActive]);
|
|
const bothVerificationsConfirmed = active ? active.sas.bothConfirmed : false;
|
|
const setBothVerificationsConfirmed = React.useCallback((v) => dispatchActive((id) => ({ type: SESSION_ACTIONS.SET_SAS, id, sas: { bothConfirmed: !!v } })), [dispatchActive]);
|
|
const pendingIncomingFiles = active ? active.pendingIncomingFiles : EMPTY_ARR;
|
|
const setPendingIncomingFiles = React.useCallback((updaterOrArr) => {
|
|
const id = activeIdRef.current;
|
|
if (!id) return;
|
|
if (typeof updaterOrArr === "function") dispatch({ type: SESSION_ACTIONS.SET_PENDING_FILES, id, updater: updaterOrArr });
|
|
else dispatch({ type: SESSION_ACTIONS.SET_PENDING_FILES, id, files: updaterOrArr });
|
|
}, []);
|
|
const setupField = (name, fallback) => active ? active.setup[name] : fallback;
|
|
const setSetupField = (name) => React.useCallback((value) => dispatchActive((id) => ({ type: SESSION_ACTIONS.PATCH_SETUP, id, patch: { [name]: value } })), [dispatchActive]);
|
|
const offerData = setupField("offerData", "");
|
|
const setOfferData = setSetupField("offerData");
|
|
const answerData = setupField("answerData", "");
|
|
const setAnswerData = setSetupField("answerData");
|
|
const offerInput = setupField("offerInput", "");
|
|
const setOfferInput = setSetupField("offerInput");
|
|
const answerInput = setupField("answerInput", "");
|
|
const setAnswerInput = setSetupField("answerInput");
|
|
const showOfferStep = setupField("showOfferStep", false);
|
|
const setShowOfferStep = setSetupField("showOfferStep");
|
|
const showAnswerStep = setupField("showAnswerStep", false);
|
|
const setShowAnswerStep = setSetupField("showAnswerStep");
|
|
const showVerification = setupField("showVerification", false);
|
|
const setShowVerification = setSetupField("showVerification");
|
|
const showQRCode = setupField("showQRCode", false);
|
|
const setShowQRCode = setSetupField("showQRCode");
|
|
const qrCodeUrl = setupField("qrCodeUrl", "");
|
|
const setQrCodeUrl = setSetupField("qrCodeUrl");
|
|
const isGeneratingKeys = setupField("isGeneratingKeys", false);
|
|
const setIsGeneratingKeys = setSetupField("isGeneratingKeys");
|
|
const webrtcManagerRef = React.useMemo(() => ({
|
|
get current() {
|
|
return managersRef.current.get(activeIdRef.current) || null;
|
|
},
|
|
set current(v) {
|
|
const id = activeIdRef.current;
|
|
if (!id) return;
|
|
if (v) managersRef.current.set(id, v);
|
|
else managersRef.current.delete(id);
|
|
}
|
|
}), []);
|
|
const notificationIntegrationRef = React.useMemo(() => ({
|
|
get current() {
|
|
return integrationsRef.current.get(activeIdRef.current) || null;
|
|
},
|
|
set current(v) {
|
|
const id = activeIdRef.current;
|
|
if (!id) return;
|
|
if (v) integrationsRef.current.set(id, v);
|
|
else integrationsRef.current.delete(id);
|
|
}
|
|
}), []);
|
|
const [myStatus, setMyStatusState] = React.useState(() => {
|
|
try {
|
|
return localStorage.getItem("securebit_my_status") || "available";
|
|
} catch {
|
|
return "available";
|
|
}
|
|
});
|
|
const myStatusRef = React.useRef(myStatus);
|
|
myStatusRef.current = myStatus;
|
|
const wirePresence = (s) => s === "invisible" ? "offline" : s;
|
|
const sendPresenceTo = React.useCallback((mgr, s) => {
|
|
if (!mgr || typeof mgr.sendMessage !== "function") return;
|
|
try {
|
|
if (mgr.isConnected && mgr.isConnected()) {
|
|
const p = mgr.sendMessage(JSON.stringify({ type: "presence", status: wirePresence(s) }));
|
|
if (p && typeof p.catch === "function") p.catch(() => {
|
|
});
|
|
}
|
|
} catch (_) {
|
|
}
|
|
}, []);
|
|
const setMyStatus = React.useCallback((key) => {
|
|
setMyStatusState(key);
|
|
try {
|
|
localStorage.setItem("securebit_my_status", key);
|
|
} catch {
|
|
}
|
|
for (const mgr of managersRef.current.values()) sendPresenceTo(mgr, key);
|
|
}, [sendPresenceTo]);
|
|
const [codeMode, setCodeMode] = React.useState(false);
|
|
const [viewOnceMode, setViewOnceMode] = React.useState(false);
|
|
const [viewOnceTtl, setViewOnceTtl] = React.useState(15);
|
|
const [disappearTtl, setDisappearTtl] = React.useState(0);
|
|
const [nowTick, setNowTick] = React.useState(() => Date.now());
|
|
const [isOffline, setIsOffline] = React.useState(typeof navigator !== "undefined" && navigator.onLine === false);
|
|
const offlineRef = React.useRef(isOffline);
|
|
React.useEffect(() => {
|
|
offlineRef.current = isOffline;
|
|
}, [isOffline]);
|
|
React.useEffect(() => {
|
|
const goOffline = () => setIsOffline(true);
|
|
const goOnline = () => setIsOffline(false);
|
|
window.addEventListener("offline", goOffline);
|
|
window.addEventListener("online", goOnline);
|
|
return () => {
|
|
window.removeEventListener("offline", goOffline);
|
|
window.removeEventListener("online", goOnline);
|
|
};
|
|
}, []);
|
|
React.useEffect(() => {
|
|
const vv = typeof window !== "undefined" ? window.visualViewport : null;
|
|
const apply = () => {
|
|
const h = vv ? vv.height : window.innerHeight || 0;
|
|
if (h) document.documentElement.style.setProperty("--sb-vh", h + "px");
|
|
};
|
|
apply();
|
|
if (vv) {
|
|
vv.addEventListener("resize", apply);
|
|
vv.addEventListener("scroll", apply);
|
|
}
|
|
window.addEventListener("resize", apply);
|
|
window.addEventListener("orientationchange", apply);
|
|
return () => {
|
|
if (vv) {
|
|
vv.removeEventListener("resize", apply);
|
|
vv.removeEventListener("scroll", apply);
|
|
}
|
|
window.removeEventListener("resize", apply);
|
|
window.removeEventListener("orientationchange", apply);
|
|
};
|
|
}, []);
|
|
const [relayOnlyMode, setRelayOnlyMode] = React.useState(() => {
|
|
try {
|
|
return localStorage.getItem("securebit_relay_only_mode") === "true";
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
const [customIceServers, setCustomIceServers] = React.useState(null);
|
|
const [iceServersText, setIceServersText] = React.useState("");
|
|
const [iceSettingsPersisted, setIceSettingsPersisted] = React.useState(false);
|
|
const [showIceSettings, setShowIceSettings] = React.useState(false);
|
|
React.useEffect(() => {
|
|
let cancelled = false;
|
|
loadIceSettings().then((saved) => {
|
|
if (cancelled || !saved) return;
|
|
if (Array.isArray(saved.servers) && saved.servers.length > 0) {
|
|
setCustomIceServers(saved.servers);
|
|
setIceServersText(JSON.stringify(saved.servers, null, 2));
|
|
}
|
|
if (saved.privacyMode === "relay-only") {
|
|
setRelayOnlyMode(true);
|
|
}
|
|
setIceSettingsPersisted(true);
|
|
}).catch(() => {
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
React.useEffect(() => {
|
|
const open = () => setShowIceSettings(true);
|
|
window.addEventListener("securebit:open-network-settings", open);
|
|
return () => window.removeEventListener("securebit:open-network-settings", open);
|
|
}, []);
|
|
const handleApplyIceSettings = React.useCallback((next, persist) => {
|
|
const servers = next.useCustom && Array.isArray(next.servers) ? next.servers : null;
|
|
setCustomIceServers(servers && servers.length ? servers : null);
|
|
setIceServersText(next.serversText || "");
|
|
setRelayOnlyMode(next.privacyMode === "relay-only");
|
|
setShowIceSettings(false);
|
|
if (persist) {
|
|
setIceSettingsPersisted(true);
|
|
saveIceSettings({ servers: servers || [], privacyMode: next.privacyMode }).catch(() => {
|
|
});
|
|
} else if (iceSettingsPersisted) {
|
|
setIceSettingsPersisted(false);
|
|
clearIceSettings().catch(() => {
|
|
});
|
|
}
|
|
}, [iceSettingsPersisted]);
|
|
const handleForgetIceSettings = React.useCallback(async () => {
|
|
await clearIceSettings().catch(() => {
|
|
});
|
|
setIceSettingsPersisted(false);
|
|
setCustomIceServers(null);
|
|
setIceServersText("");
|
|
}, []);
|
|
const [messageInput, setMessageInput] = React.useState("");
|
|
const [showQRScanner, setShowQRScanner] = React.useState(false);
|
|
const [showQRScannerModal, setShowQRScannerModal] = React.useState(false);
|
|
const [securityLevel, setSecurityLevel] = React.useState(null);
|
|
const [sessionTimeLeft, setSessionTimeLeft] = React.useState(0);
|
|
const [pendingSession, setPendingSession] = React.useState(null);
|
|
const [connectionState, setConnectionState] = React.useState({
|
|
status: "disconnected",
|
|
hasActiveAnswer: false,
|
|
answerCreatedAt: null,
|
|
isUserInitiatedDisconnect: false
|
|
});
|
|
const updateConnectionState = (newState, options = {}) => {
|
|
const { preserveAnswer = false, isUserAction = false } = options;
|
|
setConnectionState((prev) => ({
|
|
...prev,
|
|
...newState,
|
|
isUserInitiatedDisconnect: isUserAction,
|
|
hasActiveAnswer: preserveAnswer ? prev.hasActiveAnswer : false,
|
|
answerCreatedAt: preserveAnswer ? prev.answerCreatedAt : null
|
|
}));
|
|
};
|
|
const shouldPreserveAnswerData = () => {
|
|
const hasAnswerData = !!answerData || answerInput && typeof answerInput === "string" && answerInput.trim().length > 0;
|
|
const hasAnswerQR = qrCodeUrl && typeof qrCodeUrl === "string" && qrCodeUrl.trim().length > 0;
|
|
const shouldPreserve = connectionState.hasActiveAnswer && !connectionState.isUserInitiatedDisconnect || hasAnswerData && !connectionState.isUserInitiatedDisconnect || hasAnswerQR && !connectionState.isUserInitiatedDisconnect;
|
|
return shouldPreserve;
|
|
};
|
|
const markAnswerCreated = () => {
|
|
updateConnectionState({
|
|
hasActiveAnswer: true,
|
|
answerCreatedAt: Date.now()
|
|
});
|
|
};
|
|
React.useEffect(() => {
|
|
return installDebugWindowHooks({
|
|
targetWindow: window,
|
|
webrtcManagerRef,
|
|
onClearData: handleClearData
|
|
});
|
|
}, []);
|
|
const addMessageWithAutoScroll = React.useCallback((message, type, opts = {}) => {
|
|
const newId = Date.now() + Math.random();
|
|
const newMessage = {
|
|
message,
|
|
type,
|
|
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,
|
|
voice: opts.voice || void 0,
|
|
fileId: opts.fileId || void 0
|
|
};
|
|
setMessages((prev) => {
|
|
const updated = [...prev, newMessage];
|
|
setTimeout(() => {
|
|
if (chatMessagesRef?.current) {
|
|
const container = chatMessagesRef.current;
|
|
try {
|
|
const { scrollTop, scrollHeight, clientHeight } = container;
|
|
const isNearBottom = scrollHeight - scrollTop - clientHeight < 100;
|
|
if (isNearBottom || prev.length === 0) {
|
|
requestAnimationFrame(() => {
|
|
if (container && container.scrollTo) {
|
|
container.scrollTo({
|
|
top: container.scrollHeight,
|
|
behavior: "smooth"
|
|
});
|
|
}
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.warn("Scroll error:", error);
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
}
|
|
}, 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);
|
|
const out = q.outgoing;
|
|
q.outgoing = [];
|
|
for (const item of out) {
|
|
const send = mgr?.sendMessage?.(item.outText, item.meta);
|
|
if (send && typeof send.then === "function") {
|
|
send.then(() => dispatch({ type: SESSION_ACTIONS.UPDATE_MESSAGE_STATUS, id, mid: item.mid, status: "delivered" })).catch(() => dispatch({ type: SESSION_ACTIONS.UPDATE_MESSAGE_STATUS, id, mid: item.mid, status: "failed" }));
|
|
}
|
|
}
|
|
const inc = q.incoming;
|
|
q.incoming = [];
|
|
if (inc.length > 0) {
|
|
dispatch({ type: SESSION_ACTIONS.ADD_MESSAGE, id, message: buildSessionMessage(
|
|
`Connection restored \u2014 ${inc.length} message${inc.length === 1 ? "" : "s"} received while you were offline.`,
|
|
"notice"
|
|
) });
|
|
}
|
|
const viewing = id === activeIdRef.current && (typeof document === "undefined" || document.visibilityState === "visible");
|
|
for (const item of inc) {
|
|
dispatch({ type: SESSION_ACTIONS.ADD_MESSAGE, id, message: buildSessionMessage(item.message, item.type, item.opts) });
|
|
if (item.opts && item.opts.mid && item.type === "received") {
|
|
if (viewing) {
|
|
try {
|
|
mgr?.sendDeliveryReceipt?.(item.opts.mid);
|
|
} catch (_) {
|
|
}
|
|
} else if (q.pendingReadAcks) q.pendingReadAcks.push(item.opts.mid);
|
|
}
|
|
}
|
|
}
|
|
}, []);
|
|
React.useEffect(() => {
|
|
if (isOffline) return;
|
|
flushOfflineQueues();
|
|
}, [isOffline, flushOfflineQueues]);
|
|
const updateSecurityLevel = React.useCallback(async () => {
|
|
if (window.isUpdatingSecurity) {
|
|
return;
|
|
}
|
|
window.isUpdatingSecurity = true;
|
|
try {
|
|
if (webrtcManagerRef.current) {
|
|
setSecurityLevel({
|
|
level: "MAXIMUM",
|
|
score: 100,
|
|
color: "green",
|
|
details: "All security features enabled by default",
|
|
passedChecks: 10,
|
|
totalChecks: 10,
|
|
isRealData: true
|
|
});
|
|
if (window.DEBUG_MODE) {
|
|
const currentLevel = webrtcManagerRef.current.ecdhKeyPair && webrtcManagerRef.current.ecdsaKeyPair ? await webrtcManagerRef.current.calculateSecurityLevel() : {
|
|
level: "MAXIMUM",
|
|
score: 100,
|
|
sessionType: "premium",
|
|
passedChecks: 10,
|
|
totalChecks: 10
|
|
};
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to update security level:", error);
|
|
setSecurityLevel({
|
|
level: "ERROR",
|
|
score: 0,
|
|
color: "red",
|
|
details: "Verification failed"
|
|
});
|
|
} finally {
|
|
setTimeout(() => {
|
|
window.isUpdatingSecurity = false;
|
|
}, 2e3);
|
|
}
|
|
}, []);
|
|
const chatMessagesRef = React.useRef(null);
|
|
const scrollToBottom = createScrollToBottomFunction(chatMessagesRef);
|
|
React.useEffect(() => {
|
|
try {
|
|
localStorage.setItem("securebit_relay_only_mode", String(relayOnlyMode));
|
|
} catch {
|
|
}
|
|
if (webrtcManagerRef.current?._config?.webrtc) {
|
|
webrtcManagerRef.current._setRelayOnlyMode(relayOnlyMode);
|
|
}
|
|
}, [relayOnlyMode]);
|
|
React.useEffect(() => {
|
|
if (messages.length > 0 && chatMessagesRef.current) {
|
|
scrollToBottom();
|
|
setTimeout(scrollToBottom, 50);
|
|
setTimeout(scrollToBottom, 150);
|
|
}
|
|
}, [messages]);
|
|
const anyExpiring = sessionsState.order.some((id) => (sessionsState.sessions[id]?.messages || []).some((m) => typeof m.expiresAt === "number"));
|
|
const sessionsStateRef = React.useRef(sessionsState);
|
|
sessionsStateRef.current = sessionsState;
|
|
React.useEffect(() => {
|
|
if (!anyExpiring) return;
|
|
const expireFn = (prev) => {
|
|
const now = Date.now();
|
|
let changed = false;
|
|
const next = prev.map((m) => {
|
|
if (typeof m.expiresAt === "number" && m.expiresAt <= now && !m.expired) {
|
|
changed = true;
|
|
return { ...m, expired: true, message: "", expiresAt: void 0 };
|
|
}
|
|
return m;
|
|
});
|
|
return changed ? next : prev;
|
|
};
|
|
const interval = setInterval(() => {
|
|
const now = Date.now();
|
|
setNowTick(now);
|
|
const st = sessionsStateRef.current;
|
|
for (const id of st.order) {
|
|
const msgs = st.sessions[id]?.messages || [];
|
|
if (msgs.some((m) => typeof m.expiresAt === "number" && m.expiresAt <= now && !m.expired)) {
|
|
dispatch({ type: SESSION_ACTIONS.SET_MESSAGES, id, updater: expireFn });
|
|
}
|
|
}
|
|
}, 1e3);
|
|
return () => clearInterval(interval);
|
|
}, [anyExpiring]);
|
|
const createSession = (opts = {}) => {
|
|
const role = opts.role || "offer";
|
|
const entry = createSessionEntry({ role });
|
|
const id = entry.id;
|
|
dispatch({ type: SESSION_ACTIONS.CREATE_SESSION, entry, activate: opts.activate !== false });
|
|
queuesRef.current.set(id, { incoming: [], outgoing: [], pendingReadAcks: [] });
|
|
const setMessages2 = (u) => {
|
|
if (typeof u === "function") dispatch({ type: SESSION_ACTIONS.SET_MESSAGES, id, updater: u });
|
|
else dispatch({ type: SESSION_ACTIONS.SET_MESSAGES, id, messages: u });
|
|
};
|
|
const addMessageWithAutoScroll2 = (message, type, opts2 = {}) => {
|
|
dispatch({ type: SESSION_ACTIONS.ADD_MESSAGE, id, message: buildSessionMessage(message, type, opts2) });
|
|
if (type === "received" && id !== activeIdRef.current) {
|
|
dispatch({ type: SESSION_ACTIONS.INCREMENT_UNREAD, id });
|
|
}
|
|
};
|
|
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 });
|
|
const setIsVerified2 = (v) => dispatch({ type: SESSION_ACTIONS.SET_SAS, id, sas: { isVerified: !!v } });
|
|
const setLocalVerificationConfirmed2 = (v) => dispatch({ type: SESSION_ACTIONS.SET_SAS, id, sas: { localConfirmed: !!v } });
|
|
const setRemoteVerificationConfirmed2 = (v) => dispatch({ type: SESSION_ACTIONS.SET_SAS, id, sas: { remoteConfirmed: !!v } });
|
|
const setBothVerificationsConfirmed2 = (v) => dispatch({ type: SESSION_ACTIONS.SET_SAS, id, sas: { bothConfirmed: !!v } });
|
|
const setShowVerification2 = (v) => dispatch({ type: SESSION_ACTIONS.PATCH_SETUP, id, patch: { showVerification: !!v } });
|
|
const setShowOfferStep2 = (v) => dispatch({ type: SESSION_ACTIONS.PATCH_SETUP, id, patch: { showOfferStep: !!v } });
|
|
const setShowAnswerStep2 = (v) => dispatch({ type: SESSION_ACTIONS.PATCH_SETUP, id, patch: { showAnswerStep: !!v } });
|
|
const setShowQRCode2 = (v) => dispatch({ type: SESSION_ACTIONS.PATCH_SETUP, id, patch: { showQRCode: !!v } });
|
|
const setQrCodeUrl2 = (v) => dispatch({ type: SESSION_ACTIONS.PATCH_SETUP, id, patch: { qrCodeUrl: v } });
|
|
const setOfferData2 = (v) => dispatch({ type: SESSION_ACTIONS.PATCH_SETUP, id, patch: { offerData: v } });
|
|
const setAnswerData2 = (v) => dispatch({ type: SESSION_ACTIONS.PATCH_SETUP, id, patch: { answerData: v } });
|
|
const setOfferInput2 = (v) => dispatch({ type: SESSION_ACTIONS.PATCH_SETUP, id, patch: { offerInput: v } });
|
|
const setAnswerInput2 = (v) => dispatch({ type: SESSION_ACTIONS.PATCH_SETUP, id, patch: { answerInput: v } });
|
|
const setPendingIncomingFiles2 = (u) => {
|
|
if (typeof u === "function") dispatch({ type: SESSION_ACTIONS.SET_PENDING_FILES, id, updater: u });
|
|
else dispatch({ type: SESSION_ACTIONS.SET_PENDING_FILES, id, files: u });
|
|
};
|
|
const sessionQueues = () => queuesRef.current.get(id) || { incoming: [], outgoing: [] };
|
|
const handleMessage = (message, type, meta) => {
|
|
if (typeof message === "string" && message.trim().startsWith("{")) {
|
|
try {
|
|
const parsedMessage = JSON.parse(message);
|
|
if (parsedMessage.type === "presence") {
|
|
const st = parsedMessage.data && parsedMessage.data.status || parsedMessage.status;
|
|
if (st) dispatch({ type: SESSION_ACTIONS.SET_PEER_PRESENCE, id, presence: st });
|
|
return;
|
|
}
|
|
const blockedTypes = [
|
|
"file_transfer_start",
|
|
"file_transfer_response",
|
|
"file_chunk",
|
|
"chunk_confirmation",
|
|
"file_transfer_complete",
|
|
"file_transfer_error",
|
|
"heartbeat",
|
|
"verification",
|
|
"verification_response",
|
|
"verification_confirmed",
|
|
"verification_both_confirmed",
|
|
"peer_disconnect",
|
|
"key_rotation_signal",
|
|
"key_rotation_ready",
|
|
"security_upgrade",
|
|
"message_delete",
|
|
"message_receipt"
|
|
];
|
|
if (parsedMessage.type && blockedTypes.includes(parsedMessage.type)) {
|
|
console.log(`Blocked system/file message from chat: ${parsedMessage.type}`);
|
|
return;
|
|
}
|
|
if (parsedMessage.type === "message" && typeof parsedMessage.data === "string") {
|
|
message = parsedMessage.data;
|
|
if (parsedMessage.meta && typeof parsedMessage.meta === "object") meta = parsedMessage.meta;
|
|
}
|
|
} catch (parseError) {
|
|
}
|
|
}
|
|
const opts2 = {};
|
|
if (meta && typeof meta === "object") {
|
|
if (typeof meta.mid === "string") opts2.mid = meta.mid;
|
|
if (meta.once === true) {
|
|
opts2.viewOnce = true;
|
|
opts2.viewOnceTtl = Number.isFinite(meta.onceTtl) ? meta.onceTtl : 15;
|
|
}
|
|
if (Number.isFinite(meta.ttl) && meta.ttl > 0) {
|
|
opts2.expiresAt = Date.now() + meta.ttl * 1e3;
|
|
}
|
|
if (Number.isFinite(meta.ts)) opts2.timestamp = meta.ts;
|
|
}
|
|
if (offlineRef.current && type === "received") {
|
|
sessionQueues().incoming.push({ message, type, opts: opts2 });
|
|
return;
|
|
}
|
|
addMessageWithAutoScroll2(message, type, opts2);
|
|
if (opts2.mid && type === "received") {
|
|
const beingViewed = id === activeIdRef.current && (typeof document === "undefined" || document.visibilityState === "visible");
|
|
if (beingViewed) {
|
|
try {
|
|
manager?.sendDeliveryReceipt?.(opts2.mid);
|
|
} catch (_) {
|
|
}
|
|
} else {
|
|
const q = sessionQueues();
|
|
if (q.pendingReadAcks) q.pendingReadAcks.push(opts2.mid);
|
|
}
|
|
}
|
|
};
|
|
const handleStatusChange = (status) => {
|
|
setConnectionStatus2(status);
|
|
if (status === "connected") {
|
|
document.dispatchEvent(new CustomEvent("new-connection"));
|
|
if (!window.isUpdatingSecurity) {
|
|
updateSecurityLevel().catch(console.error);
|
|
}
|
|
} else if (status === "verifying") {
|
|
setShowVerification2(true);
|
|
if (!window.isUpdatingSecurity) {
|
|
updateSecurityLevel().catch(console.error);
|
|
}
|
|
} else if (status === "verified") {
|
|
setIsVerified2(true);
|
|
setShowVerification2(false);
|
|
setBothVerificationsConfirmed2(true);
|
|
setConnectionStatus2("connected");
|
|
setTimeout(() => {
|
|
setIsVerified2(true);
|
|
}, 0);
|
|
try {
|
|
const s = myStatusRef.current === "invisible" ? "offline" : myStatusRef.current;
|
|
setTimeout(() => {
|
|
try {
|
|
const p = manager.sendMessage?.(JSON.stringify({ type: "presence", status: s }));
|
|
if (p && typeof p.catch === "function") p.catch(() => {
|
|
});
|
|
} catch (_) {
|
|
}
|
|
}, 400);
|
|
} catch (_) {
|
|
}
|
|
if (!window.isUpdatingSecurity) {
|
|
updateSecurityLevel().catch(console.error);
|
|
}
|
|
} else if (status === "connecting") {
|
|
if (!window.isUpdatingSecurity) {
|
|
updateSecurityLevel().catch(console.error);
|
|
}
|
|
} else if (status === "disconnected") {
|
|
setConnectionStatus2("disconnected");
|
|
setIsVerified2(false);
|
|
setShowVerification2(false);
|
|
setLocalVerificationConfirmed2(false);
|
|
setRemoteVerificationConfirmed2(false);
|
|
setBothVerificationsConfirmed2(false);
|
|
if (id === activeIdRef.current) document.dispatchEvent(new CustomEvent("disconnected"));
|
|
} else if (status === "peer_disconnected") {
|
|
if (id === activeIdRef.current) {
|
|
setSessionTimeLeft(0);
|
|
document.dispatchEvent(new CustomEvent("peer-disconnect"));
|
|
}
|
|
setConnectionStatus2("peer_disconnected");
|
|
setIsVerified2(false);
|
|
setShowVerification2(false);
|
|
setLocalVerificationConfirmed2(false);
|
|
setRemoteVerificationConfirmed2(false);
|
|
setBothVerificationsConfirmed2(false);
|
|
}
|
|
};
|
|
const handleKeyExchange = (fingerprint) => {
|
|
if (fingerprint === "") {
|
|
setKeyFingerprint2("");
|
|
} else {
|
|
setKeyFingerprint2(fingerprint);
|
|
}
|
|
};
|
|
const handleVerificationRequired = (code) => {
|
|
if (code === "") {
|
|
setVerificationCode2("");
|
|
setShowVerification2(false);
|
|
} else {
|
|
setVerificationCode2(code);
|
|
setShowVerification2(true);
|
|
}
|
|
};
|
|
const handleVerificationStateChange = (state) => {
|
|
setLocalVerificationConfirmed2(state.localConfirmed);
|
|
setRemoteVerificationConfirmed2(state.remoteConfirmed);
|
|
setBothVerificationsConfirmed2(state.bothConfirmed);
|
|
};
|
|
const handleAnswerError = (errorType, errorMessage) => {
|
|
if (errorType === "replay_attack") {
|
|
setSessionTimeLeft(0);
|
|
setPendingSession(null);
|
|
addMessageWithAutoScroll2("\u{1F4A1} Data is outdated. Please create a new invitation or use a current response code.", "system");
|
|
if (typeof console.clear === "function") {
|
|
console.clear();
|
|
}
|
|
} else if (errorType === "security_violation") {
|
|
setSessionTimeLeft(0);
|
|
setPendingSession(null);
|
|
addMessageWithAutoScroll2(` Security breach: ${errorMessage}`, "system");
|
|
if (typeof console.clear === "function") {
|
|
console.clear();
|
|
}
|
|
}
|
|
};
|
|
if (typeof console.clear === "function") {
|
|
console.clear();
|
|
}
|
|
const manager = new EnhancedSecureWebRTCManager(
|
|
handleMessage,
|
|
handleStatusChange,
|
|
handleKeyExchange,
|
|
handleVerificationRequired,
|
|
handleAnswerError,
|
|
handleVerificationStateChange,
|
|
{
|
|
webrtc: {
|
|
relayOnly: relayOnlyMode,
|
|
// Priority: user's custom servers > operator override > built-in defaults.
|
|
iceServers: Array.isArray(customIceServers) && customIceServers.length ? customIceServers : Array.isArray(window.SECUREBIT_ICE_SERVERS) ? window.SECUREBIT_ICE_SERVERS : void 0
|
|
}
|
|
}
|
|
);
|
|
managersRef.current.set(id, manager);
|
|
manager.onMessageDelete = (mid) => {
|
|
if (!mid) return;
|
|
setMessages2((prev) => prev.filter((m) => String(m.mid) !== String(mid)));
|
|
};
|
|
manager.onMessageDelivered = (mid) => {
|
|
updateMessageStatus2(mid, "read");
|
|
};
|
|
if (typeof Notification !== "undefined" && Notification && Notification.permission === "granted" && window.NotificationIntegration && !integrationsRef.current.get(id)) {
|
|
try {
|
|
const integration = new window.NotificationIntegration(manager);
|
|
integration.init().then(() => {
|
|
integrationsRef.current.set(id, integration);
|
|
}).catch((error) => {
|
|
});
|
|
} catch (error) {
|
|
}
|
|
}
|
|
handleMessage(" SecureBit.chat Enhanced Security Edition v5.5.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");
|
|
manager.setFileTransferCallbacks(
|
|
// 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. 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();
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = fileData.fileName || "file";
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
setTimeout(() => fileData.revokeObjectURL(url), 15e3);
|
|
};
|
|
saveToDisk().then(() => {
|
|
addMessageWithAutoScroll2(`File received & saved: ${fileData.fileName} (${sizeMb} MB)`, "system");
|
|
}).catch((e) => {
|
|
console.error("Auto-save failed:", e);
|
|
addMessageWithAutoScroll2(`File received: ${fileData.fileName} (${sizeMb} MB). Open the file panel to download it.`, "system");
|
|
});
|
|
},
|
|
// Error callback
|
|
(error) => {
|
|
console.error("File transfer error:", error);
|
|
if (error.includes("Connection not ready")) {
|
|
addMessageWithAutoScroll2(` File transfer error: connection not ready. Try again later.`, "system");
|
|
} else if (error.includes("File too large")) {
|
|
addMessageWithAutoScroll2(` File is too big. Maximum size: 100 MB`, "system");
|
|
} else {
|
|
addMessageWithAutoScroll2(` File transfer error: ${error}`, "system");
|
|
}
|
|
},
|
|
// 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];
|
|
});
|
|
}
|
|
);
|
|
return id;
|
|
};
|
|
const createSessionRef = React.useRef(createSession);
|
|
createSessionRef.current = createSession;
|
|
const destroyingRef = React.useRef(/* @__PURE__ */ new Set());
|
|
const destroySession = React.useCallback((id) => {
|
|
if (!id || destroyingRef.current.has(id)) return;
|
|
destroyingRef.current.add(id);
|
|
try {
|
|
const mgr = managersRef.current.get(id);
|
|
if (mgr) {
|
|
try {
|
|
mgr.disconnect();
|
|
} catch (_) {
|
|
}
|
|
managersRef.current.delete(id);
|
|
}
|
|
const integ = integrationsRef.current.get(id);
|
|
if (integ) {
|
|
try {
|
|
integ.cleanup?.();
|
|
} catch (_) {
|
|
}
|
|
integrationsRef.current.delete(id);
|
|
}
|
|
queuesRef.current.delete(id);
|
|
dispatch({ type: SESSION_ACTIONS.REMOVE_SESSION, id });
|
|
} finally {
|
|
destroyingRef.current.delete(id);
|
|
}
|
|
}, []);
|
|
React.useEffect(() => {
|
|
if (sessionsState.order.length === 0) createSessionRef.current({ role: "offer" });
|
|
}, [sessionsState.order.length]);
|
|
const [sidebarCollapsed, setSidebarCollapsed] = React.useState(() => {
|
|
try {
|
|
return localStorage.getItem("securebit_sidebar_collapsed") === "true";
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
React.useEffect(() => {
|
|
try {
|
|
localStorage.setItem("securebit_sidebar_collapsed", String(sidebarCollapsed));
|
|
} catch {
|
|
}
|
|
}, [sidebarCollapsed]);
|
|
const [sidebarDrawerOpen, setSidebarDrawerOpen] = React.useState(false);
|
|
const handleSelectSession = React.useCallback((id) => {
|
|
dispatch({ type: SESSION_ACTIONS.SET_ACTIVE, id });
|
|
dispatch({ type: SESSION_ACTIONS.CLEAR_UNREAD, id });
|
|
setSidebarDrawerOpen(false);
|
|
}, []);
|
|
const handleNewChat = React.useCallback(() => {
|
|
createSessionRef.current({ role: "offer" });
|
|
setSidebarDrawerOpen(false);
|
|
}, []);
|
|
const handleRenameSession = React.useCallback((id, label) => {
|
|
dispatch({ type: SESSION_ACTIONS.RENAME, id, label });
|
|
}, []);
|
|
const flushReadAcks = React.useCallback((id) => {
|
|
if (!id) return;
|
|
const q = queuesRef.current.get(id);
|
|
const mgr = managersRef.current.get(id);
|
|
if (!q || !mgr || !q.pendingReadAcks || q.pendingReadAcks.length === 0) return;
|
|
const acks = q.pendingReadAcks;
|
|
q.pendingReadAcks = [];
|
|
for (const mid of acks) {
|
|
try {
|
|
mgr.sendDeliveryReceipt?.(mid);
|
|
} catch (_) {
|
|
}
|
|
}
|
|
}, []);
|
|
React.useEffect(() => {
|
|
if (!activeSessionId) return;
|
|
dispatch({ type: SESSION_ACTIONS.CLEAR_UNREAD, id: activeSessionId });
|
|
if (typeof document === "undefined" || document.visibilityState === "visible") flushReadAcks(activeSessionId);
|
|
}, [activeSessionId, flushReadAcks]);
|
|
const didInitRef = React.useRef(false);
|
|
React.useEffect(() => {
|
|
if (didInitRef.current) return;
|
|
didInitRef.current = true;
|
|
let isTabSwitching = false;
|
|
let tabSwitchTimeout = null;
|
|
const handleBeforeUnload = (event) => {
|
|
if (event.type === "beforeunload" && !isTabSwitching) {
|
|
for (const mgr of managersRef.current.values()) {
|
|
try {
|
|
if (mgr.isConnected && mgr.isConnected()) {
|
|
try {
|
|
mgr.sendSystemMessage({ type: "peer_disconnect", reason: "user_disconnect", timestamp: Date.now() });
|
|
} catch (_) {
|
|
}
|
|
setTimeout(() => {
|
|
try {
|
|
mgr.disconnect();
|
|
} catch (_) {
|
|
}
|
|
}, 100);
|
|
} else {
|
|
mgr.disconnect();
|
|
}
|
|
} catch (_) {
|
|
}
|
|
}
|
|
} else if (isTabSwitching) {
|
|
event.preventDefault();
|
|
event.returnValue = "";
|
|
}
|
|
};
|
|
const handleVisibilityChange = () => {
|
|
if (document.visibilityState === "hidden") {
|
|
isTabSwitching = true;
|
|
if (tabSwitchTimeout) clearTimeout(tabSwitchTimeout);
|
|
tabSwitchTimeout = setTimeout(() => {
|
|
isTabSwitching = false;
|
|
}, 5e3);
|
|
} else if (document.visibilityState === "visible") {
|
|
isTabSwitching = false;
|
|
if (tabSwitchTimeout) {
|
|
clearTimeout(tabSwitchTimeout);
|
|
tabSwitchTimeout = null;
|
|
}
|
|
flushReadAcks(activeIdRef.current);
|
|
}
|
|
};
|
|
window.addEventListener("beforeunload", handleBeforeUnload);
|
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
return () => {
|
|
window.removeEventListener("beforeunload", handleBeforeUnload);
|
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
if (tabSwitchTimeout) {
|
|
clearTimeout(tabSwitchTimeout);
|
|
tabSwitchTimeout = null;
|
|
}
|
|
for (const mgr of managersRef.current.values()) {
|
|
try {
|
|
mgr.disconnect();
|
|
} catch (_) {
|
|
}
|
|
}
|
|
managersRef.current.clear();
|
|
for (const integ of integrationsRef.current.values()) {
|
|
try {
|
|
integ.cleanup?.();
|
|
} catch (_) {
|
|
}
|
|
}
|
|
integrationsRef.current.clear();
|
|
queuesRef.current.clear();
|
|
};
|
|
}, []);
|
|
const compressOfferData = (offerData2) => {
|
|
try {
|
|
const offer = typeof offerData2 === "string" ? JSON.parse(offerData2) : offerData2;
|
|
const minimalOffer = {
|
|
type: offer.type,
|
|
version: offer.version,
|
|
timestamp: offer.timestamp,
|
|
sessionId: offer.sessionId,
|
|
connectionId: offer.connectionId,
|
|
verificationCode: offer.verificationCode,
|
|
salt: offer.salt,
|
|
// Use only key fingerprints instead of full keys
|
|
keyFingerprints: offer.keyFingerprints,
|
|
// Add a reference to get full data
|
|
fullDataAvailable: true,
|
|
compressionLevel: "minimal"
|
|
};
|
|
return JSON.stringify(minimalOffer);
|
|
} catch (error) {
|
|
console.error("Error compressing offer data:", error);
|
|
return offerData2;
|
|
}
|
|
};
|
|
const createQRReference = (offerData2) => {
|
|
try {
|
|
const referenceId = `offer_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
localStorage.setItem(`qr_offer_${referenceId}`, JSON.stringify(offerData2));
|
|
const qrReference = {
|
|
type: "secure_offer_reference",
|
|
referenceId,
|
|
timestamp: Date.now(),
|
|
message: "Scan this QR code and use the reference ID to get full offer data"
|
|
};
|
|
return JSON.stringify(qrReference);
|
|
} catch (error) {
|
|
console.error("Error creating QR reference:", error);
|
|
return null;
|
|
}
|
|
};
|
|
const createTemplateOffer = (offer) => {
|
|
const templateOffer = {
|
|
type: "enhanced_secure_offer_template",
|
|
version: "4.0",
|
|
sessionId: offer.sessionId,
|
|
connectionId: offer.connectionId,
|
|
verificationCode: offer.verificationCode,
|
|
timestamp: offer.timestamp,
|
|
// Avoid bulky fields (SDP, raw keys); keep only fingerprints and essentials
|
|
keyFingerprints: offer.keyFingerprints,
|
|
// Keep concise auth hints (omit large nonces)
|
|
authChallenge: offer?.authChallenge?.challenge,
|
|
// Optionally include a compact capability hint if small
|
|
capabilities: Array.isArray(offer.capabilities) && offer.capabilities.length <= 5 ? offer.capabilities : void 0
|
|
};
|
|
return templateOffer;
|
|
};
|
|
const MAX_QR_LEN = 800;
|
|
const BIN_MAX_QR_LEN = 400;
|
|
const [qrFramesTotal, setQrFramesTotal] = React.useState(0);
|
|
const [qrFrameIndex, setQrFrameIndex] = React.useState(0);
|
|
const [qrManualMode, setQrManualMode] = React.useState(false);
|
|
const qrAnimationRef = React.useRef({ timer: null, chunks: [], idx: 0, active: false });
|
|
React.useEffect(() => () => {
|
|
try {
|
|
if (qrAnimationRef.current && qrAnimationRef.current.timer) {
|
|
clearInterval(qrAnimationRef.current.timer);
|
|
qrAnimationRef.current.timer = null;
|
|
}
|
|
} catch {
|
|
}
|
|
}, [activeSessionId]);
|
|
const stopQrAnimation = () => {
|
|
try {
|
|
if (qrAnimationRef.current.timer) {
|
|
clearInterval(qrAnimationRef.current.timer);
|
|
}
|
|
} catch {
|
|
}
|
|
qrAnimationRef.current = { timer: null, chunks: [], idx: 0, active: false };
|
|
setQrFrameIndex(0);
|
|
setQrFramesTotal(0);
|
|
setQrManualMode(false);
|
|
};
|
|
const renderCurrent = async () => {
|
|
const { chunks, idx } = qrAnimationRef.current || {};
|
|
if (!chunks || !chunks.length) return;
|
|
const current = chunks[idx % chunks.length];
|
|
try {
|
|
const isDesktop = typeof window !== "undefined" && (window.innerWidth || 0) >= 1024;
|
|
const QR_SIZE = isDesktop ? 720 : 512;
|
|
const url = await (window.generateQRCode ? window.generateQRCode(current, { errorCorrectionLevel: "M", margin: 2, size: QR_SIZE }) : Promise.resolve(""));
|
|
if (url) setQrCodeUrl(url);
|
|
} catch (e) {
|
|
console.warn("Animated QR render error (current):", e);
|
|
}
|
|
setQrFrameIndex((qrAnimationRef.current?.idx || 0) % (qrAnimationRef.current?.chunks?.length || 1) + 1);
|
|
};
|
|
const renderAndAdvance = async () => {
|
|
await renderCurrent();
|
|
const len = qrAnimationRef.current?.chunks?.length || 0;
|
|
if (len > 0) {
|
|
const nextIdx = ((qrAnimationRef.current?.idx || 0) + 1) % len;
|
|
qrAnimationRef.current.idx = nextIdx;
|
|
setQrFrameIndex(nextIdx + 1);
|
|
}
|
|
};
|
|
const toggleQrManualMode = () => {
|
|
const newManualMode = !qrManualMode;
|
|
setQrManualMode(newManualMode);
|
|
if (newManualMode) {
|
|
if (qrAnimationRef.current.timer) {
|
|
clearInterval(qrAnimationRef.current.timer);
|
|
qrAnimationRef.current.timer = null;
|
|
}
|
|
console.log("QR Manual mode enabled - auto-scroll stopped");
|
|
} else {
|
|
if (qrAnimationRef.current.chunks.length > 1) {
|
|
const intervalMs = 3e3;
|
|
qrAnimationRef.current.active = true;
|
|
clearInterval(qrAnimationRef.current.timer);
|
|
qrAnimationRef.current.timer = setInterval(renderAndAdvance, intervalMs);
|
|
}
|
|
console.log("QR Manual mode disabled - auto-scroll resumed");
|
|
}
|
|
};
|
|
const nextQrFrame = async () => {
|
|
console.log("\u{1F3AE} nextQrFrame called, qrFramesTotal:", qrFramesTotal, "qrAnimationRef.current:", qrAnimationRef.current);
|
|
if (qrAnimationRef.current.chunks.length > 1) {
|
|
const nextIdx = (qrAnimationRef.current.idx + 1) % qrAnimationRef.current.chunks.length;
|
|
qrAnimationRef.current.idx = nextIdx;
|
|
setQrFrameIndex(nextIdx + 1);
|
|
console.log("\u{1F3AE} Next frame index:", nextIdx + 1);
|
|
try {
|
|
clearInterval(qrAnimationRef.current.timer);
|
|
} catch {
|
|
}
|
|
qrAnimationRef.current.timer = null;
|
|
await renderCurrent();
|
|
if (!qrManualMode && qrAnimationRef.current.chunks.length > 1) {
|
|
const intervalMs = 3e3;
|
|
qrAnimationRef.current.active = true;
|
|
qrAnimationRef.current.timer = setInterval(renderAndAdvance, intervalMs);
|
|
} else {
|
|
qrAnimationRef.current.active = false;
|
|
}
|
|
} else {
|
|
console.log("\u{1F3AE} No multiple frames to navigate");
|
|
}
|
|
};
|
|
const prevQrFrame = async () => {
|
|
console.log("\u{1F3AE} prevQrFrame called, qrFramesTotal:", qrFramesTotal, "qrAnimationRef.current:", qrAnimationRef.current);
|
|
if (qrAnimationRef.current.chunks.length > 1) {
|
|
const prevIdx = (qrAnimationRef.current.idx - 1 + qrAnimationRef.current.chunks.length) % qrAnimationRef.current.chunks.length;
|
|
qrAnimationRef.current.idx = prevIdx;
|
|
setQrFrameIndex(prevIdx + 1);
|
|
console.log("\u{1F3AE} Previous frame index:", prevIdx + 1);
|
|
try {
|
|
clearInterval(qrAnimationRef.current.timer);
|
|
} catch {
|
|
}
|
|
qrAnimationRef.current.timer = null;
|
|
await renderCurrent();
|
|
if (!qrManualMode && qrAnimationRef.current.chunks.length > 1) {
|
|
const intervalMs = 3e3;
|
|
qrAnimationRef.current.active = true;
|
|
qrAnimationRef.current.timer = setInterval(renderAndAdvance, intervalMs);
|
|
} else {
|
|
qrAnimationRef.current.active = false;
|
|
}
|
|
} else {
|
|
console.log("\u{1F3AE} No multiple frames to navigate");
|
|
}
|
|
};
|
|
const qrChunksBufferRef = React.useRef({ id: null, total: 0, seen: /* @__PURE__ */ new Set(), items: [] });
|
|
const generateQRCode = async (data) => {
|
|
try {
|
|
const originalSize = typeof data === "string" ? data.length : JSON.stringify(data).length;
|
|
const isDesktop = typeof window !== "undefined" && (window.innerWidth || 0) >= 1024;
|
|
const QR_SIZE = isDesktop ? 720 : 512;
|
|
if (typeof window.generateBinaryQRCodeFromObject === "function") {
|
|
try {
|
|
const obj = typeof data === "string" ? JSON.parse(data) : data;
|
|
const qrDataUrl = await window.generateBinaryQRCodeFromObject(obj, { errorCorrectionLevel: "M", size: QR_SIZE, margin: 2 });
|
|
if (qrDataUrl) {
|
|
try {
|
|
if (qrAnimationRef.current && qrAnimationRef.current.timer) {
|
|
clearInterval(qrAnimationRef.current.timer);
|
|
}
|
|
} catch {
|
|
}
|
|
qrAnimationRef.current = { timer: null, chunks: [], idx: 0, active: false };
|
|
setQrFrameIndex(0);
|
|
setQrFramesTotal(0);
|
|
setQrManualMode(false);
|
|
setQrCodeUrl(qrDataUrl);
|
|
setQrFramesTotal(1);
|
|
setQrFrameIndex(1);
|
|
return;
|
|
}
|
|
} catch (e) {
|
|
console.warn("Binary QR generation failed, falling back to compressed:", e?.message || e);
|
|
}
|
|
}
|
|
if (typeof window.generateCompressedQRCode === "function") {
|
|
try {
|
|
const payload2 = typeof data === "string" ? data : JSON.stringify(data);
|
|
const qrDataUrl = await window.generateCompressedQRCode(payload2, { errorCorrectionLevel: "M", size: QR_SIZE, margin: 2 });
|
|
if (qrDataUrl) {
|
|
try {
|
|
if (qrAnimationRef.current && qrAnimationRef.current.timer) {
|
|
clearInterval(qrAnimationRef.current.timer);
|
|
}
|
|
} catch {
|
|
}
|
|
qrAnimationRef.current = { timer: null, chunks: [], idx: 0, active: false };
|
|
setQrFrameIndex(0);
|
|
setQrFramesTotal(0);
|
|
setQrManualMode(false);
|
|
setQrCodeUrl(qrDataUrl);
|
|
setQrFramesTotal(1);
|
|
setQrFrameIndex(1);
|
|
return;
|
|
}
|
|
} catch (e) {
|
|
console.warn("Compressed QR generation failed, falling back to plain:", e?.message || e);
|
|
}
|
|
}
|
|
const payload = typeof data === "string" ? data : JSON.stringify(data);
|
|
if (payload.length <= MAX_QR_LEN) {
|
|
if (!window.generateQRCode) throw new Error("QR code generator unavailable");
|
|
try {
|
|
if (qrAnimationRef.current && qrAnimationRef.current.timer) {
|
|
clearInterval(qrAnimationRef.current.timer);
|
|
}
|
|
} catch {
|
|
}
|
|
qrAnimationRef.current = { timer: null, chunks: [], idx: 0, active: false };
|
|
setQrFrameIndex(0);
|
|
setQrFramesTotal(0);
|
|
setQrManualMode(false);
|
|
const qrDataUrl = await window.generateQRCode(payload, { errorCorrectionLevel: "M", size: QR_SIZE, margin: 2 });
|
|
setQrCodeUrl(qrDataUrl);
|
|
setQrFramesTotal(1);
|
|
setQrFrameIndex(1);
|
|
return;
|
|
}
|
|
try {
|
|
if (qrAnimationRef.current && qrAnimationRef.current.timer) {
|
|
clearInterval(qrAnimationRef.current.timer);
|
|
}
|
|
} catch {
|
|
}
|
|
qrAnimationRef.current = { timer: null, chunks: [], idx: 0, active: false };
|
|
setQrFrameIndex(0);
|
|
setQrFramesTotal(0);
|
|
setQrManualMode(false);
|
|
const id = `raw_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
const TARGET_CHUNKS = 10;
|
|
const FRAME_MAX = Math.max(200, Math.floor(payload.length / TARGET_CHUNKS));
|
|
const total = Math.ceil(payload.length / FRAME_MAX);
|
|
const rawChunks = [];
|
|
for (let i = 0; i < total; i++) {
|
|
const seq = i + 1;
|
|
const part = payload.slice(i * FRAME_MAX, (i + 1) * FRAME_MAX);
|
|
rawChunks.push(JSON.stringify({ hdr: { v: 1, id, seq, total, rt: "raw" }, body: part }));
|
|
}
|
|
if (!window.generateQRCode) throw new Error("QR code generator unavailable");
|
|
if (rawChunks.length === 1) {
|
|
const url = await window.generateQRCode(rawChunks[0], { errorCorrectionLevel: "M", margin: 2, size: QR_SIZE });
|
|
setQrCodeUrl(url);
|
|
setQrFramesTotal(1);
|
|
setQrFrameIndex(1);
|
|
return;
|
|
}
|
|
qrAnimationRef.current.chunks = rawChunks;
|
|
qrAnimationRef.current.idx = 0;
|
|
qrAnimationRef.current.active = true;
|
|
setQrFramesTotal(rawChunks.length);
|
|
setQrFrameIndex(1);
|
|
const EC_OPTS = { errorCorrectionLevel: "M", margin: 2, size: QR_SIZE };
|
|
await renderNext();
|
|
if (!qrManualMode) {
|
|
const intervalMs = 4e3;
|
|
qrAnimationRef.current.active = true;
|
|
qrAnimationRef.current.timer = setInterval(renderAndAdvance, intervalMs);
|
|
}
|
|
return;
|
|
} catch (error) {
|
|
console.error("QR code generation failed:", error);
|
|
setMessages((prev) => [...prev, {
|
|
message: ` QR code generation failed: ${error.message}`,
|
|
type: "error"
|
|
}]);
|
|
}
|
|
};
|
|
const reconstructFromTemplate = (templateData) => {
|
|
const fullOffer = {
|
|
type: "enhanced_secure_offer",
|
|
version: templateData.version,
|
|
timestamp: templateData.timestamp,
|
|
sessionId: templateData.sessionId,
|
|
connectionId: templateData.connectionId,
|
|
verificationCode: templateData.verificationCode,
|
|
salt: templateData.salt,
|
|
sdp: templateData.sdp,
|
|
keyFingerprints: templateData.keyFingerprints,
|
|
capabilities: templateData.capabilities,
|
|
// Reconstruct ECDH key object
|
|
ecdhPublicKey: {
|
|
keyType: "ECDH",
|
|
keyData: templateData.ecdhKeyData,
|
|
timestamp: templateData.timestamp - 1e3,
|
|
// Approximate
|
|
version: templateData.version,
|
|
signature: templateData.ecdhSignature
|
|
},
|
|
// Reconstruct ECDSA key object
|
|
ecdsaPublicKey: {
|
|
keyType: "ECDSA",
|
|
keyData: templateData.ecdsaKeyData,
|
|
timestamp: templateData.timestamp - 999,
|
|
// Approximate
|
|
version: templateData.version,
|
|
signature: templateData.ecdsaSignature
|
|
},
|
|
// Reconstruct auth challenge
|
|
authChallenge: {
|
|
challenge: templateData.authChallenge,
|
|
timestamp: templateData.timestamp,
|
|
nonce: templateData.authNonce,
|
|
version: templateData.version
|
|
},
|
|
// Generate security level (can be recalculated)
|
|
securityLevel: {
|
|
level: "CRITICAL",
|
|
score: 20,
|
|
color: "red",
|
|
verificationResults: {
|
|
encryption: { passed: false, details: "Encryption not working", points: 0 },
|
|
keyExchange: { passed: true, details: "Simple key exchange verified", points: 15 },
|
|
messageIntegrity: { passed: false, details: "Message integrity failed", points: 0 },
|
|
rateLimiting: { passed: true, details: "Rate limiting active", points: 5 },
|
|
ecdsa: { passed: false, details: "Enhanced session required - feature not available", points: 0 },
|
|
metadataProtection: { passed: false, details: "Enhanced session required - feature not available", points: 0 },
|
|
pfs: { passed: false, details: "Enhanced session required - feature not available", points: 0 },
|
|
nestedEncryption: { passed: false, details: "Enhanced session required - feature not available", points: 0 },
|
|
packetPadding: { passed: false, details: "Enhanced session required - feature not available", points: 0 },
|
|
advancedFeatures: { passed: false, details: "Premium session required - feature not available", points: 0 }
|
|
},
|
|
timestamp: templateData.timestamp,
|
|
details: "Real verification: 20/100 security checks passed (2/4 available)",
|
|
isRealData: true,
|
|
passedChecks: 2,
|
|
totalChecks: 4,
|
|
sessionType: "demo",
|
|
maxPossibleScore: 50
|
|
}
|
|
};
|
|
return fullOffer;
|
|
};
|
|
const handleQRScan = async (scannedData) => {
|
|
try {
|
|
console.log("QR Code scanned:", scannedData.substring(0, 100) + "...");
|
|
console.log("Current buffer state:", qrChunksBufferRef.current);
|
|
if (scannedData.startsWith("SB1:bin:") || qrChunksBufferRef.current && qrChunksBufferRef.current.id) {
|
|
console.log("Binary chunk detected:", scannedData.substring(0, 50) + "...");
|
|
if (!qrChunksBufferRef.current.id) {
|
|
console.log("Initializing buffer for binary chunks");
|
|
qrChunksBufferRef.current = {
|
|
id: `bin_${Date.now()}`,
|
|
total: 4,
|
|
// We expect 4 chunks
|
|
seen: /* @__PURE__ */ new Set(),
|
|
items: [],
|
|
lastUpdateMs: Date.now()
|
|
};
|
|
}
|
|
const chunkHash = scannedData.substring(0, 50);
|
|
if (qrChunksBufferRef.current.seen.has(chunkHash)) {
|
|
console.log(`Chunk already scanned, ignoring...`);
|
|
return Promise.resolve(false);
|
|
}
|
|
qrChunksBufferRef.current.seen.add(chunkHash);
|
|
qrChunksBufferRef.current.items.push(scannedData);
|
|
qrChunksBufferRef.current.lastUpdateMs = Date.now();
|
|
try {
|
|
const uniqueCount = qrChunksBufferRef.current.seen.size;
|
|
document.dispatchEvent(new CustomEvent("qr-scan-progress", {
|
|
detail: {
|
|
id: qrChunksBufferRef.current.id,
|
|
seq: uniqueCount,
|
|
total: qrChunksBufferRef.current.total
|
|
}
|
|
}));
|
|
setQrFramesTotal(qrChunksBufferRef.current.total);
|
|
setQrFrameIndex(uniqueCount);
|
|
} catch {
|
|
}
|
|
const isComplete = qrChunksBufferRef.current.seen.size >= qrChunksBufferRef.current.total;
|
|
console.log(`Chunks collected: ${qrChunksBufferRef.current.seen.size}/${qrChunksBufferRef.current.total}, complete: ${isComplete}`);
|
|
if (!isComplete) {
|
|
console.log(`Scanned chunk ${qrChunksBufferRef.current.seen.size}/${qrChunksBufferRef.current.total}, waiting for more...`);
|
|
return Promise.resolve(false);
|
|
}
|
|
try {
|
|
const fullBinaryData = qrChunksBufferRef.current.items.join("");
|
|
if (showOfferStep) {
|
|
setAnswerInput(fullBinaryData);
|
|
} else {
|
|
setOfferInput(fullBinaryData);
|
|
}
|
|
setMessages((prev) => [...prev, {
|
|
message: "All binary chunks captured. Payload reconstructed.",
|
|
type: "success"
|
|
}]);
|
|
qrChunksBufferRef.current = { id: null, total: 0, seen: /* @__PURE__ */ new Set(), items: [] };
|
|
setShowQRScannerModal(false);
|
|
return Promise.resolve(true);
|
|
} catch (e) {
|
|
console.warn("Binary chunks reconstruction failed:", e);
|
|
return Promise.resolve(false);
|
|
}
|
|
}
|
|
if (scannedData.length > 100 && !scannedData.startsWith("{") && !scannedData.startsWith("[")) {
|
|
console.log("Detected potential binary chunk (long non-JSON string):", scannedData.substring(0, 50) + "...");
|
|
if (!qrChunksBufferRef.current.id) {
|
|
console.log("Initializing buffer for potential binary chunks");
|
|
qrChunksBufferRef.current = {
|
|
id: `bin_${Date.now()}`,
|
|
total: 4,
|
|
// We expect 4 chunks
|
|
seen: /* @__PURE__ */ new Set(),
|
|
items: [],
|
|
lastUpdateMs: Date.now()
|
|
};
|
|
}
|
|
const chunkHash = scannedData.substring(0, 50);
|
|
if (qrChunksBufferRef.current.seen.has(chunkHash)) {
|
|
console.log(`Chunk already scanned, ignoring...`);
|
|
return Promise.resolve(false);
|
|
}
|
|
qrChunksBufferRef.current.seen.add(chunkHash);
|
|
qrChunksBufferRef.current.items.push(scannedData);
|
|
qrChunksBufferRef.current.lastUpdateMs = Date.now();
|
|
try {
|
|
const uniqueCount = qrChunksBufferRef.current.seen.size;
|
|
document.dispatchEvent(new CustomEvent("qr-scan-progress", {
|
|
detail: {
|
|
id: qrChunksBufferRef.current.id,
|
|
seq: uniqueCount,
|
|
total: qrChunksBufferRef.current.total
|
|
}
|
|
}));
|
|
setQrFramesTotal(qrChunksBufferRef.current.total);
|
|
setQrFrameIndex(uniqueCount);
|
|
} catch {
|
|
}
|
|
const isComplete = qrChunksBufferRef.current.seen.size >= qrChunksBufferRef.current.total;
|
|
console.log(`Chunks collected: ${qrChunksBufferRef.current.seen.size}/${qrChunksBufferRef.current.total}, complete: ${isComplete}`);
|
|
if (!isComplete) {
|
|
console.log(`Scanned chunk ${qrChunksBufferRef.current.seen.size}/${qrChunksBufferRef.current.total}, waiting for more...`);
|
|
return Promise.resolve(false);
|
|
}
|
|
try {
|
|
const fullBinaryData = qrChunksBufferRef.current.items.join("");
|
|
if (showOfferStep) {
|
|
setAnswerInput(fullBinaryData);
|
|
} else {
|
|
setOfferInput(fullBinaryData);
|
|
}
|
|
setMessages((prev) => [...prev, {
|
|
message: "All binary chunks captured. Payload reconstructed.",
|
|
type: "success"
|
|
}]);
|
|
qrChunksBufferRef.current = { id: null, total: 0, seen: /* @__PURE__ */ new Set(), items: [] };
|
|
setShowQRScannerModal(false);
|
|
return Promise.resolve(true);
|
|
} catch (e) {
|
|
console.warn("Binary chunks reconstruction failed:", e);
|
|
return Promise.resolve(false);
|
|
}
|
|
}
|
|
let parsedData;
|
|
if (typeof window.decodeAnyPayload === "function") {
|
|
const any = window.decodeAnyPayload(scannedData);
|
|
if (typeof any === "string") {
|
|
parsedData = JSON.parse(any);
|
|
} else {
|
|
parsedData = any;
|
|
}
|
|
} else {
|
|
const maybeDecompressed = typeof window.decompressIfNeeded === "function" ? window.decompressIfNeeded(scannedData) : scannedData;
|
|
parsedData = JSON.parse(maybeDecompressed);
|
|
}
|
|
console.log("Decoded data:", parsedData);
|
|
if (parsedData.hdr && parsedData.body) {
|
|
const { hdr } = parsedData;
|
|
if (!qrChunksBufferRef.current.id || qrChunksBufferRef.current.id !== hdr.id) {
|
|
qrChunksBufferRef.current = { id: hdr.id, total: hdr.total || 1, seen: /* @__PURE__ */ new Set(), items: [], lastUpdateMs: Date.now() };
|
|
try {
|
|
document.dispatchEvent(new CustomEvent("qr-scan-progress", { detail: { id: hdr.id, seq: 0, total: hdr.total || 1 } }));
|
|
} catch {
|
|
}
|
|
}
|
|
if (!qrChunksBufferRef.current.seen.has(hdr.seq)) {
|
|
qrChunksBufferRef.current.seen.add(hdr.seq);
|
|
qrChunksBufferRef.current.items.push(scannedData);
|
|
qrChunksBufferRef.current.lastUpdateMs = Date.now();
|
|
}
|
|
try {
|
|
const uniqueCount = qrChunksBufferRef.current.seen.size;
|
|
document.dispatchEvent(new CustomEvent("qr-scan-progress", { detail: { id: hdr.id, seq: uniqueCount, total: qrChunksBufferRef.current.total || hdr.total || 0 } }));
|
|
} catch {
|
|
}
|
|
const isComplete = qrChunksBufferRef.current.seen.size >= (qrChunksBufferRef.current.total || 1);
|
|
if (!isComplete) {
|
|
return Promise.resolve(false);
|
|
}
|
|
if (hdr.rt === "raw") {
|
|
try {
|
|
const parts = qrChunksBufferRef.current.items.map((s) => JSON.parse(s)).sort((a, b) => (a.hdr.seq || 0) - (b.hdr.seq || 0)).map((p) => p.body || "");
|
|
const fullText = parts.join("");
|
|
const payloadObj = JSON.parse(fullText);
|
|
if (showOfferStep) {
|
|
setAnswerInput(JSON.stringify(payloadObj, null, 2));
|
|
} else {
|
|
setOfferInput(JSON.stringify(payloadObj, null, 2));
|
|
}
|
|
setMessages((prev) => [...prev, { message: "All frames captured. RAW payload reconstructed.", type: "success" }]);
|
|
try {
|
|
document.dispatchEvent(new CustomEvent("qr-scan-complete", { detail: { id: hdr.id } }));
|
|
} catch {
|
|
}
|
|
qrChunksBufferRef.current = { id: null, total: 0, seen: /* @__PURE__ */ new Set(), items: [] };
|
|
setShowQRScannerModal(false);
|
|
return Promise.resolve(true);
|
|
} catch (e) {
|
|
console.warn("RAW multi-frame reconstruction failed:", e);
|
|
return Promise.resolve(false);
|
|
}
|
|
} else if (hdr.rt === "bin") {
|
|
try {
|
|
const parts = qrChunksBufferRef.current.items.map((s) => JSON.parse(s)).sort((a, b) => (a.hdr.seq || 0) - (b.hdr.seq || 0)).map((p) => p.body || "");
|
|
const fullText = parts.join("");
|
|
let payloadObj;
|
|
if (typeof window.decodeAnyPayload === "function") {
|
|
const any = window.decodeAnyPayload(fullText);
|
|
payloadObj = typeof any === "string" ? JSON.parse(any) : any;
|
|
} else {
|
|
payloadObj = JSON.parse(fullText);
|
|
}
|
|
if (showOfferStep) {
|
|
setAnswerInput(JSON.stringify(payloadObj, null, 2));
|
|
} else {
|
|
setOfferInput(JSON.stringify(payloadObj, null, 2));
|
|
}
|
|
setMessages((prev) => [...prev, { message: "All frames captured. BIN payload reconstructed.", type: "success" }]);
|
|
try {
|
|
document.dispatchEvent(new CustomEvent("qr-scan-complete", { detail: { id: hdr.id } }));
|
|
} catch {
|
|
}
|
|
qrChunksBufferRef.current = { id: null, total: 0, seen: /* @__PURE__ */ new Set(), items: [] };
|
|
setShowQRScannerModal(false);
|
|
return Promise.resolve(true);
|
|
} catch (e) {
|
|
console.warn("BIN multi-frame reconstruction failed:", e);
|
|
return Promise.resolve(false);
|
|
}
|
|
} else if (window.receiveAndProcess) {
|
|
try {
|
|
const results = await window.receiveAndProcess(qrChunksBufferRef.current.items);
|
|
if (results.length > 0) {
|
|
const { payloadObj } = results[0];
|
|
if (showOfferStep) {
|
|
setAnswerInput(JSON.stringify(payloadObj, null, 2));
|
|
} else {
|
|
setOfferInput(JSON.stringify(payloadObj, null, 2));
|
|
}
|
|
setMessages((prev) => [...prev, { message: "All frames captured. COSE payload reconstructed.", type: "success" }]);
|
|
try {
|
|
document.dispatchEvent(new CustomEvent("qr-scan-complete", { detail: { id: hdr.id } }));
|
|
} catch {
|
|
}
|
|
qrChunksBufferRef.current = { id: null, total: 0, seen: /* @__PURE__ */ new Set(), items: [] };
|
|
setShowQRScannerModal(false);
|
|
return Promise.resolve(true);
|
|
}
|
|
} catch (e) {
|
|
console.warn("COSE multi-chunk processing failed:", e);
|
|
}
|
|
return Promise.resolve(false);
|
|
} else {
|
|
return Promise.resolve(false);
|
|
}
|
|
}
|
|
if (parsedData.type === "enhanced_secure_offer_template") {
|
|
console.log("QR scan: Template-based offer detected, reconstructing...");
|
|
const fullOffer = reconstructFromTemplate(parsedData);
|
|
if (showOfferStep) {
|
|
setAnswerInput(JSON.stringify(fullOffer, null, 2));
|
|
console.log("\u{1F4F1} Template data populated to answerInput (waiting for response mode)");
|
|
} else {
|
|
setOfferInput(JSON.stringify(fullOffer, null, 2));
|
|
console.log("\u{1F4F1} Template data populated to offerInput (paste invitation mode)");
|
|
}
|
|
setMessages((prev) => [...prev, {
|
|
message: "\u{1F4F1} QR code scanned successfully! Full offer reconstructed from template.",
|
|
type: "success"
|
|
}]);
|
|
setShowQRScannerModal(false);
|
|
return true;
|
|
} else if (parsedData.type === "secure_offer_reference" && parsedData.referenceId) {
|
|
const fullOfferData = localStorage.getItem(`qr_offer_${parsedData.referenceId}`);
|
|
if (fullOfferData) {
|
|
const fullOffer = JSON.parse(fullOfferData);
|
|
if (showOfferStep) {
|
|
setAnswerInput(JSON.stringify(fullOffer, null, 2));
|
|
} else {
|
|
setOfferInput(JSON.stringify(fullOffer, null, 2));
|
|
}
|
|
setMessages((prev) => [...prev, {
|
|
message: "\u{1F4F1} QR code scanned successfully! Full offer data retrieved.",
|
|
type: "success"
|
|
}]);
|
|
setShowQRScannerModal(false);
|
|
return true;
|
|
} else {
|
|
setMessages((prev) => [...prev, {
|
|
message: "QR code reference found but full data not available. Please use copy/paste.",
|
|
type: "error"
|
|
}]);
|
|
return false;
|
|
}
|
|
} else {
|
|
if (!parsedData.sdp && parsedData.type === "enhanced_secure_offer") {
|
|
setMessages((prev) => [...prev, {
|
|
message: "Compressed QR may omit SDP for brevity. Use copy/paste if connection fails.",
|
|
type: "warning"
|
|
}]);
|
|
}
|
|
if (showOfferStep) {
|
|
console.log("QR scan: Populating answerInput with:", parsedData);
|
|
setAnswerInput(JSON.stringify(parsedData, null, 2));
|
|
} else {
|
|
console.log("QR scan: Populating offerInput with:", parsedData);
|
|
setOfferInput(JSON.stringify(parsedData, null, 2));
|
|
}
|
|
setMessages((prev) => [...prev, {
|
|
message: "\u{1F4F1} QR code scanned successfully!",
|
|
type: "success"
|
|
}]);
|
|
setShowQRScannerModal(false);
|
|
return true;
|
|
}
|
|
} catch (error) {
|
|
if (showOfferStep) {
|
|
setAnswerInput(scannedData);
|
|
} else {
|
|
setOfferInput(scannedData);
|
|
}
|
|
setMessages((prev) => [...prev, {
|
|
message: "\u{1F4F1} QR code scanned successfully!",
|
|
type: "success"
|
|
}]);
|
|
setShowQRScannerModal(false);
|
|
return true;
|
|
}
|
|
};
|
|
const handleCreateOffer = async () => {
|
|
try {
|
|
setIsGeneratingKeys(true);
|
|
setOfferData("");
|
|
setShowOfferStep(false);
|
|
setShowQRCode(false);
|
|
setQrCodeUrl("");
|
|
const offer = await webrtcManagerRef.current.createSecureOffer();
|
|
setOfferData(offer);
|
|
setShowOfferStep(true);
|
|
const offerString = typeof offer === "object" ? JSON.stringify(offer) : offer;
|
|
try {
|
|
if (typeof window.encodeBinaryToPrefixed === "function") {
|
|
const bin = window.encodeBinaryToPrefixed(offerString);
|
|
const TARGET_CHUNKS = 4;
|
|
let total = TARGET_CHUNKS;
|
|
let FRAME_MAX = Math.max(200, Math.ceil(bin.length / TARGET_CHUNKS));
|
|
if (FRAME_MAX <= 0) FRAME_MAX = 200;
|
|
if (bin.length <= FRAME_MAX) {
|
|
total = 1;
|
|
FRAME_MAX = bin.length;
|
|
} else {
|
|
FRAME_MAX = Math.ceil(bin.length / TARGET_CHUNKS);
|
|
total = TARGET_CHUNKS;
|
|
}
|
|
const id = `bin_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
const chunks = [];
|
|
for (let i = 0; i < total; i++) {
|
|
const seq = i + 1;
|
|
const part = bin.slice(i * FRAME_MAX, (i + 1) * FRAME_MAX);
|
|
chunks.push(part);
|
|
}
|
|
const isDesktop = typeof window !== "undefined" && (window.innerWidth || 0) >= 1024;
|
|
const QR_SIZE = isDesktop ? 720 : 512;
|
|
if (window.generateQRCode && chunks.length > 0) {
|
|
const firstUrl = await window.generateQRCode(chunks[0], { errorCorrectionLevel: "M", size: QR_SIZE, margin: 2 });
|
|
if (firstUrl) setQrCodeUrl(firstUrl);
|
|
}
|
|
try {
|
|
if (qrAnimationRef.current && qrAnimationRef.current.timer) {
|
|
clearInterval(qrAnimationRef.current.timer);
|
|
}
|
|
} catch {
|
|
}
|
|
qrAnimationRef.current = { timer: null, chunks, idx: 0, active: true };
|
|
setQrFramesTotal(chunks.length);
|
|
setQrFrameIndex(1);
|
|
setQrManualMode(false);
|
|
const intervalMs = 3e3;
|
|
qrAnimationRef.current.timer = setInterval(renderAndAdvance, intervalMs);
|
|
try {
|
|
setShowQRCode(true);
|
|
} catch {
|
|
}
|
|
} else {
|
|
await generateQRCode(offer);
|
|
try {
|
|
setShowQRCode(true);
|
|
} catch {
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn("Offer QR generation failed:", e);
|
|
}
|
|
const existingMessages = messages.filter(
|
|
(m) => m.type === "system" && (m.message.includes("Secure invitation created") || m.message.includes("Send the encrypted code"))
|
|
);
|
|
if (existingMessages.length === 0) {
|
|
setMessages((prev) => [...prev, {
|
|
message: "Secure invitation created and encrypted!",
|
|
type: "system",
|
|
id: Date.now(),
|
|
timestamp: Date.now()
|
|
}]);
|
|
setMessages((prev) => [...prev, {
|
|
message: "Send the invitation code to your interlocutor via a secure channel (voice call, SMS, etc.).",
|
|
type: "system",
|
|
id: Date.now(),
|
|
timestamp: Date.now()
|
|
}]);
|
|
}
|
|
if (!window.isUpdatingSecurity) {
|
|
updateSecurityLevel().catch(console.error);
|
|
}
|
|
} catch (error) {
|
|
setMessages((prev) => [...prev, {
|
|
message: `Error creating invitation: ${error.message}`,
|
|
type: "system",
|
|
id: Date.now(),
|
|
timestamp: Date.now()
|
|
}]);
|
|
} finally {
|
|
setIsGeneratingKeys(false);
|
|
}
|
|
};
|
|
const handleCreateAnswer = async () => {
|
|
try {
|
|
if (!offerInput.trim()) {
|
|
setMessages((prev) => [...prev, {
|
|
message: "You need to insert the invitation code from your interlocutor.",
|
|
type: "system",
|
|
id: Date.now(),
|
|
timestamp: Date.now()
|
|
}]);
|
|
return;
|
|
}
|
|
try {
|
|
setMessages((prev) => [...prev, {
|
|
message: "Processing the secure invitation...",
|
|
type: "system",
|
|
id: Date.now(),
|
|
timestamp: Date.now()
|
|
}]);
|
|
let offer;
|
|
try {
|
|
if (typeof window.decodeAnyPayload === "function") {
|
|
const any = window.decodeAnyPayload(offerInput.trim());
|
|
offer = typeof any === "string" ? JSON.parse(any) : any;
|
|
} else {
|
|
const rawText = typeof window.decompressIfNeeded === "function" ? window.decompressIfNeeded(offerInput.trim()) : offerInput.trim();
|
|
offer = JSON.parse(rawText);
|
|
}
|
|
} catch (parseError) {
|
|
throw new Error(`Invalid invitation format: ${parseError.message}`);
|
|
}
|
|
if (!offer || typeof offer !== "object") {
|
|
throw new Error("The invitation must be an object");
|
|
}
|
|
const isValidOfferType = offer.t === "offer" || offer.type === "enhanced_secure_offer";
|
|
if (!isValidOfferType) {
|
|
throw new Error("Invalid invitation type. Expected offer or enhanced_secure_offer");
|
|
}
|
|
const answer = await webrtcManagerRef.current.createSecureAnswer(offer);
|
|
setAnswerData(answer);
|
|
setShowAnswerStep(true);
|
|
const answerString = typeof answer === "object" ? JSON.stringify(answer) : answer;
|
|
try {
|
|
if (typeof window.encodeBinaryToPrefixed === "function") {
|
|
const bin = window.encodeBinaryToPrefixed(answerString);
|
|
const TARGET_CHUNKS = 4;
|
|
let total = TARGET_CHUNKS;
|
|
let FRAME_MAX = Math.max(200, Math.ceil(bin.length / TARGET_CHUNKS));
|
|
if (FRAME_MAX <= 0) FRAME_MAX = 200;
|
|
if (bin.length <= FRAME_MAX) {
|
|
total = 1;
|
|
FRAME_MAX = bin.length;
|
|
} else {
|
|
FRAME_MAX = Math.ceil(bin.length / TARGET_CHUNKS);
|
|
total = TARGET_CHUNKS;
|
|
}
|
|
const id = `ans_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
const chunks = [];
|
|
for (let i = 0; i < total; i++) {
|
|
const seq = i + 1;
|
|
const part = bin.slice(i * FRAME_MAX, (i + 1) * FRAME_MAX);
|
|
chunks.push(part);
|
|
}
|
|
const isDesktop = typeof window !== "undefined" && (window.innerWidth || 0) >= 1024;
|
|
const QR_SIZE = isDesktop ? 720 : 512;
|
|
if (window.generateQRCode && chunks.length > 0) {
|
|
const firstUrl = await window.generateQRCode(chunks[0], { errorCorrectionLevel: "M", size: QR_SIZE, margin: 2 });
|
|
if (firstUrl) setQrCodeUrl(firstUrl);
|
|
}
|
|
try {
|
|
if (qrAnimationRef.current && qrAnimationRef.current.timer) {
|
|
clearInterval(qrAnimationRef.current.timer);
|
|
}
|
|
} catch {
|
|
}
|
|
qrAnimationRef.current = { timer: null, chunks, idx: 0, active: true };
|
|
setQrFramesTotal(chunks.length);
|
|
setQrFrameIndex(1);
|
|
setQrManualMode(false);
|
|
const intervalMs = 3e3;
|
|
qrAnimationRef.current.timer = setInterval(renderAndAdvance, intervalMs);
|
|
try {
|
|
setShowQRCode(true);
|
|
} catch {
|
|
}
|
|
} else {
|
|
await generateQRCode(answer);
|
|
try {
|
|
setShowQRCode(true);
|
|
} catch {
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn("Answer QR generation failed:", e);
|
|
}
|
|
if (typeof markAnswerCreated === "function") {
|
|
markAnswerCreated();
|
|
}
|
|
const existingResponseMessages = messages.filter(
|
|
(m) => m.type === "system" && (m.message.includes("Secure response created") || m.message.includes("Send the response"))
|
|
);
|
|
if (existingResponseMessages.length === 0) {
|
|
setMessages((prev) => [...prev, {
|
|
message: "Secure response created!",
|
|
type: "system",
|
|
id: Date.now(),
|
|
timestamp: Date.now()
|
|
}]);
|
|
setMessages((prev) => [...prev, {
|
|
message: "Send the response code to the initiator via a secure channel or let them scan the QR code below.",
|
|
type: "system",
|
|
id: Date.now(),
|
|
timestamp: Date.now()
|
|
}]);
|
|
}
|
|
if (!window.isUpdatingSecurity) {
|
|
updateSecurityLevel().catch(console.error);
|
|
}
|
|
} catch (error) {
|
|
console.error("Error in handleCreateAnswer:", error);
|
|
setMessages((prev) => [...prev, {
|
|
message: `Error processing the invitation: ${error.message}`,
|
|
type: "system",
|
|
id: Date.now(),
|
|
timestamp: Date.now()
|
|
}]);
|
|
}
|
|
} catch (error) {
|
|
console.error("Error in handleCreateAnswer:", error);
|
|
setMessages((prev) => [...prev, {
|
|
message: `Invitation processing error: ${error.message}`,
|
|
type: "system",
|
|
id: Date.now(),
|
|
timestamp: Date.now()
|
|
}]);
|
|
}
|
|
};
|
|
const handleConnect = async () => {
|
|
try {
|
|
if (!answerInput.trim()) {
|
|
setMessages((prev) => [...prev, {
|
|
message: "You need to insert the response code from your interlocutor.",
|
|
type: "system",
|
|
id: Date.now(),
|
|
timestamp: Date.now()
|
|
}]);
|
|
return;
|
|
}
|
|
try {
|
|
setMessages((prev) => [...prev, {
|
|
message: "Processing the secure response...",
|
|
type: "system",
|
|
id: Date.now(),
|
|
timestamp: Date.now()
|
|
}]);
|
|
let answer;
|
|
try {
|
|
if (typeof window.decodeAnyPayload === "function") {
|
|
const anyAnswer = window.decodeAnyPayload(answerInput.trim());
|
|
answer = typeof anyAnswer === "string" ? JSON.parse(anyAnswer) : anyAnswer;
|
|
} else {
|
|
const rawText = typeof window.decompressIfNeeded === "function" ? window.decompressIfNeeded(answerInput.trim()) : answerInput.trim();
|
|
answer = JSON.parse(rawText);
|
|
}
|
|
} catch (parseError) {
|
|
throw new Error(`Invalid response format: ${parseError.message}`);
|
|
}
|
|
if (!answer || typeof answer !== "object") {
|
|
throw new Error("The response must be an object");
|
|
}
|
|
const answerType = answer.t || answer.type;
|
|
if (!answerType || answerType !== "answer" && answerType !== "enhanced_secure_answer") {
|
|
throw new Error("Invalid response type. Expected answer or enhanced_secure_answer");
|
|
}
|
|
await webrtcManagerRef.current.handleSecureAnswer(answer);
|
|
if (pendingSession) {
|
|
setPendingSession(null);
|
|
setMessages((prev) => [...prev, {
|
|
message: `All security features enabled by default`,
|
|
type: "system",
|
|
id: Date.now(),
|
|
timestamp: Date.now()
|
|
}]);
|
|
}
|
|
setMessages((prev) => [...prev, {
|
|
message: "Finalizing the secure connection...",
|
|
type: "system",
|
|
id: Date.now(),
|
|
timestamp: Date.now()
|
|
}]);
|
|
if (!window.isUpdatingSecurity) {
|
|
updateSecurityLevel().catch(console.error);
|
|
}
|
|
} catch (error) {
|
|
console.error("Error in handleConnect inner try:", error);
|
|
let errorMessage = "Connection setup error";
|
|
if (error.message.includes("CRITICAL SECURITY FAILURE")) {
|
|
if (error.message.includes("ECDH public key structure")) {
|
|
errorMessage = "Invalid response code - missing or corrupted cryptographic key. Please check the code and try again.";
|
|
} else if (error.message.includes("ECDSA public key structure")) {
|
|
errorMessage = "Invalid response code - missing signature verification key. Please check the code and try again.";
|
|
} else {
|
|
errorMessage = "Security validation failed - possible attack detected";
|
|
}
|
|
} else if (error.message.includes("too old") || error.message.includes("replay")) {
|
|
errorMessage = "Response data is outdated - please use a fresh invitation";
|
|
} else if (error.message.includes("MITM") || error.message.includes("signature")) {
|
|
errorMessage = "Security breach detected - connection rejected";
|
|
} else if (error.message.includes("Invalid") || error.message.includes("format")) {
|
|
errorMessage = "Invalid response format - please check the code";
|
|
} else {
|
|
errorMessage = ` ${error.message}`;
|
|
}
|
|
setMessages((prev) => [...prev, {
|
|
message: errorMessage,
|
|
type: "system",
|
|
id: Date.now(),
|
|
timestamp: Date.now(),
|
|
showRetryButton: true
|
|
}]);
|
|
if (!error.message.includes("too old") && !error.message.includes("replay")) {
|
|
setPendingSession(null);
|
|
setSessionTimeLeft(0);
|
|
}
|
|
setConnectionStatus("failed");
|
|
}
|
|
} catch (error) {
|
|
console.error("Error in handleConnect outer try:", error);
|
|
let errorMessage = "Connection setup error";
|
|
if (error.message.includes("CRITICAL SECURITY FAILURE")) {
|
|
if (error.message.includes("ECDH public key structure")) {
|
|
errorMessage = "Invalid response code - missing or corrupted cryptographic key. Please check the code and try again.";
|
|
} else if (error.message.includes("ECDSA public key structure")) {
|
|
errorMessage = "Invalid response code - missing signature verification key. Please check the code and try again.";
|
|
} else {
|
|
errorMessage = "Security validation failed - possible attack detected";
|
|
}
|
|
} else if (error.message.includes("too old") || error.message.includes("replay")) {
|
|
errorMessage = "Response data is outdated - please use a fresh invitation";
|
|
} else if (error.message.includes("MITM") || error.message.includes("signature")) {
|
|
errorMessage = "Security breach detected - connection rejected";
|
|
} else if (error.message.includes("Invalid") || error.message.includes("format")) {
|
|
errorMessage = "Invalid response format - please check the code";
|
|
} else {
|
|
errorMessage = `${error.message}`;
|
|
}
|
|
setMessages((prev) => [...prev, {
|
|
message: errorMessage,
|
|
type: "system",
|
|
id: Date.now(),
|
|
timestamp: Date.now(),
|
|
showRetryButton: true
|
|
}]);
|
|
if (!error.message.includes("too old") && !error.message.includes("replay")) {
|
|
setPendingSession(null);
|
|
setSessionTimeLeft(0);
|
|
}
|
|
setConnectionStatus("failed");
|
|
}
|
|
};
|
|
const handleVerifyConnection = async (userCode, isValid = true) => {
|
|
if (isValid) {
|
|
webrtcManagerRef.current.confirmVerification(userCode);
|
|
setLocalVerificationConfirmed(true);
|
|
try {
|
|
if (window.NotificationIntegration && webrtcManagerRef.current && !notificationIntegrationRef.current) {
|
|
const integration = new window.NotificationIntegration(webrtcManagerRef.current);
|
|
await integration.init();
|
|
notificationIntegrationRef.current = integration;
|
|
const status = integration.getStatus();
|
|
if (status.permission === "granted") {
|
|
setMessages((prev) => [...prev, {
|
|
message: "\u2713 Notifications enabled - you will receive alerts when the tab is inactive",
|
|
type: "system",
|
|
id: Date.now(),
|
|
timestamp: Date.now()
|
|
}]);
|
|
} else {
|
|
setMessages((prev) => [...prev, {
|
|
message: "\u2139 Notifications disabled - you can enable them using the button on the main page",
|
|
type: "system",
|
|
id: Date.now(),
|
|
timestamp: Date.now()
|
|
}]);
|
|
}
|
|
} else if (notificationIntegrationRef.current) {
|
|
} else {
|
|
}
|
|
} catch (error) {
|
|
console.warn("Failed to initialize notifications:", error);
|
|
}
|
|
} else {
|
|
setMessages((prev) => [...prev, {
|
|
message: " Verification rejected. The connection is unsafe! Session reset..",
|
|
type: "system",
|
|
id: Date.now(),
|
|
timestamp: Date.now()
|
|
}]);
|
|
setLocalVerificationConfirmed(false);
|
|
setRemoteVerificationConfirmed(false);
|
|
setBothVerificationsConfirmed(false);
|
|
setShowVerification(false);
|
|
setVerificationCode("");
|
|
setConnectionStatus("disconnected");
|
|
setOfferData("");
|
|
setAnswerData("");
|
|
setOfferInput("");
|
|
setAnswerInput("");
|
|
setShowOfferStep(false);
|
|
setShowAnswerStep(false);
|
|
setKeyFingerprint("");
|
|
setSecurityLevel(null);
|
|
setIsVerified(false);
|
|
setMessages([]);
|
|
setSessionTimeLeft(0);
|
|
setPendingSession(null);
|
|
document.dispatchEvent(new CustomEvent("disconnected"));
|
|
handleDisconnect();
|
|
}
|
|
};
|
|
const handleSendMessage = async () => {
|
|
if (!messageInput.trim()) {
|
|
return;
|
|
}
|
|
if (!webrtcManagerRef.current) {
|
|
return;
|
|
}
|
|
const baseTextEarly = messageInput.trim();
|
|
const midEarly = `m_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
const offlineNow = isOffline || typeof navigator !== "undefined" && navigator.onLine === false || window.pwaOfflineManager && window.pwaOfflineManager.isOnline === false;
|
|
if (offlineNow) {
|
|
const outTextOff = codeMode ? "```\n" + baseTextEarly + "\n```" : baseTextEarly;
|
|
const tsOff = Date.now();
|
|
const metaOff = { mid: midEarly, ts: tsOff };
|
|
if (viewOnceMode) {
|
|
metaOff.once = true;
|
|
metaOff.onceTtl = viewOnceTtl;
|
|
}
|
|
if (disappearTtl > 0) metaOff.ttl = disappearTtl;
|
|
const echoOpts = { mid: midEarly, status: "sent", timestamp: tsOff };
|
|
if (disappearTtl > 0) echoOpts.expiresAt = tsOff + disappearTtl * 1e3;
|
|
addMessageWithAutoScroll(outTextOff, "sent", echoOpts);
|
|
const q = queuesRef.current.get(activeIdRef.current);
|
|
if (q) q.outgoing.push({ outText: outTextOff, meta: metaOff, mid: midEarly });
|
|
setMessageInput("");
|
|
if (codeMode) setCodeMode(false);
|
|
if (viewOnceMode) setViewOnceMode(false);
|
|
return;
|
|
}
|
|
if (!webrtcManagerRef.current.isConnected()) {
|
|
return;
|
|
}
|
|
try {
|
|
const baseText = baseTextEarly;
|
|
const outText = codeMode ? "```\n" + baseText + "\n```" : baseText;
|
|
const mid = midEarly;
|
|
const meta = { mid, ts: Date.now() };
|
|
if (viewOnceMode) {
|
|
meta.once = true;
|
|
meta.onceTtl = viewOnceTtl;
|
|
}
|
|
if (disappearTtl > 0) meta.ttl = disappearTtl;
|
|
const localOpts = { mid, status: "sending" };
|
|
if (disappearTtl > 0) localOpts.expiresAt = Date.now() + disappearTtl * 1e3;
|
|
addMessageWithAutoScroll(outText, "sent", localOpts);
|
|
try {
|
|
await webrtcManagerRef.current.sendMessage(outText, meta);
|
|
updateMessageStatus(mid, "delivered");
|
|
} catch (sendErr) {
|
|
updateMessageStatus(mid, "failed");
|
|
throw sendErr;
|
|
}
|
|
setMessageInput("");
|
|
if (codeMode) setCodeMode(false);
|
|
if (viewOnceMode) setViewOnceMode(false);
|
|
} catch (error) {
|
|
const msg = String(error?.message || error);
|
|
if (!/queued for sending|Data channel not ready/i.test(msg)) {
|
|
addMessageWithAutoScroll(`Sending error: ${msg}`, "system");
|
|
}
|
|
}
|
|
};
|
|
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)));
|
|
try {
|
|
webrtcManagerRef.current?.sendMessageDelete?.(String(mid));
|
|
} catch (_) {
|
|
}
|
|
}, []);
|
|
const handleMessageExpire = React.useCallback((id) => {
|
|
setMessages((prev) => prev.map((m) => m.id === id ? { ...m, expired: true, message: "", expiresAt: void 0 } : m));
|
|
}, []);
|
|
const handleClearData = () => {
|
|
setOfferData("");
|
|
setAnswerData("");
|
|
setOfferInput("");
|
|
setAnswerInput("");
|
|
setShowOfferStep(false);
|
|
setIsGeneratingKeys(false);
|
|
if (!shouldPreserveAnswerData()) {
|
|
setShowAnswerStep(false);
|
|
}
|
|
setShowVerification(false);
|
|
setShowQRCode(false);
|
|
setShowQRScanner(false);
|
|
setShowQRScannerModal(false);
|
|
qrChunksBufferRef.current = { id: null, total: 0, seen: /* @__PURE__ */ new Set(), items: [] };
|
|
if (!shouldPreserveAnswerData()) {
|
|
setQrCodeUrl("");
|
|
}
|
|
setVerificationCode("");
|
|
setIsVerified(false);
|
|
setKeyFingerprint("");
|
|
setSecurityLevel(null);
|
|
setConnectionStatus("disconnected");
|
|
setMessages([]);
|
|
setMessageInput("");
|
|
setLocalVerificationConfirmed(false);
|
|
setRemoteVerificationConfirmed(false);
|
|
setBothVerificationsConfirmed(false);
|
|
if (typeof console.clear === "function") {
|
|
console.clear();
|
|
}
|
|
setSessionTimeLeft(0);
|
|
setPendingSession(null);
|
|
document.dispatchEvent(new CustomEvent("peer-disconnect"));
|
|
};
|
|
const handleIncomingDecision = React.useCallback(async (fileId, accepted) => {
|
|
try {
|
|
if (accepted) {
|
|
await webrtcManagerRef.current?.acceptIncomingFile(fileId);
|
|
} else {
|
|
await webrtcManagerRef.current?.rejectIncomingFile(fileId);
|
|
}
|
|
} finally {
|
|
setPendingIncomingFiles((prev) => prev.filter((f) => f.fileId !== fileId));
|
|
}
|
|
}, []);
|
|
const handleDisconnect = () => {
|
|
try {
|
|
const id = activeIdRef.current;
|
|
setSessionTimeLeft(0);
|
|
document.dispatchEvent(new CustomEvent("peer-disconnect"));
|
|
document.dispatchEvent(new CustomEvent("disconnected"));
|
|
document.dispatchEvent(new CustomEvent("session-cleanup", {
|
|
detail: { timestamp: Date.now(), reason: "manual_disconnect" }
|
|
}));
|
|
destroySession(id);
|
|
if (typeof console.clear === "function") console.clear();
|
|
} catch (error) {
|
|
console.error("Error during disconnect:", error);
|
|
}
|
|
};
|
|
const handleSessionActivated = (session) => {
|
|
let message;
|
|
if (session.type === "demo") {
|
|
message = ` Demo session activated for 6 minutes. You can create invitations!`;
|
|
} else {
|
|
message = ` All security features enabled by default. You can create invitations!`;
|
|
}
|
|
addMessageWithAutoScroll(message, "system");
|
|
};
|
|
React.useEffect(() => {
|
|
if (connectionStatus === "connected" && isVerified) {
|
|
addMessageWithAutoScroll(" Secure connection successfully established and verified! You can now communicate safely with full protection against MITM attacks and Perfect Forward Secrecy..", "system");
|
|
}
|
|
}, [connectionStatus, isVerified]);
|
|
const isConnectedAndVerified = (connectionStatus === "connected" || connectionStatus === "verified") && isVerified;
|
|
React.useEffect(() => {
|
|
document.body.classList.toggle("sb-in-chat", isConnectedAndVerified);
|
|
return () => document.body.classList.remove("sb-in-chat");
|
|
}, [isConnectedAndVerified]);
|
|
React.useEffect(() => {
|
|
if (isConnectedAndVerified && pendingSession && connectionStatus !== "failed") {
|
|
setPendingSession(null);
|
|
setSessionTimeLeft(0);
|
|
addMessageWithAutoScroll(" All security features enabled by default", "system");
|
|
}
|
|
}, [isConnectedAndVerified, pendingSession, connectionStatus]);
|
|
React.useEffect(() => {
|
|
if (showQRScannerModal && window.Html5Qrcode) {
|
|
const html5Qrcode = new window.Html5Qrcode("qr-reader");
|
|
const config = {
|
|
fps: 10
|
|
// Убираем qrbox чтобы использовать всю область
|
|
};
|
|
let isScanning = true;
|
|
html5Qrcode.start(
|
|
{ facingMode: "environment" },
|
|
// Use back camera
|
|
config,
|
|
(decodedText, decodedResult) => {
|
|
if (!isScanning) {
|
|
console.log("Scanner stopped, ignoring scan");
|
|
return;
|
|
}
|
|
console.log("QR Code scanned:", decodedText);
|
|
console.log("Current buffer state:", qrChunksBufferRef.current);
|
|
handleQRScan(decodedText).then((success) => {
|
|
console.log("QR scan result:", success);
|
|
if (success) {
|
|
console.log("Closing scanner and modal");
|
|
isScanning = false;
|
|
try {
|
|
console.log("Stopping scanner...");
|
|
html5Qrcode.stop().then(() => {
|
|
console.log("Scanner stopped, clearing...");
|
|
html5Qrcode.clear();
|
|
setShowQRScannerModal(false);
|
|
}).catch((err) => {
|
|
console.log("Error stopping scanner:", err);
|
|
try {
|
|
html5Qrcode.clear();
|
|
} catch (clearErr) {
|
|
console.log("Error clearing scanner:", clearErr);
|
|
}
|
|
setShowQRScannerModal(false);
|
|
});
|
|
} catch (err) {
|
|
console.log("Error in scanner cleanup:", err);
|
|
setShowQRScannerModal(false);
|
|
}
|
|
} else {
|
|
console.log("Continuing to scan for more chunks...");
|
|
}
|
|
}).catch((error) => {
|
|
console.error("QR scan processing error:", error);
|
|
});
|
|
},
|
|
(error) => {
|
|
if (isScanning) {
|
|
console.log("QR scan error (ignored):", error);
|
|
}
|
|
}
|
|
).catch((err) => {
|
|
console.error("QR Scanner start error:", err);
|
|
setShowQRScannerModal(false);
|
|
});
|
|
return () => {
|
|
isScanning = false;
|
|
try {
|
|
html5Qrcode.stop().then(() => {
|
|
html5Qrcode.clear();
|
|
}).catch((err) => {
|
|
console.log("Scanner already stopped or error stopping:", err);
|
|
try {
|
|
html5Qrcode.clear();
|
|
} catch (clearErr) {
|
|
console.log("Error clearing scanner in cleanup:", clearErr);
|
|
}
|
|
});
|
|
} catch (err) {
|
|
console.log("Error in cleanup:", err);
|
|
try {
|
|
html5Qrcode.clear();
|
|
} catch (clearErr) {
|
|
console.log("Error clearing scanner in cleanup:", clearErr);
|
|
}
|
|
}
|
|
};
|
|
}
|
|
}, [showQRScannerModal]);
|
|
const sessionChats = decorateSessions(sessionsState);
|
|
const showSidebar = sessionsState.order.length > 1 || sessionsState.order.some((id) => {
|
|
const s = sessionsState.sessions[id];
|
|
return s && s.sas && s.sas.isVerified;
|
|
});
|
|
return React.createElement("div", {
|
|
className: showSidebar ? "minimal-bg sb-app-shell" : "minimal-bg",
|
|
// With the rail visible the app is a fixed-height shell (rail + column
|
|
// fill the viewport, design-style). Otherwise it's the scrollable landing.
|
|
// flexDirection:'row' is explicit — the .minimal-bg class forces
|
|
// flex-direction:column, which would otherwise stack the rail ABOVE the chat.
|
|
// height:100vh is the fallback; .sb-app-shell upgrades it to 100dvh on
|
|
// mobile so the shell fits under the browser toolbar (header stays put).
|
|
style: showSidebar ? { display: "flex", flexDirection: "row", height: "100vh", width: "100%", overflow: "hidden" } : { minHeight: "100vh" }
|
|
}, [
|
|
showSidebar && React.createElement(SessionsSidebar, {
|
|
key: "sessions-sidebar",
|
|
chats: sessionChats,
|
|
collapsed: sidebarCollapsed,
|
|
drawerOpen: sidebarDrawerOpen,
|
|
onToggleCollapse: () => setSidebarCollapsed((v) => !v),
|
|
onSelect: handleSelectSession,
|
|
onNewChat: handleNewChat,
|
|
onRename: handleRenameSession,
|
|
onCloseDrawer: () => setSidebarDrawerOpen(false),
|
|
myStatus,
|
|
onSetStatus: setMyStatus
|
|
}),
|
|
// Mobile-only hamburger that opens the drawer (hidden on desktop via CSS).
|
|
showSidebar && React.createElement("button", {
|
|
key: "sb-burger",
|
|
className: "sb-burger",
|
|
onClick: () => setSidebarDrawerOpen(true),
|
|
style: { display: "none", position: "fixed", top: "13px", left: "13px", zIndex: 55, width: "38px", height: "38px", borderRadius: "10px", placeItems: "center", border: "1px solid rgba(255,255,255,0.1)", background: "rgba(18,18,20,0.9)", color: "#cfcfd4", cursor: "pointer" },
|
|
dangerouslySetInnerHTML: { __html: SB_SVG.burger }
|
|
}),
|
|
React.createElement("div", {
|
|
key: "app-column",
|
|
className: showSidebar ? "minimal-bg sb-app-col" : "minimal-bg min-h-screen",
|
|
style: showSidebar ? { flex: 1, minWidth: 0, height: "100vh", overflow: "hidden", display: "flex", flexDirection: "column" } : {}
|
|
}, [
|
|
// Advanced network settings now render inside the connection
|
|
// screen's right panel (see EnhancedConnectionSetup), matching
|
|
// the design's slide-up-within-the-right-column behavior.
|
|
// The verified chat renders its own in-chat header (SecureBit Chat
|
|
// design); the shared header is shown only on the landing/setup view.
|
|
!isConnectedAndVerified && !showSidebar && window.EnhancedMinimalHeader && React.createElement(window.EnhancedMinimalHeader, {
|
|
key: "header",
|
|
status: connectionStatus,
|
|
fingerprint: keyFingerprint,
|
|
verificationCode,
|
|
onDisconnect: handleDisconnect,
|
|
isConnected: isConnectedAndVerified,
|
|
securityLevel,
|
|
// sessionManager removed - all features enabled by default
|
|
webrtcManager: webrtcManagerRef.current
|
|
}),
|
|
React.createElement(
|
|
"main",
|
|
{
|
|
key: "main"
|
|
},
|
|
/* @__PURE__ */ (() => {
|
|
return isConnectedAndVerified;
|
|
})() ? (() => {
|
|
return React.createElement(EnhancedChatInterface, {
|
|
title: active ? active.peerLabel : "",
|
|
isOffline,
|
|
peerPresence: active ? active.peerPresence : null,
|
|
onRenameTitle: (label) => {
|
|
if (activeSessionId) dispatch({ type: SESSION_ACTIONS.RENAME, id: activeSessionId, label });
|
|
},
|
|
messages,
|
|
messageInput,
|
|
setMessageInput,
|
|
onSendMessage: handleSendMessage,
|
|
onSendVoice: handleSendVoice,
|
|
onDisconnect: handleDisconnect,
|
|
keyFingerprint,
|
|
isVerified,
|
|
chatMessagesRef,
|
|
scrollToBottom,
|
|
webrtcManager: webrtcManagerRef.current,
|
|
status: connectionStatus,
|
|
pendingIncomingFiles,
|
|
onIncomingDecision: handleIncomingDecision,
|
|
// Secure chat extras
|
|
codeMode,
|
|
setCodeMode,
|
|
viewOnceMode,
|
|
setViewOnceMode,
|
|
viewOnceTtl,
|
|
setViewOnceTtl,
|
|
disappearTtl,
|
|
setDisappearTtl,
|
|
nowTick,
|
|
onUnsendMessage: handleUnsendMessage,
|
|
onMessageExpire: handleMessageExpire
|
|
});
|
|
})() : React.createElement(EnhancedConnectionSetup, {
|
|
onCreateOffer: handleCreateOffer,
|
|
onCreateAnswer: handleCreateAnswer,
|
|
onConnect: handleConnect,
|
|
onClearData: handleClearData,
|
|
onVerifyConnection: handleVerifyConnection,
|
|
connectionStatus,
|
|
offerData,
|
|
answerData,
|
|
offerInput,
|
|
setOfferInput,
|
|
answerInput,
|
|
setAnswerInput,
|
|
showOfferStep,
|
|
showAnswerStep,
|
|
verificationCode,
|
|
showVerification,
|
|
showQRCode,
|
|
qrCodeUrl,
|
|
showQRScanner,
|
|
setShowQRCode,
|
|
setShowQRScanner,
|
|
setShowQRScannerModal,
|
|
messages,
|
|
localVerificationConfirmed,
|
|
remoteVerificationConfirmed,
|
|
bothVerificationsConfirmed,
|
|
// QR control props
|
|
qrFramesTotal,
|
|
qrFrameIndex,
|
|
qrManualMode,
|
|
toggleQrManualMode,
|
|
nextQrFrame,
|
|
prevQrFrame,
|
|
// PAKE passwords removed - using SAS verification instead
|
|
markAnswerCreated,
|
|
notificationIntegrationRef,
|
|
isGeneratingKeys,
|
|
setIsGeneratingKeys,
|
|
handleCreateOffer,
|
|
relayOnlyMode,
|
|
setRelayOnlyMode,
|
|
webrtcManagerRef,
|
|
showIceSettings,
|
|
setShowIceSettings,
|
|
iceServersText,
|
|
iceSettingsPersisted,
|
|
customIceServers,
|
|
handleApplyIceSettings,
|
|
handleForgetIceSettings,
|
|
// Render only the create/connect card inside the chat column
|
|
// (an additional session), instead of the full landing.
|
|
compact: showSidebar
|
|
})
|
|
),
|
|
// QR Scanner Modal — camera scan (design import: "Start Secure" / Camera scan modal)
|
|
showQRScannerModal && (() => {
|
|
const closeScanner = () => {
|
|
setShowQRScannerModal(false);
|
|
qrChunksBufferRef.current = { id: null, total: 0, seen: /* @__PURE__ */ new Set(), items: [] };
|
|
};
|
|
const buf = qrChunksBufferRef.current;
|
|
const hasParts = !!(buf && buf.id && buf.total > 1);
|
|
const framesText = hasParts ? `Scanning frames\u2026 ${buf.seen.size} / ${buf.total}` : "Scanning\u2026";
|
|
const corner = (k, st) => React.createElement("span", {
|
|
key: k,
|
|
style: Object.assign({ position: "absolute", width: "34px", height: "34px", zIndex: 3 }, st)
|
|
});
|
|
return React.createElement("div", {
|
|
key: "qr-scanner-modal",
|
|
onClick: closeScanner,
|
|
style: { position: "fixed", inset: 0, zIndex: 50, display: "flex", alignItems: "center", justifyContent: "center", padding: "32px", background: "rgba(6,6,8,0.82)", backdropFilter: "blur(10px)", WebkitBackdropFilter: "blur(10px)", animation: "sbUp .2s ease" }
|
|
}, [
|
|
React.createElement("div", {
|
|
key: "scanner-container",
|
|
onClick: (e) => e.stopPropagation(),
|
|
style: { width: "100%", maxWidth: "420px", borderRadius: "22px", border: "1px solid rgba(255,255,255,0.1)", background: "#111113", boxShadow: "0 30px 90px rgba(0,0,0,0.6)", overflow: "hidden" }
|
|
}, [
|
|
// Header
|
|
React.createElement("div", {
|
|
key: "scanner-header",
|
|
style: { display: "flex", alignItems: "center", gap: "11px", padding: "18px 20px", borderBottom: "1px solid rgba(255,255,255,0.06)" }
|
|
}, [
|
|
React.createElement("span", {
|
|
key: "scanner-icon",
|
|
style: { display: "flex" },
|
|
dangerouslySetInnerHTML: { __html: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#3ecf8e" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M2 8.5V6.5A2.5 2.5 0 0 1 4.5 4h2M17.5 4h2A2.5 2.5 0 0 1 22 6.5v2M22 15.5v2a2.5 2.5 0 0 1-2.5 2.5h-2M6.5 20h-2A2.5 2.5 0 0 1 2 17.5v-2"/><circle cx="12" cy="12" r="3.2"/></svg>' }
|
|
}),
|
|
React.createElement("div", {
|
|
key: "scanner-titles",
|
|
style: { flex: 1, lineHeight: 1.2 }
|
|
}, [
|
|
React.createElement("div", {
|
|
key: "scanner-title",
|
|
style: { fontSize: "15.5px", fontWeight: 800, color: "#f4f4f6" }
|
|
}, "Scan QR code"),
|
|
React.createElement("div", {
|
|
key: "scanner-hint",
|
|
style: { fontSize: "12px", color: "#7b7b83" }
|
|
}, "Point your camera at their QR")
|
|
]),
|
|
React.createElement("button", {
|
|
key: "close-btn",
|
|
onClick: closeScanner,
|
|
style: { width: "32px", height: "32px", display: "grid", placeItems: "center", borderRadius: "9px", border: "none", background: "rgba(255,255,255,0.05)", color: "#9a9aa2", cursor: "pointer" }
|
|
}, React.createElement("i", { className: "fas fa-times" }))
|
|
]),
|
|
// Body
|
|
React.createElement("div", {
|
|
key: "scanner-body",
|
|
style: { padding: "22px 24px 24px" }
|
|
}, [
|
|
React.createElement("div", {
|
|
key: "viewfinder",
|
|
style: { position: "relative", aspectRatio: "1", borderRadius: "18px", overflow: "hidden", background: "#000", border: "1px solid rgba(255,255,255,0.1)" }
|
|
}, [
|
|
React.createElement("div", {
|
|
key: "vf-bg",
|
|
style: { position: "absolute", inset: 0, background: "radial-gradient(circle at 50% 45%, #1a1a1f, #000)" }
|
|
}),
|
|
// Camera video is injected here by Html5Qrcode
|
|
React.createElement("div", {
|
|
key: "qr-reader",
|
|
id: "qr-reader",
|
|
style: { position: "absolute", inset: 0, zIndex: 1 }
|
|
}),
|
|
corner("c-tl", { top: "18px", left: "18px", borderTop: "2.5px solid #3ecf8e", borderLeft: "2.5px solid #3ecf8e", borderRadius: "8px 0 0 0" }),
|
|
corner("c-tr", { top: "18px", right: "18px", borderTop: "2.5px solid #3ecf8e", borderRight: "2.5px solid #3ecf8e", borderRadius: "0 8px 0 0" }),
|
|
corner("c-bl", { bottom: "18px", left: "18px", borderBottom: "2.5px solid #3ecf8e", borderLeft: "2.5px solid #3ecf8e", borderRadius: "0 0 0 8px" }),
|
|
corner("c-br", { bottom: "18px", right: "18px", borderBottom: "2.5px solid #3ecf8e", borderRight: "2.5px solid #3ecf8e", borderRadius: "0 0 8px 0" }),
|
|
React.createElement("span", {
|
|
key: "scan-line",
|
|
style: { position: "absolute", left: "18px", right: "18px", height: "2.5px", zIndex: 2, background: "linear-gradient(90deg, transparent, #3ecf8e, transparent)", boxShadow: "0 0 16px #3ecf8e", animation: "sbScan 1.5s ease-in-out infinite alternate" }
|
|
}),
|
|
React.createElement("div", {
|
|
key: "scan-status",
|
|
style: { position: "absolute", bottom: 0, left: 0, right: 0, zIndex: 3, display: "flex", alignItems: "center", justifyContent: "center", gap: "8px", padding: "14px", background: "linear-gradient(transparent, rgba(0,0,0,0.6))" }
|
|
}, [
|
|
React.createElement("span", {
|
|
key: "spinner",
|
|
style: { display: "flex", animation: "sbSpin 1.4s linear infinite" },
|
|
dangerouslySetInnerHTML: { __html: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#3ecf8e" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 1 1-6.2-8.6"/></svg>' }
|
|
}),
|
|
React.createElement("span", {
|
|
key: "scan-frames",
|
|
style: { fontSize: "12.5px", fontWeight: 600, color: "#cfcfd4" }
|
|
}, framesText)
|
|
])
|
|
]),
|
|
React.createElement("p", {
|
|
key: "scanner-note",
|
|
style: { margin: "16px 0 0", textAlign: "center", fontSize: "12px", lineHeight: 1.5, color: "#6b6b73" }
|
|
}, "Hold steady until all parts are captured. Camera access is local \u2014 nothing is uploaded.")
|
|
])
|
|
])
|
|
]);
|
|
})()
|
|
])
|
|
// end app-column
|
|
]);
|
|
};
|
|
var UpdateCheckerWrapper = ({ children }) => {
|
|
if (typeof window !== "undefined" && window.UpdateChecker) {
|
|
return React.createElement(window.UpdateChecker, {
|
|
debug: false
|
|
}, children);
|
|
}
|
|
return children;
|
|
};
|
|
function initializeApp() {
|
|
if (window.EnhancedSecureCryptoUtils && window.EnhancedSecureWebRTCManager) {
|
|
const AppWithUpdateChecker = React.createElement(
|
|
UpdateCheckerWrapper,
|
|
null,
|
|
React.createElement(EnhancedSecureP2PChat)
|
|
);
|
|
ReactDOM.render(AppWithUpdateChecker, document.getElementById("root"));
|
|
} else {
|
|
console.error("\u041C\u043E\u0434\u0443\u043B\u0438 \u043D\u0435 \u0437\u0430\u0433\u0440\u0443\u0436\u0435\u043D\u044B:", {
|
|
hasCrypto: !!window.EnhancedSecureCryptoUtils,
|
|
hasWebRTC: !!window.EnhancedSecureWebRTCManager
|
|
});
|
|
}
|
|
}
|
|
if (typeof window !== "undefined") {
|
|
window.addEventListener("unhandledrejection", (event) => {
|
|
console.error("Unhandled promise rejection:", event.reason);
|
|
event.preventDefault();
|
|
});
|
|
window.addEventListener("error", (event) => {
|
|
console.error("Global error:", event.error);
|
|
event.preventDefault();
|
|
});
|
|
if (!window.initializeApp) {
|
|
window.initializeApp = initializeApp;
|
|
}
|
|
}
|
|
if (window.EnhancedSecureCryptoUtils && window.EnhancedSecureWebRTCManager) {
|
|
const UpdateCheckerWrapper2 = ({ children }) => {
|
|
if (typeof window !== "undefined" && window.UpdateChecker) {
|
|
return React.createElement(window.UpdateChecker, {
|
|
debug: false
|
|
}, children);
|
|
}
|
|
return children;
|
|
};
|
|
const AppWithUpdateChecker = React.createElement(
|
|
UpdateCheckerWrapper2,
|
|
null,
|
|
React.createElement(EnhancedSecureP2PChat)
|
|
);
|
|
ReactDOM.render(AppWithUpdateChecker, document.getElementById("root"));
|
|
} else {
|
|
ReactDOM.render(React.createElement(EnhancedSecureP2PChat), document.getElementById("root"));
|
|
}
|
|
//# sourceMappingURL=app.js.map
|