// 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(label2) { const words = String(label2 || "").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 "reconnecting": return "Reconnecting\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" || action.status === "reconnecting"; 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 label2 = String(action.label || "").trim() || session.peerLabel; return patchSession(state, action.id, { peerLabel: label2, 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 && m.type !== "system" && (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" || s === "reconnecting"; 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/group/groupCrypto.js var GROUP_LIMITS = Object.freeze({ // Eight is a mesh limit, not a crypto limit: it is where N(N-1)/2 pairwise // connections and N-1 fan-out copies stop being comfortable in a browser. MAX_MEMBERS: 8, MIN_MEMBERS: 2, GROUP_ID_BYTES: 16, NONCE_BYTES: 32, COMMIT_BYTES: 32, FINGERPRINT_BYTES: 32, // Matches the pairwise SAS. Safe at this length only because of the // commit-reveal ordering above — see the header. SAS_DIGITS: 7, // Bytes, not characters — and the gap between the two is a real trap. The // create dialog used to cap input at 64 CHARACTERS, so a 36-character // Cyrillic name ("Наша секретная группа для обсуждений") is 68 bytes and was // accepted by the UI and then rejected here, inside the admin's roster // signing, killing group formation with no visible cause. The dialog now // clamps by bytes, and the budget is generous enough that a normal name in // any script fits. MAX_NAME_BYTES: 128, // Epoch is a uint32 on the wire; a group that changes membership four // billion times has other problems. MAX_EPOCH: 4294967295, MAX_SPKI_BYTES: 256, MIN_SPKI_BYTES: 40, MAX_SIG_BYTES: 160, MIN_SIG_BYTES: 48, /** * Group frames travel as chat content on a pairwise session, and that path * ends in EnhancedSecureCryptoUtils.sanitizeMessage, which runs DOMPurify and * then truncates to 2000 characters. Truncation would corrupt a frame * silently, so every frame has to fit underneath it after base64 — see * FRAME_BUDGET_CHARS and the envelope in GroupSession. * * A frame's fixed overhead (group id, epoch, sequence, sender fingerprint, * timestamp, signature, envelope) is roughly 300 bytes, and base64 costs * another third. 1024 bytes of body leaves comfortable headroom, and it is * bytes rather than characters so a message in a non-Latin script is bounded * by the same real budget. */ MAX_BODY_BYTES: 1024, FRAME_BUDGET_CHARS: 1800, /** * A mesh descriptor as it travels inside a group frame. * * SBQ2 caps a descriptor payload at 512 bytes (LIMITS.MAX_PAYLOAD_BYTES), * which is "SB2:" plus 683 base64url characters at the absolute worst. 768 * bounds the allocation with room to spare and still leaves the whole frame * — descriptor, two fingerprints, a nonce and a signature, wrapped in a * relay envelope and base64'd — under FRAME_BUDGET_CHARS. A descriptor that * somehow does not fit is refused rather than truncated; the pair simply * stays on the relay path, which is the same thing that happens when the * mesh dial fails for any other reason. */ MAX_DESCRIPTOR_CHARS: 768, /** Binds an answer to the one dial attempt that asked for it. */ MESH_NONCE_BYTES: 16 }); var MESH_KINDS = Object.freeze({ OFFER: "moffer", ANSWER: "manswer" }); var MEMBER_OPS = Object.freeze({ CREATE: "create", ADD: "add", REMOVE: "remove", RENAME: "rename" }); var ENC = new TextEncoder(); var GroupCryptoError = class extends Error { constructor(message, code = "group_crypto") { super(message); this.name = "GroupCryptoError"; this.code = code; } }; var fail = (msg, code) => { throw new GroupCryptoError(msg, code); }; function toHex(bytes) { const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); let out = ""; for (let i = 0; i < view.length; i++) out += view[i].toString(16).padStart(2, "0"); return out; } function fromHex(hex) { if (typeof hex !== "string" || hex.length % 2 !== 0 || !/^[0-9a-f]*$/i.test(hex)) { fail("not a hex string", "bad_hex"); } const out = new Uint8Array(hex.length / 2); for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.substr(i * 2, 2), 16); return out; } function toB64(bytes) { const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); let binary = ""; for (let i = 0; i < view.length; i++) binary += String.fromCharCode(view[i]); return btoa(binary); } function fromB64(b64, { max = GROUP_LIMITS.MAX_SPKI_BYTES } = {}) { if (typeof b64 !== "string") fail("not a base64 string", "bad_b64"); if (b64.length > Math.ceil(max * 4 / 3) + 4) fail("base64 payload exceeds its limit", "bad_b64"); let binary; try { binary = atob(b64); } catch (_) { fail("malformed base64", "bad_b64"); } const out = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i); return out; } function randomBytes(n) { return crypto.getRandomValues(new Uint8Array(n)); } function newGroupId() { return toHex(randomBytes(GROUP_LIMITS.GROUP_ID_BYTES)); } function lp(label2, ...parts) { const chunks = [ENC.encode(label2 + "\0")]; let total = chunks[0].length; for (const part of parts) { const bytes = part instanceof Uint8Array ? part : typeof part === "string" ? ENC.encode(part) : fail("unsupported payload component", "bad_payload"); const header = new Uint8Array(4); new DataView(header.buffer).setUint32(0, bytes.length); chunks.push(header, bytes); total += 4 + bytes.length; } const out = new Uint8Array(total); let o = 0; for (const c of chunks) { out.set(c, o); o += c.length; } return out; } function u32(n) { if (!Number.isInteger(n) || n < 0 || n > GROUP_LIMITS.MAX_EPOCH) fail("value out of uint32 range", "bad_u32"); const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, n); return b; } function equalBytes(a, b) { if (!(a instanceof Uint8Array) || !(b instanceof Uint8Array) || a.length !== b.length) return false; let diff = 0; for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]; return diff === 0; } function assertGroupId(groupId) { if (typeof groupId !== "string" || groupId.length !== GROUP_LIMITS.GROUP_ID_BYTES * 2 || !/^[0-9a-f]+$/.test(groupId)) { fail("malformed group id", "bad_group_id"); } return groupId; } function assertFingerprint(fp) { if (typeof fp !== "string" || fp.length !== GROUP_LIMITS.FINGERPRINT_BYTES * 2 || !/^[0-9a-f]+$/.test(fp)) { fail("malformed member fingerprint", "bad_fingerprint"); } return fp; } function assertEpoch(epoch) { if (!Number.isInteger(epoch) || epoch < 0 || epoch > GROUP_LIMITS.MAX_EPOCH) { fail("epoch out of range", "bad_epoch"); } return epoch; } function assertName(name) { const value = typeof name === "string" ? name : ""; if (ENC.encode(value).length > GROUP_LIMITS.MAX_NAME_BYTES) fail("group name too long", "bad_name"); return value; } function canonicalFingerprints(fps) { if (!Array.isArray(fps)) fail("member list is not an array", "bad_members"); if (fps.length < GROUP_LIMITS.MIN_MEMBERS) fail("a group needs at least two members", "bad_members"); if (fps.length > GROUP_LIMITS.MAX_MEMBERS) fail(`a group is limited to ${GROUP_LIMITS.MAX_MEMBERS} members`, "too_many_members"); const seen = /* @__PURE__ */ new Set(); for (const fp of fps) { assertFingerprint(fp); if (seen.has(fp)) fail("duplicate member fingerprint", "duplicate_member"); seen.add(fp); } return [...fps].sort(); } async function generateGroupIdentity(subtle) { const keyPair = await subtle.generateKey( { name: "ECDSA", namedCurve: "P-384" }, false, ["sign", "verify"] ); const spki = new Uint8Array(await subtle.exportKey("spki", keyPair.publicKey)); const fingerprint = await fingerprintSpki(subtle, spki); return { keyPair, spki, fingerprint }; } async function fingerprintSpki(subtle, spki) { if (!(spki instanceof Uint8Array) || spki.length < GROUP_LIMITS.MIN_SPKI_BYTES || spki.length > GROUP_LIMITS.MAX_SPKI_BYTES) { fail("SPKI length out of range", "bad_spki"); } return toHex(new Uint8Array(await subtle.digest("SHA-256", spki))); } async function importMemberIdentity(subtle, spki) { const fingerprint = await fingerprintSpki(subtle, spki); let publicKey; try { publicKey = await subtle.importKey("spki", spki, { name: "ECDSA", namedCurve: "P-384" }, false, ["verify"]); } catch (_) { fail("member identity key is not a valid P-384 public key", "bad_spki"); } return { publicKey, fingerprint }; } async function buildCommitment(subtle, { groupId, epoch, fingerprint, nonce }) { assertGroupId(groupId); assertEpoch(epoch); assertFingerprint(fingerprint); if (!(nonce instanceof Uint8Array) || nonce.length !== GROUP_LIMITS.NONCE_BYTES) { fail("nonce must be 32 bytes", "bad_nonce"); } const payload = lp("securebit/group/commit/v1", fromHex(groupId), u32(epoch), fromHex(fingerprint), nonce); return new Uint8Array(await subtle.digest("SHA-256", payload)); } async function verifyCommitment(subtle, commitment, fields) { if (!(commitment instanceof Uint8Array) || commitment.length !== GROUP_LIMITS.COMMIT_BYTES) return false; let expected; try { expected = await buildCommitment(subtle, fields); } catch (_) { return false; } return equalBytes(commitment, expected); } async function computeGroupSas(subtle, { groupId, epoch, contributions, digits = GROUP_LIMITS.SAS_DIGITS }) { assertGroupId(groupId); assertEpoch(epoch); if (!Array.isArray(contributions)) fail("contributions must be an array", "bad_contributions"); if (!Number.isInteger(digits) || digits < 4 || digits > 12) fail("digit count out of range", "bad_digits"); canonicalFingerprints(contributions.map((c) => c && c.fingerprint)); const ordered = [...contributions].sort((a, b) => a.fingerprint < b.fingerprint ? -1 : 1); const parts = []; for (const c of ordered) { if (!(c.nonce instanceof Uint8Array) || c.nonce.length !== GROUP_LIMITS.NONCE_BYTES) { fail("every member must contribute a 32-byte nonce", "bad_nonce"); } parts.push(fromHex(c.fingerprint), c.nonce); } const ikm = lp("securebit/group/sas/v1", fromHex(groupId), u32(epoch), ...parts); const salt = new Uint8Array(await subtle.digest("SHA-256", lp("securebit/group/sas-salt/v1", fromHex(groupId), u32(epoch)))); let key = null; try { key = await subtle.importKey("raw", ikm, "HKDF", false, ["deriveBits"]); const bits = await subtle.deriveBits( { name: "HKDF", hash: "SHA-256", salt, info: ENC.encode("securebit-group-sas-v1") }, key, 64 ); const dv = new DataView(bits); const n = dv.getUint32(0) * 2 ** 20 + (dv.getUint32(4) >>> 12); return String(n % 10 ** digits).padStart(digits, "0"); } finally { try { ikm.fill(0); } catch (_) { } } } var GroupSasCeremony = class { constructor({ groupId, epoch, selfFingerprint, memberFingerprints }) { this.groupId = assertGroupId(groupId); this.epoch = assertEpoch(epoch); this.selfFingerprint = assertFingerprint(selfFingerprint); this.members = canonicalFingerprints(memberFingerprints); if (!this.members.includes(this.selfFingerprint)) { fail("the local member is not in the member set", "not_a_member"); } this.nonce = randomBytes(GROUP_LIMITS.NONCE_BYTES); this.commitments = /* @__PURE__ */ new Map(); this.nonces = /* @__PURE__ */ new Map(); this.revealed = false; this.code = null; } /** Our own commitment, to be broadcast first. */ async ownCommitment(subtle) { const commitment = await buildCommitment(subtle, { groupId: this.groupId, epoch: this.epoch, fingerprint: this.selfFingerprint, nonce: this.nonce }); this.commitments.set(this.selfFingerprint, commitment); return commitment; } /** * Record a peer commitment. Rejects anyone outside the member set, and * refuses to overwrite one already recorded — a second, different commitment * from the same member is an attempt to move after seeing more of the round. */ acceptCommitment(fingerprint, commitment) { assertFingerprint(fingerprint); if (!this.members.includes(fingerprint)) fail("commitment from a non-member", "not_a_member"); if (!(commitment instanceof Uint8Array) || commitment.length !== GROUP_LIMITS.COMMIT_BYTES) { fail("malformed commitment", "bad_commitment"); } const existing = this.commitments.get(fingerprint); if (existing) { if (!equalBytes(existing, commitment)) fail("member changed their commitment", "commitment_changed"); return false; } this.commitments.set(fingerprint, commitment); return true; } get commitmentsComplete() { return this.members.every((fp) => this.commitments.has(fp)); } /** * Our nonce — available ONLY once every commitment is in. * * This is the gate the whole construction rests on. Do not add a caller that * bypasses it, and do not "helpfully" relax it when a member is slow: a * timeout must fail the ceremony, never proceed without a commitment. */ reveal() { if (!this.commitmentsComplete) { fail("cannot reveal before every member has committed", "premature_reveal"); } this.revealed = true; this.nonces.set(this.selfFingerprint, this.nonce); return this.nonce; } /** Record a peer nonce, checking it against the commitment they are bound to. */ async acceptReveal(subtle, fingerprint, nonce) { assertFingerprint(fingerprint); if (!this.members.includes(fingerprint)) fail("reveal from a non-member", "not_a_member"); const commitment = this.commitments.get(fingerprint); if (!commitment) fail("reveal arrived before the commitment", "reveal_without_commitment"); const ok = await verifyCommitment(subtle, commitment, { groupId: this.groupId, epoch: this.epoch, fingerprint, nonce }); if (!ok) fail("revealed nonce does not match the commitment", "commitment_mismatch"); this.nonces.set(fingerprint, nonce); return true; } get revealsComplete() { return this.members.every((fp) => this.nonces.has(fp)); } /** The digits, once every nonce is in and verified. */ async finish(subtle) { if (!this.revealsComplete) fail("not every member has revealed", "incomplete_reveal"); this.code = await computeGroupSas(subtle, { groupId: this.groupId, epoch: this.epoch, contributions: this.members.map((fp) => ({ fingerprint: fp, nonce: this.nonces.get(fp) })) }); return this.code; } /** Wipe the nonce material once the code exists or the ceremony is abandoned. */ destroy() { try { this.nonce.fill(0); } catch (_) { } for (const n of this.nonces.values()) { try { n.fill(0); } catch (_) { } } this.nonces.clear(); this.commitments.clear(); } }; function memberOpPayload({ groupId, epoch, op, memberFps, name = "" }) { assertGroupId(groupId); assertEpoch(epoch); if (!Object.values(MEMBER_OPS).includes(op)) fail("unknown membership operation", "bad_op"); const ordered = canonicalFingerprints(memberFps); return lp( "securebit/group/member-op/v1", fromHex(groupId), u32(epoch), op, assertName(name), ...ordered.map((fp) => fromHex(fp)) ); } async function signMemberOp(subtle, privateKey, fields) { const sig = await subtle.sign({ name: "ECDSA", hash: "SHA-384" }, privateKey, memberOpPayload(fields)); return new Uint8Array(sig); } async function verifyMemberOp(subtle, publicKey, fields, signature) { if (!(signature instanceof Uint8Array) || signature.length < GROUP_LIMITS.MIN_SIG_BYTES || signature.length > GROUP_LIMITS.MAX_SIG_BYTES) { return false; } let payload; try { payload = memberOpPayload(fields); } catch (_) { return false; } try { return await subtle.verify({ name: "ECDSA", hash: "SHA-384" }, publicKey, signature, payload); } catch (_) { return false; } } async function hashBody(subtle, body) { const bytes = typeof body === "string" ? ENC.encode(body) : body; if (!(bytes instanceof Uint8Array)) fail("message body must be a string or bytes", "bad_body"); if (bytes.length > GROUP_LIMITS.MAX_BODY_BYTES) fail("message body exceeds the group limit", "body_too_large"); return new Uint8Array(await subtle.digest("SHA-256", bytes)); } function groupMessagePayload({ groupId, epoch, seq, senderFp, bodyHash }) { assertGroupId(groupId); assertEpoch(epoch); assertEpoch(seq); assertFingerprint(senderFp); if (!(bodyHash instanceof Uint8Array) || bodyHash.length !== 32) fail("body hash must be 32 bytes", "bad_body_hash"); return lp("securebit/group/message/v1", fromHex(groupId), u32(epoch), u32(seq), fromHex(senderFp), bodyHash); } async function signGroupMessage(subtle, privateKey, fields) { const sig = await subtle.sign({ name: "ECDSA", hash: "SHA-384" }, privateKey, groupMessagePayload(fields)); return new Uint8Array(sig); } async function verifyGroupMessage(subtle, publicKey, fields, signature) { if (!(signature instanceof Uint8Array) || signature.length < GROUP_LIMITS.MIN_SIG_BYTES || signature.length > GROUP_LIMITS.MAX_SIG_BYTES) { return false; } let payload; try { payload = groupMessagePayload(fields); } catch (_) { return false; } try { return await subtle.verify({ name: "ECDSA", hash: "SHA-384" }, publicKey, signature, payload); } catch (_) { return false; } } function meshDescriptorPayload({ groupId, epoch, kind, fromFp, toFp, descriptor, nonce }) { assertGroupId(groupId); assertEpoch(epoch); if (kind !== MESH_KINDS.OFFER && kind !== MESH_KINDS.ANSWER) { fail("unknown mesh descriptor kind", "bad_mesh_kind"); } assertFingerprint(fromFp); assertFingerprint(toFp); if (fromFp === toFp) fail("a member cannot dial itself", "bad_mesh_peer"); if (typeof descriptor !== "string" || descriptor.length === 0 || descriptor.length > GROUP_LIMITS.MAX_DESCRIPTOR_CHARS) { fail("mesh descriptor is missing or oversized", "bad_descriptor"); } if (!(nonce instanceof Uint8Array) || nonce.length !== GROUP_LIMITS.MESH_NONCE_BYTES) { fail("mesh nonce must be 16 bytes", "bad_mesh_nonce"); } return lp( "securebit/group/mesh-descriptor/v1", fromHex(groupId), u32(epoch), kind, fromHex(fromFp), fromHex(toFp), descriptor, nonce ); } async function signMeshDescriptor(subtle, privateKey, fields) { const sig = await subtle.sign({ name: "ECDSA", hash: "SHA-384" }, privateKey, meshDescriptorPayload(fields)); return new Uint8Array(sig); } async function verifyMeshDescriptor(subtle, publicKey, fields, signature) { if (!(signature instanceof Uint8Array) || signature.length < GROUP_LIMITS.MIN_SIG_BYTES || signature.length > GROUP_LIMITS.MAX_SIG_BYTES) { return false; } let payload; try { payload = meshDescriptorPayload(fields); } catch (_) { return false; } try { return await subtle.verify({ name: "ECDSA", hash: "SHA-384" }, publicKey, signature, payload); } catch (_) { return false; } } function linkProbePayload({ groupId, epoch, fp, linkFp }) { assertGroupId(groupId); assertEpoch(epoch); assertFingerprint(fp); if (typeof linkFp !== "string" || linkFp.length === 0 || linkFp.length > 256) { fail("link fingerprint is missing or oversized", "bad_link_fp"); } return lp("securebit/group/link-probe/v1", fromHex(groupId), u32(epoch), fromHex(fp), linkFp); } async function signLinkProbe(subtle, privateKey, fields) { const sig = await subtle.sign({ name: "ECDSA", hash: "SHA-384" }, privateKey, linkProbePayload(fields)); return new Uint8Array(sig); } async function verifyLinkProbe(subtle, publicKey, fields, signature) { if (!(signature instanceof Uint8Array) || signature.length < GROUP_LIMITS.MIN_SIG_BYTES || signature.length > GROUP_LIMITS.MAX_SIG_BYTES) { return false; } let payload; try { payload = linkProbePayload(fields); } catch (_) { return false; } try { return await subtle.verify({ name: "ECDSA", hash: "SHA-384" }, publicKey, signature, payload); } catch (_) { return false; } } // src/state/groupsStore.js var GROUP_ACTIONS = Object.freeze({ CREATE_GROUP: "CREATE_GROUP", REMOVE_GROUP: "REMOVE_GROUP", SET_ACTIVE_GROUP: "SET_ACTIVE_GROUP", SET_PHASE: "SET_PHASE", SET_MEMBERS: "SET_MEMBERS", PATCH_MEMBER: "PATCH_MEMBER", SET_SAS: "SET_SAS", CONFIRM_SAS: "CONFIRM_SAS", ADD_MESSAGE: "ADD_MESSAGE", SET_MESSAGES: "SET_MESSAGES", UPDATE_MESSAGE_STATUS: "UPDATE_MESSAGE_STATUS", INCREMENT_UNREAD: "INCREMENT_UNREAD", CLEAR_UNREAD: "CLEAR_UNREAD", RENAME: "RENAME", SET_ERROR: "SET_ERROR" }); var GROUP_PHASE = Object.freeze({ FORMING: "forming", // members chosen, identity keys being exchanged COMMITTING: "committing", // commitments in flight REVEALING: "revealing", // every commitment in, nonces in flight AWAITING_SAS: "awaiting_sas", // code computed, waiting for the humans READY: "ready", // confirmed; group traffic flows FAILED: "failed" // ceremony aborted; nothing flows }); var MEMBER_STATE = Object.freeze({ SELF: "self", LINKED: "linked", // pairwise session up and SAS-verified PENDING: "pending", // session exists but is not verified/connected yet LOST: "lost" // was linked, connection dropped }); var GROUP_PHASE_WORD = { [GROUP_PHASE.FORMING]: "Forming\u2026", [GROUP_PHASE.COMMITTING]: "Exchanging commitments\u2026", [GROUP_PHASE.REVEALING]: "Revealing\u2026", [GROUP_PHASE.AWAITING_SAS]: "Compare the group code", [GROUP_PHASE.READY]: "Group ready", [GROUP_PHASE.FAILED]: "Group failed" }; function groupInitials(name) { const words = String(name || "").trim().split(/\s+/).filter(Boolean); const a = words[0]?.[0] || ""; const b = words[1]?.[0] || words[0]?.[1] || ""; return (a + b).toUpperCase() || "##"; } function createGroupEntry(opts = {}) { return { id: opts.id, name: opts.name || "Group", createdAt: opts.createdAt || Date.now(), adminFp: opts.adminFp || "", selfFp: opts.selfFp || "", isAdmin: !!opts.isAdmin, epoch: Number.isInteger(opts.epoch) ? opts.epoch : 1, phase: opts.phase || GROUP_PHASE.FORMING, // members: [{ fp, name, sessionId, state }]. Always stored in canonical // fingerprint order so every device renders the same list. members: Array.isArray(opts.members) ? [...opts.members].sort(byFingerprint) : [], sasCode: "", sasConfirmed: false, messages: [], unreadCount: 0, error: null }; } var NAME_ENCODER = new TextEncoder(); function clampNameBytes(value) { let out = String(value); while (NAME_ENCODER.encode(out).length > GROUP_LIMITS.MAX_NAME_BYTES) out = out.slice(0, -1); return out; } function byFingerprint(a, b) { return a.fp < b.fp ? -1 : a.fp > b.fp ? 1 : 0; } function createInitialGroupState() { return { groups: {}, order: [], activeGroupId: null }; } function patchGroup(state, id, patch) { const group = state.groups[id]; if (!group) return state; return { ...state, groups: { ...state.groups, [id]: { ...group, ...patch } } }; } function groupsReducer(state, action) { const A = GROUP_ACTIONS; switch (action.type) { case A.CREATE_GROUP: { const entry = action.entry || createGroupEntry(action); if (!entry.id || state.groups[entry.id]) return state; return { groups: { ...state.groups, [entry.id]: entry }, order: [...state.order, entry.id], activeGroupId: action.activate === false ? state.activeGroupId : entry.id }; } case A.REMOVE_GROUP: { const { id } = action; if (!state.groups[id]) return state; const groups = { ...state.groups }; delete groups[id]; const order = state.order.filter((x) => x !== id); let activeGroupId = state.activeGroupId; if (activeGroupId === id) { const removedIdx = state.order.indexOf(id); activeGroupId = order[Math.max(0, removedIdx - 1)] || order[0] || null; } return { groups, order, activeGroupId }; } case A.SET_ACTIVE_GROUP: { if (action.id === null) { return state.activeGroupId === null ? state : { ...state, activeGroupId: null }; } if (!state.groups[action.id] || state.activeGroupId === action.id) return state; return { ...state, activeGroupId: action.id }; } case A.SET_PHASE: { const group = state.groups[action.id]; if (!group || group.phase === action.phase) return state; const patch = { phase: action.phase }; if (action.phase !== GROUP_PHASE.READY && group.sasConfirmed) { patch.sasConfirmed = false; } if (action.phase !== GROUP_PHASE.READY && action.phase !== GROUP_PHASE.AWAITING_SAS) { patch.sasCode = ""; } if (action.phase !== GROUP_PHASE.FAILED) patch.error = null; return patchGroup(state, action.id, patch); } case A.SET_MEMBERS: { const group = state.groups[action.id]; if (!group) return state; const members = Array.isArray(action.members) ? [...action.members].sort(byFingerprint) : group.members; const patch = { members }; if (Number.isInteger(action.epoch)) patch.epoch = action.epoch; return patchGroup(state, action.id, patch); } case A.PATCH_MEMBER: { const group = state.groups[action.id]; if (!group) return state; let changed = false; const members = group.members.map((m) => { if (m.fp !== action.fp) return m; const next = { ...m, ...action.patch }; if (Object.keys(action.patch).every((k) => m[k] === next[k])) return m; changed = true; return next; }); return changed ? patchGroup(state, action.id, { members }) : state; } case A.SET_SAS: { const group = state.groups[action.id]; if (!group || group.sasCode === action.code) return state; return patchGroup(state, action.id, { sasCode: action.code || "", sasConfirmed: false }); } case A.CONFIRM_SAS: { const group = state.groups[action.id]; if (!group) return state; if (!group.sasCode) return state; if (group.phase !== GROUP_PHASE.AWAITING_SAS) return state; if (group.sasConfirmed && group.phase === GROUP_PHASE.READY) return state; return patchGroup(state, action.id, { sasConfirmed: true, phase: GROUP_PHASE.READY, error: null }); } case A.ADD_MESSAGE: { const group = state.groups[action.id]; if (!group) return state; return patchGroup(state, action.id, { messages: [...group.messages, action.message] }); } case A.SET_MESSAGES: { const group = state.groups[action.id]; if (!group) return state; const next = typeof action.updater === "function" ? action.updater(group.messages) : action.messages; return patchGroup(state, action.id, { messages: Array.isArray(next) ? next : [] }); } case A.UPDATE_MESSAGE_STATUS: { const group = state.groups[action.id]; if (!group) return state; let changed = false; const messages = group.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 ? patchGroup(state, action.id, { messages }) : state; } case A.INCREMENT_UNREAD: { const group = state.groups[action.id]; if (!group) return state; return patchGroup(state, action.id, { unreadCount: group.unreadCount + 1 }); } case A.CLEAR_UNREAD: { const group = state.groups[action.id]; if (!group || group.unreadCount === 0) return state; return patchGroup(state, action.id, { unreadCount: 0 }); } case A.RENAME: { const group = state.groups[action.id]; if (!group) return state; const name = clampNameBytes(String(action.name || "").trim()) || group.name; return patchGroup(state, action.id, { name }); } case A.SET_ERROR: { const group = state.groups[action.id]; if (!group) return state; const patch = { error: action.error || null }; if (action.error) patch.phase = GROUP_PHASE.FAILED; return patchGroup(state, action.id, patch); } default: return state; } } function linkedCount(group) { return group.members.filter((m) => m.state === MEMBER_STATE.SELF || m.state === MEMBER_STATE.LINKED).length; } function groupSub(group) { if (group.phase !== GROUP_PHASE.READY) return GROUP_PHASE_WORD[group.phase] || "Group"; const total = group.members.length; const linked = linkedCount(group); if (linked < total) return `${linked} of ${total} connected`; return `${total} members \xB7 P2P mesh`; } function groupDot(group) { switch (group.phase) { case GROUP_PHASE.READY: return linkedCount(group) < group.members.length ? "#e3b341" : "#3ecf8e"; case GROUP_PHASE.FAILED: return "#e5727a"; default: return "#e3b341"; } } function decorateGroup(group, activeGroupId) { const lastMessage = [...group.messages].reverse().find( (m) => !m.expired && typeof m.message === "string" && m.message.trim() ); const sub = groupSub(group); return { id: group.id, kind: "group", name: group.name, mono: groupInitials(group.name), dot: groupDot(group), headerSub: sub, phase: group.phase, memberCount: group.members.length, linkedCount: linkedCount(group), preview: lastMessage ? lastMessage.message : sub, unread: group.unreadCount > 0 ? group.unreadCount > 99 ? "99+" : String(group.unreadCount) : null, verified: group.phase === GROUP_PHASE.READY && group.sasConfirmed, active: group.id === activeGroupId, inactive: group.id !== activeGroupId }; } function decorateGroups(state) { return state.order.map((id) => state.groups[id]).filter(Boolean).map((g) => decorateGroup(g, state.activeGroupId)); } // src/group/GroupSession.js var GROUP_FRAMES = Object.freeze({ INVITE: "g_invite", HELLO: "g_hello", MEMBER: "g_member", ROSTER: "g_roster", COMMIT: "g_commit", REVEAL: "g_reveal", MESSAGE: "g_msg", RELAY: "g_relay", LEAVE: "g_leave", // Mesh link establishment. These carry SBQ2 descriptors between two members // who have no link yet, over the relay path of a member who can reach both. MESH_OFFER: "g_moffer", MESH_ANSWER: "g_manswer", MESH_ABORT: "g_mabort", // "The pairwise chat this arrived on is me, member ." PROBE: "g_probe" }); var GROUP_ENVELOPE = "g_env"; var GROUP_FRAME_TYPES = Object.freeze(new Set(Object.values(GROUP_FRAMES))); function isGroupFrame(parsed) { if (!parsed || typeof parsed !== "object") return false; return parsed.type === GROUP_ENVELOPE || GROUP_FRAME_TYPES.has(parsed.type); } function groupFrameType(parsed) { if (!parsed || typeof parsed !== "object") return null; if (parsed.type === GROUP_ENVELOPE) return typeof parsed.t === "string" ? parsed.t : null; return GROUP_FRAME_TYPES.has(parsed.type) ? parsed.type : null; } function encodeEnvelope(frame) { const json = JSON.stringify(frame); const encoded = toB64(new TextEncoder().encode(json)); if (encoded.length > GROUP_LIMITS.FRAME_BUDGET_CHARS) { throw new GroupSessionError("group frame exceeds the transport budget", "frame_too_large"); } return { type: GROUP_ENVELOPE, gid: frame.gid, t: frame.type, d: encoded }; } function decodeEnvelope(envelope) { if (!envelope || envelope.type !== GROUP_ENVELOPE) return envelope; const raw = String(envelope.d || ""); if (raw.length > GROUP_LIMITS.FRAME_BUDGET_CHARS) { throw new GroupSessionError("group frame exceeds the transport budget", "frame_too_large"); } const bytes = fromB64(raw, { max: GROUP_LIMITS.FRAME_BUDGET_CHARS }); const frame = JSON.parse(new TextDecoder().decode(bytes)); if (!frame || typeof frame !== "object" || !GROUP_FRAME_TYPES.has(frame.type)) { throw new GroupSessionError("envelope carried no recognisable frame", "bad_envelope"); } if (envelope.gid && frame.gid !== envelope.gid) { throw new GroupSessionError("envelope group id does not match its frame", "bad_envelope"); } if (envelope.t && frame.type !== envelope.t) { throw new GroupSessionError("envelope type does not match its frame", "bad_envelope"); } return frame; } var TIMEOUTS = Object.freeze({ // A member that has not committed by now is treated as absent and the // ceremony fails. It must FAIL rather than proceed: proceeding without a // commitment is exactly the grinding freedom the commit round removes. CEREMONY_MS: 6e4, // How long the admin waits for invitees to publish their identity keys. HELLO_MS: 45e3, // How long one mesh dial may stay in flight. It covers a descriptor going // out over a relay hop, an answer coming back, and the whole SBQ2 in-band // exchange completing on the new channel. Generous, because failing early // costs a direct link and gains nothing: the pair keeps working over the // relay the entire time the dial is running. MESH_DIAL_MS: 45e3, // Gap before a failed pair is dialled again, doubling per failure. A pair // that cannot connect is usually a network that will not allow it, and // retrying hard turns one unreachable member into a permanent load. MESH_RETRY_MS: 2e4 }); var MESH_MAX_CONCURRENT_DIALS = 2; var MESH_MAX_ATTEMPTS = 3; var GroupSessionError = class extends Error { constructor(message, code = "group_session") { super(message); this.name = "GroupSessionError"; this.code = code; } }; var GroupSession = class { /** * @param {object} opts * @param {string} opts.groupId * @param {string} opts.name * @param {boolean} opts.isAdmin * @param {SubtleCrypto} opts.subtle * @param {(sessionId: string, frame: object) => Promise} opts.send * @param {(event: string, payload: object) => void} opts.emit */ constructor({ groupId, name, isAdmin, subtle, send, emit, mesh = null, log = () => { } }) { this.groupId = assertGroupId(groupId); this.name = assertName(name); this.isAdmin = !!isAdmin; this.subtle = subtle; this._send = send; this._emit = emit; this._log = log; this._mesh = mesh; this.identity = null; this.epoch = 1; this.adminFp = ""; this.phase = GROUP_PHASE.FORMING; this.members = /* @__PURE__ */ new Map(); this.sessionToFp = /* @__PURE__ */ new Map(); this.ceremony = null; this.sasCode = ""; this.sasConfirmed = false; this.seq = 0; this.transcript = /* @__PURE__ */ new Map(); this._timers = /* @__PURE__ */ new Set(); this._destroyed = false; this._awaitingHello = /* @__PURE__ */ new Map(); this._pendingCeremony = []; this._draining = false; this._pendingKeys = /* @__PURE__ */ new Map(); this._pendingAdd = null; this._meshDials = /* @__PURE__ */ new Map(); this._meshFailures = /* @__PURE__ */ new Map(); this._meshSessions = /* @__PURE__ */ new Set(); this._probed = /* @__PURE__ */ new Set(); this._meshPass = null; } // ----------------------------------------------------------------------- // lifecycle // ----------------------------------------------------------------------- static newId() { return newGroupId(); } async init() { if (this.identity) return this.identity; this.identity = await generateGroupIdentity(this.subtle); this.members.set(this.identity.fingerprint, { fp: this.identity.fingerprint, name: "You", spki: this.identity.spki, publicKey: null, // our own key verifies nothing inbound sessionId: null, state: MEMBER_STATE.SELF }); if (this.isAdmin) this.adminFp = this.identity.fingerprint; return this.identity; } get selfFp() { return this.identity?.fingerprint || ""; } destroy() { this._destroyed = true; for (const t of this._timers) clearTimeout(t); this._timers.clear(); try { this.ceremony?.destroy(); } catch (_) { } this.ceremony = null; for (const sessionId of this._meshSessions) { try { this._mesh?.close(sessionId); } catch (_) { } } this._meshSessions.clear(); this._meshDials.clear(); this._meshFailures.clear(); this._probed.clear(); this.members.clear(); this.sessionToFp.clear(); this.transcript.clear(); this._pendingCeremony = []; this._pendingKeys.clear(); this._pendingAdd = null; this.identity = null; } _timer(fn, ms) { const t = setTimeout(() => { this._timers.delete(t); if (!this._destroyed) fn(); }, ms); this._timers.add(t); return t; } _fail(code) { if (this._destroyed) return; this._log("warn", "group ceremony failed", { code }); this.phase = GROUP_PHASE.FAILED; try { this.ceremony?.destroy(); } catch (_) { } this.ceremony = null; this._emit("error", { error: code }); } _setPhase(phase) { if (this.phase === phase) return; this.phase = phase; if (phase !== GROUP_PHASE.READY) this.sasConfirmed = false; if (phase !== GROUP_PHASE.READY && phase !== GROUP_PHASE.AWAITING_SAS) { this.sasCode = ""; } this._emit("phase", { phase }); } /** The member list in the shape the reducer stores. */ _memberSnapshot() { return [...this.members.values()].map((m) => ({ fp: m.fp, name: m.name, sessionId: m.sessionId, state: m.state })); } _emitMembers() { this._emit("members", { members: this._memberSnapshot(), epoch: this.epoch }); } // ----------------------------------------------------------------------- // routing // ----------------------------------------------------------------------- /** Members we can reach over their own pairwise link right now. */ _directPeers() { return [...this.members.values()].filter( (m) => m.state === MEMBER_STATE.LINKED && m.sessionId ); } /** * Whoever can carry a frame to a member we cannot reach ourselves. * * The admin is preferred because by construction it holds a link to every * member; any other directly-linked member is a fallback for when the admin * is the one that has gone away. */ _relayFor(toFp) { const admin = this.members.get(this.adminFp); if (admin && admin.state === MEMBER_STATE.LINKED && admin.sessionId && admin.fp !== toFp) { return admin; } return this._directPeers().find((m) => m.fp !== toFp) || null; } /** * Put a frame on the wire, wrapped. * * Every outbound frame goes through here so the envelope is applied in * exactly one place — a frame sent raw would be silently mangled by the * chat path's sanitiser rather than rejected, which is the worst way for * this to fail. */ async _wire(sessionId, frame) { return this._send(sessionId, encodeEnvelope(frame)); } /** Send one frame to one member, directly if we can and relayed if we cannot. */ async _sendTo(toFp, frame) { const member = this.members.get(toFp); if (!member || member.fp === this.selfFp) return false; if (member.state === MEMBER_STATE.LINKED && member.sessionId) { await this._wire(member.sessionId, frame); return true; } const relay = this._relayFor(toFp); if (!relay) return false; await this._wire(relay.sessionId, { type: GROUP_FRAMES.RELAY, gid: this.groupId, to: toFp, hopped: false, inner: frame }); return member.state !== MEMBER_STATE.LOST; } /** * Fan a frame out to every other member. Failures are per-recipient. * * Returns WHO could not be reached as well as how many could, because a * count on its own cannot tell "Alice is offline" from "Bob is offline" — * and the sender is the only person in a position to know the difference. */ async _broadcast(frame, { exclude = [] } = {}) { const targets = [...this.members.keys()].filter( (fp) => fp !== this.selfFp && !exclude.includes(fp) ); const results = await Promise.allSettled(targets.map((fp) => this._sendTo(fp, frame))); const unreachable = []; let delivered = 0; results.forEach((result, i) => { if (result.status === "fulfilled" && result.value === true) { delivered += 1; return; } const member = this.members.get(targets[i]); unreachable.push({ fp: targets[i], name: member?.name || "A member" }); }); return { delivered, unreachable }; } // ----------------------------------------------------------------------- // link bookkeeping (driven by the app as pairwise sessions come and go) // ----------------------------------------------------------------------- /** * Bind a pairwise session to a member. Called for the admin's own invites and * whenever a member is reached over a session for the first time. */ bindSession(fp, sessionId, state = MEMBER_STATE.LINKED) { const member = this.members.get(fp); if (!member) return false; if (member.sessionId && member.sessionId !== sessionId) { this.sessionToFp.delete(member.sessionId); this._closeMeshSession(member.sessionId); } member.sessionId = sessionId; member.state = state; if (sessionId) this.sessionToFp.set(sessionId, fp); this._emitMembers(); return true; } /** * Detach a member from whatever link it was on, back to the relay path. * * Used when a mesh dial fails: the half-built session is closed and the * member returns to being reachable only through someone else, which is * where they were before the dial started. Deliberately does NOT change the * member's state to LOST — they are not offline, we just have no direct * route to them. */ unbindSession(fp) { const member = this.members.get(fp); if (!member || !member.sessionId) return false; const sessionId = member.sessionId; this.sessionToFp.delete(sessionId); member.sessionId = null; if (member.state === MEMBER_STATE.LINKED || member.state === MEMBER_STATE.LOST) { member.state = MEMBER_STATE.PENDING; } this._closeMeshSession(sessionId); this._emitMembers(); this._scheduleMeshMaintain(); return true; } /** A pairwise session changed state; reflect it on whichever member owns it. */ setSessionState(sessionId, connected) { const fp = this.sessionToFp.get(sessionId); if (!fp) return; const member = this.members.get(fp); if (!member || member.state === MEMBER_STATE.SELF) return; const next = connected ? MEMBER_STATE.LINKED : MEMBER_STATE.LOST; if (member.state === next) return; member.state = next; this._emitMembers(); if (connected) this._settleDial(fp); this._scheduleMeshMaintain(); } // ----------------------------------------------------------------------- // the mesh // ----------------------------------------------------------------------- // // Every pair that has no link between it dials one, so that the relay path // becomes the exception it was always described as rather than the way the // whole group runs. // // WHO DIALS // --------- // The member with the smaller fingerprint. That is the entire glare // protocol: both sides compute it from the roster they already agree on, so // exactly one side opens each pair and there is no simultaneous-offer case // to resolve. A member that receives an offer from someone it should have // been dialling ITSELF refuses it — either the peer is confused or somebody // is trying to get two half-open dials fighting over one pair. // // WHEN // ---- // Only once the group is READY and the safety code is confirmed. Before // that, the roster's identity keys are keys nobody has vouched for yet, and // a link authenticated by an unconfirmed key is a link authenticated by // nothing. Waiting costs a few seconds of relayed traffic. // // HOW IT IS AUTHENTICATED // ----------------------- // The descriptors travel through a relay, so they are signed with the // sender's group identity key and checked against the roster — see // meshDescriptorPayload in groupCrypto.js for why that is enough and what // it deliberately does not defend against. Once the transport is up, the // SBQ2 in-band exchange runs on it exactly as it does for a 1:1 chat, with // one difference: nobody is asked to compare digits, because the group code // already authenticated the key that signed the descriptor. The app closes // that loop by marking the link verified on the group's authority. /** * Is this dial still worth finishing? * * Re-checked after EVERY await inside a dial, and that is not defensive * padding — both awaits are long. Building a descriptor means gathering ICE, * and verifying a signature is a trip through WebCrypto; either is ample time * for the answer to change. * * The case that made this necessary: a link probe adopts a chat the two * members already had while a dial for the same pair is mid-flight. The * probe binds a link that works. The dial then came back and bound its own * half-built session over the top, downgrading a live link to a pending one * and leaving the pair relaying to each other over a connection that was * never needed. Checking the member's session — not just our own dial * bookkeeping — is what catches that. */ _dialStillWanted(fp, dial) { if (this._destroyed) return false; if (this._meshDials.get(fp) !== dial) return false; if (this.epoch !== dial.epoch) return false; const member = this.members.get(fp); if (!member) return false; if (member.sessionId && member.sessionId !== dial.sessionId) return false; return true; } /** Drop a dial that is no longer wanted, without recording it as a failure. */ _abandonDial(fp, dial, sessionId) { if (this._meshDials.get(fp) === dial) { if (dial.timer) { clearTimeout(dial.timer); this._timers.delete(dial.timer); } this._meshDials.delete(fp); } this._meshSessions.delete(sessionId); try { this._mesh?.close(sessionId); } catch (_) { } } /** Close and forget a connection this group opened. Never touches a chat. */ _closeMeshSession(sessionId) { if (!sessionId || !this._meshSessions.has(sessionId)) return; this._meshSessions.delete(sessionId); try { this._mesh?.close(sessionId); } catch (_) { } } /** A dial is over, one way or another. Stops its timer and frees the slot. */ _settleDial(fp) { const dial = this._meshDials.get(fp); if (!dial) return; if (dial.timer) { clearTimeout(dial.timer); this._timers.delete(dial.timer); } this._meshDials.delete(fp); this._meshFailures.delete(fp); } /** * Give up on one pair, for now. * * The half-built connection is closed and the member goes back to being * reached through somebody else — which is where they were before the dial * started, so nothing the user can see gets worse. The backoff doubles per * attempt because a pair that cannot connect is usually a network that will * not allow it, and hammering at that produces load rather than links. */ _meshFail(fp, code, { tellPeer = true } = {}) { const dial = this._meshDials.get(fp); if (dial) { if (dial.timer) { clearTimeout(dial.timer); this._timers.delete(dial.timer); } this._meshDials.delete(fp); const member = this.members.get(fp); if (dial.sessionId && member && member.sessionId === dial.sessionId) { this.unbindSession(fp); } else { this._closeMeshSession(dial.sessionId); } } const failure = this._meshFailures.get(fp) || { attempts: 0, nextAt: 0 }; failure.attempts += 1; failure.nextAt = Date.now() + TIMEOUTS.MESH_RETRY_MS * 2 ** (failure.attempts - 1); this._meshFailures.set(fp, failure); this._log("warn", "mesh dial failed", { code, attempts: failure.attempts }); if (tellPeer && this.members.has(fp)) { this._sendTo(fp, { type: GROUP_FRAMES.MESH_ABORT, gid: this.groupId, epoch: this.epoch, from: this.selfFp, to: fp }).catch(() => { }); } this._scheduleMeshMaintain(); } /** * Cancel every dial in flight and forget every backoff. * * Called when the epoch moves. A dial signed against the old epoch will not * verify against the new one, and a pair that could not connect under the * old membership deserves a fresh chance under the new one. Links that are * already up are untouched — _onRoster carries them across. */ _meshReset() { for (const fp of [...this._meshDials.keys()]) { const dial = this._meshDials.get(fp); if (dial?.timer) { clearTimeout(dial.timer); this._timers.delete(dial.timer); } this._meshDials.delete(fp); const member = this.members.get(fp); if (dial?.sessionId && member && member.sessionId === dial.sessionId) { this.unbindSession(fp); } else { this._closeMeshSession(dial?.sessionId); } } this._meshFailures.clear(); this._probed.clear(); } /** * Ask for a maintenance pass soon rather than now. * * Every edge that could change the answer calls this — a link coming up, a * dial failing, the code being confirmed — and several of them fire in a * burst. Coalescing them means one pass sees the settled picture instead of * several passes each acting on a half-updated one. */ _scheduleMeshMaintain() { if (this._destroyed || !this._mesh || this._meshPass) return; this._meshPass = this._timer(() => { this._meshPass = null; this._meshMaintain(); }, 0); } /** Open dials for whoever still has no link, within the concurrency limit. */ _meshMaintain() { if (this._destroyed || !this._mesh) return; if (this.phase !== GROUP_PHASE.READY || !this.sasConfirmed) return; const now = Date.now(); let inFlight = this._meshDials.size; let soonest = Infinity; for (const fp of canonicalFingerprints([...this.members.keys()])) { if (inFlight >= MESH_MAX_CONCURRENT_DIALS) break; const member = this.members.get(fp); if (!member || member.state === MEMBER_STATE.SELF) continue; if (member.sessionId) continue; if (this._meshDials.has(fp)) continue; if (!(this.selfFp < fp)) continue; const failure = this._meshFailures.get(fp); if (failure) { if (failure.attempts >= MESH_MAX_ATTEMPTS) continue; if (now < failure.nextAt) { soonest = Math.min(soonest, failure.nextAt); continue; } } if (!this._relayFor(fp)) continue; inFlight += 1; this._meshDial(fp).catch(() => { }); } if (soonest !== Infinity && !this._meshPass) { this._meshPass = this._timer(() => { this._meshPass = null; this._meshMaintain(); }, Math.max(0, soonest - now) + 50); } } /** Build a descriptor for one peer, sign it, and put it on the relay path. */ async _meshDial(fp) { const member = this.members.get(fp); if (!member || member.sessionId || this._meshDials.has(fp)) return; const epoch = this.epoch; const nonce = randomBytes(GROUP_LIMITS.MESH_NONCE_BYTES); const dial = { role: "offer", sessionId: null, nonce, epoch, timer: null }; this._meshDials.set(fp, dial); try { const link = await this._mesh.createOffer(fp); const sessionId = link && link.sessionId; const descriptor = String(link && link.descriptor || ""); if (!sessionId || !descriptor) throw new GroupSessionError("mesh transport produced no descriptor", "no_descriptor"); if (!this._dialStillWanted(fp, dial)) return this._abandonDial(fp, dial, sessionId); dial.sessionId = sessionId; this._meshSessions.add(sessionId); this.bindSession(fp, sessionId, MEMBER_STATE.PENDING); const sig = await signMeshDescriptor(this.subtle, this.identity.keyPair.privateKey, { groupId: this.groupId, epoch, kind: MESH_KINDS.OFFER, fromFp: this.selfFp, toFp: fp, descriptor, nonce }); const sent = await this._sendTo(fp, { type: GROUP_FRAMES.MESH_OFFER, gid: this.groupId, epoch, from: this.selfFp, to: fp, d: descriptor, n: toB64(nonce), sig: toB64(sig) }); if (!sent) throw new GroupSessionError("no route to carry the dial", "unreachable"); dial.timer = this._timer(() => this._meshFail(fp, "dial_timeout"), TIMEOUTS.MESH_DIAL_MS); } catch (error) { this._meshFail(fp, error?.code || "dial_failed"); } } async _onMeshOffer(frame) { if (!this._mesh || this._destroyed) return; const from = assertFingerprint(String(frame.from || "")); if (assertFingerprint(String(frame.to || "")) !== this.selfFp) return; if (assertEpoch(frame.epoch) !== this.epoch) return; if (this.phase !== GROUP_PHASE.READY || !this.sasConfirmed) return; const member = this.members.get(from); if (!member || !member.publicKey) { throw new GroupSessionError("mesh dial from a non-member", "not_a_member"); } if (member.sessionId) return; if (this._meshDials.has(from)) return; if (this.selfFp < from) return; const descriptor = String(frame.d || ""); if (!descriptor || descriptor.length > GROUP_LIMITS.MAX_DESCRIPTOR_CHARS) { throw new GroupSessionError("mesh descriptor is missing or oversized", "bad_descriptor"); } const nonce = fromB64(String(frame.n || ""), { max: GROUP_LIMITS.MESH_NONCE_BYTES }); const ok = await verifyMeshDescriptor(this.subtle, member.publicKey, { groupId: this.groupId, epoch: this.epoch, kind: MESH_KINDS.OFFER, fromFp: from, toFp: this.selfFp, descriptor, nonce }, fromB64(String(frame.sig || ""), { max: GROUP_LIMITS.MAX_SIG_BYTES })); if (!ok) throw new GroupSessionError("mesh dial signature did not verify", "bad_signature"); if (this._destroyed || member.sessionId || this._meshDials.has(from)) return; if (assertEpoch(frame.epoch) !== this.epoch) return; const epoch = this.epoch; const dial = { role: "answer", sessionId: null, nonce, epoch, timer: null }; this._meshDials.set(from, dial); try { const link = await this._mesh.createAnswer(from, descriptor); const sessionId = link && link.sessionId; const answer = String(link && link.descriptor || ""); if (!sessionId || !answer) throw new GroupSessionError("mesh transport produced no answer", "no_descriptor"); if (!this._dialStillWanted(from, dial)) return this._abandonDial(from, dial, sessionId); dial.sessionId = sessionId; this._meshSessions.add(sessionId); this.bindSession(from, sessionId, MEMBER_STATE.PENDING); const sig = await signMeshDescriptor(this.subtle, this.identity.keyPair.privateKey, { groupId: this.groupId, epoch, kind: MESH_KINDS.ANSWER, fromFp: this.selfFp, toFp: from, descriptor: answer, nonce }); const sent = await this._sendTo(from, { type: GROUP_FRAMES.MESH_ANSWER, gid: this.groupId, epoch, from: this.selfFp, to: from, d: answer, n: toB64(nonce), sig: toB64(sig) }); if (!sent) throw new GroupSessionError("no route to carry the answer", "unreachable"); dial.timer = this._timer(() => this._meshFail(from, "answer_timeout"), TIMEOUTS.MESH_DIAL_MS); } catch (error) { this._meshFail(from, error?.code || "answer_failed"); } } async _onMeshAnswer(frame) { if (!this._mesh || this._destroyed) return; const from = assertFingerprint(String(frame.from || "")); if (assertFingerprint(String(frame.to || "")) !== this.selfFp) return; if (assertEpoch(frame.epoch) !== this.epoch) return; const dial = this._meshDials.get(from); if (!dial || dial.role !== "offer" || !dial.sessionId || dial.epoch !== this.epoch) return; const member = this.members.get(from); if (!member || !member.publicKey) { throw new GroupSessionError("mesh answer from a non-member", "not_a_member"); } const descriptor = String(frame.d || ""); if (!descriptor || descriptor.length > GROUP_LIMITS.MAX_DESCRIPTOR_CHARS) { throw new GroupSessionError("mesh descriptor is missing or oversized", "bad_descriptor"); } const nonce = fromB64(String(frame.n || ""), { max: GROUP_LIMITS.MESH_NONCE_BYTES }); if (nonce.length !== dial.nonce.length || !nonce.every((b, i) => b === dial.nonce[i])) { throw new GroupSessionError("mesh answer does not match the dial it claims", "bad_mesh_nonce"); } const ok = await verifyMeshDescriptor(this.subtle, member.publicKey, { groupId: this.groupId, epoch: this.epoch, kind: MESH_KINDS.ANSWER, fromFp: from, toFp: this.selfFp, descriptor, nonce }, fromB64(String(frame.sig || ""), { max: GROUP_LIMITS.MAX_SIG_BYTES })); if (!ok) throw new GroupSessionError("mesh answer signature did not verify", "bad_signature"); try { await this._mesh.acceptAnswer(dial.sessionId, descriptor); } catch (error) { this._meshFail(from, error?.code || "answer_rejected"); } } _onMeshAbort(frame) { const from = assertFingerprint(String(frame.from || "")); if (assertFingerprint(String(frame.to || "")) !== this.selfFp) return; if (!this._meshDials.has(from)) return; this._meshFail(from, "peer_aborted", { tellPeer: false }); } // ----------------------------------------------------------------------- // link probes // ----------------------------------------------------------------------- /** * Claim a pairwise chat we already hold as this group's link to a member. * * Two people who were already talking do not need a second connection built * between them, and dialling one anyway would spend a WebRTC negotiation to * arrive back where we started. The app calls this for every verified chat * that is not already carrying a member; whoever is on the other end and is * in this group binds it, and the pair is meshed without dialling anything. * * Sent once per session per epoch. It is a claim about identity, not a * request, so there is nothing to retry. */ async probeSession(sessionId) { if (this._destroyed || !this._mesh || !sessionId) return false; if (this.phase !== GROUP_PHASE.READY || !this.sasConfirmed) return false; if (this.sessionToFp.has(sessionId)) return false; return this._sendProbe(sessionId); } /** * Sign and send one probe. Once per session per epoch, whatever asked for it. * * Split from probeSession because the two callers disagree about one check: * the app only offers sessions that carry nobody, while an ANSWERING probe * goes out on a session that has just been bound — by the very probe it is * answering. */ async _sendProbe(sessionId) { if (this._destroyed || this._probed.has(sessionId)) return false; const linkFp = this._linkFingerprint(sessionId); if (!linkFp) return false; this._probed.add(sessionId); const sig = await signLinkProbe(this.subtle, this.identity.keyPair.privateKey, { groupId: this.groupId, epoch: this.epoch, fp: this.selfFp, linkFp }); await this._wire(sessionId, { type: GROUP_FRAMES.PROBE, gid: this.groupId, epoch: this.epoch, fp: this.selfFp, sig: toB64(sig) }); return true; } _linkFingerprint(sessionId) { try { const fp = this._mesh?.linkFingerprint?.(sessionId); return typeof fp === "string" && fp.length > 0 ? fp : ""; } catch (_) { return ""; } } async _onProbe(sessionId, frame) { if (this._destroyed) return; if (this.phase !== GROUP_PHASE.READY || !this.sasConfirmed) return; if (assertEpoch(frame.epoch) !== this.epoch) return; const fp = assertFingerprint(String(frame.fp || "")); if (fp === this.selfFp) return; const member = this.members.get(fp); if (!member || !member.publicKey) return; if (this.sessionToFp.has(sessionId)) return; if (member.sessionId) { if (member.state === MEMBER_STATE.LINKED) return; if (!this._meshDials.has(fp)) return; } const linkFp = this._linkFingerprint(sessionId); if (!linkFp) return; const ok = await verifyLinkProbe(this.subtle, member.publicKey, { groupId: this.groupId, epoch: this.epoch, fp, linkFp }, fromB64(String(frame.sig || ""), { max: GROUP_LIMITS.MAX_SIG_BYTES })); if (!ok) throw new GroupSessionError("link probe signature did not verify", "bad_signature"); this._settleDial(fp); this.bindSession(fp, sessionId, MEMBER_STATE.LINKED); this._sendProbe(sessionId).catch(() => { }); this._scheduleMeshMaintain(); } // ----------------------------------------------------------------------- // step 1-2: invite / hello // ----------------------------------------------------------------------- /** * Admin: invite peers we already hold verified 1:1 sessions with. * @param {{sessionId: string, name: string}[]} peers */ async invite(peers) { if (!this.isAdmin) throw new GroupSessionError("only the admin invites", "not_admin"); await this.init(); if (peers.length + 1 > GROUP_LIMITS.MAX_MEMBERS) { throw new GroupSessionError(`a group is limited to ${GROUP_LIMITS.MAX_MEMBERS} members`, "too_many_members"); } this._setPhase(GROUP_PHASE.FORMING); for (const peer of peers) this._awaitingHello.set(peer.sessionId, peer.name || "Member"); const frame = { type: GROUP_FRAMES.INVITE, gid: this.groupId, epoch: this.epoch, name: this.name, adminSpki: toB64(this.identity.spki) }; const results = await Promise.allSettled(peers.map((p) => this._wire(p.sessionId, frame))); const failed = results.filter((r) => r.status === "rejected"); if (failed.length === peers.length) { this._fail("invitations_could_not_be_sent"); throw new GroupSessionError( "the invitation could not be sent \u2014 the chat with that peer is not connected", "invitations_could_not_be_sent" ); } if (failed.length > 0) { const reachable = peers.filter((_, i) => results[i].status === "fulfilled"); for (const p of peers) { if (!reachable.includes(p)) this._awaitingHello.delete(p.sessionId); } this._emit("partial_invite", { sent: reachable.length, total: peers.length }); } this._timer(() => { if (this.phase === GROUP_PHASE.FORMING) this._fail("invitees_did_not_respond"); }, TIMEOUTS.HELLO_MS); } /** Invitee: adopt an invitation and publish our own identity key back. */ async acceptInvite(sessionId, envelope) { const frame = decodeEnvelope(envelope); await this.init(); const adminSpki = fromB64(String(frame.adminSpki || "")); const { publicKey, fingerprint } = await importMemberIdentity(this.subtle, adminSpki); this.adminFp = fingerprint; this.epoch = assertEpoch(frame.epoch); this.name = assertName(frame.name); this.members.set(fingerprint, { fp: fingerprint, name: "Admin", spki: adminSpki, publicKey, sessionId, state: MEMBER_STATE.LINKED }); this.sessionToFp.set(sessionId, fingerprint); this._setPhase(GROUP_PHASE.FORMING); this._emitMembers(); await this._wire(sessionId, { type: GROUP_FRAMES.HELLO, gid: this.groupId, epoch: this.epoch, spki: toB64(this.identity.spki) }); this._timer(() => { if (this.phase === GROUP_PHASE.FORMING) this._fail("roster_never_arrived"); }, TIMEOUTS.HELLO_MS); } async _onHello(sessionId, frame) { if (!this.isAdmin) return; if (!this._awaitingHello.has(sessionId)) { this._log("warn", "dropped an unsolicited group hello", { groupId: this.groupId }); return; } const spki = fromB64(String(frame.spki || "")); const { publicKey, fingerprint } = await importMemberIdentity(this.subtle, spki); if (this.members.has(fingerprint) && fingerprint !== this.selfFp) return; if (this.members.size >= GROUP_LIMITS.MAX_MEMBERS) throw new GroupSessionError("group is full", "too_many_members"); this.members.set(fingerprint, { fp: fingerprint, name: this._awaitingHello.get(sessionId) || "Member", spki, publicKey, sessionId, state: MEMBER_STATE.LINKED }); this.sessionToFp.set(sessionId, fingerprint); this._awaitingHello.delete(sessionId); this._emitMembers(); if (this._awaitingHello.size === 0) { if (this._pendingAdd) return this._finishAdd(); await this.publishRoster(MEMBER_OPS.CREATE); } } // ----------------------------------------------------------------------- // step 3: the signed roster // ----------------------------------------------------------------------- /** Admin: sign the current member set for this epoch and broadcast it. */ async publishRoster(op = MEMBER_OPS.ADD) { if (!this.isAdmin) throw new GroupSessionError("only the admin publishes the roster", "not_admin"); const memberFps = canonicalFingerprints([...this.members.keys()]); const fields = { groupId: this.groupId, epoch: this.epoch, op, memberFps, name: this.name }; const sig = await signMemberOp(this.subtle, this.identity.keyPair.privateKey, fields); for (const fp of memberFps) { const m = this.members.get(fp); await this._broadcast({ type: GROUP_FRAMES.MEMBER, gid: this.groupId, epoch: this.epoch, fp, name: m.name === "You" ? "Admin" : m.name, spki: toB64(m.spki) }); } await this._broadcast({ type: GROUP_FRAMES.ROSTER, gid: this.groupId, epoch: this.epoch, op, name: this.name, adminSpki: toB64(this.identity.spki), members: memberFps, sig: toB64(sig) }); await this._startCeremony(); } /** * A member's identity key, published ahead of the roster that names them. * * Held in a staging area rather than applied: until the admin's signed roster * arrives, a key frame is an unverified claim about who is in the group. The * fingerprint is derived from the bytes, never taken from the frame, so a * member cannot register a key under someone else's name. */ async _onMemberKey(frame) { const epoch = assertEpoch(frame.epoch); if (epoch < this.epoch) return; if (this._pendingKeys.size > GROUP_LIMITS.MAX_MEMBERS * 2) return; const spki = fromB64(String(frame.spki || "")); const { publicKey, fingerprint } = await importMemberIdentity(this.subtle, spki); if (assertFingerprint(String(frame.fp || "")) !== fingerprint) { throw new GroupSessionError("member key does not match its fingerprint", "fingerprint_mismatch"); } this._pendingKeys.set(fingerprint, { spki, publicKey, name: assertName(frame.name) }); } /** * Member: adopt a roster. * * The admin's signature is checked against the key whose fingerprint IS the * admin fingerprint we recorded at invite time — not against whatever key the * frame happens to carry — so a member cannot promote itself by attaching its * own key to a roster. The epoch must move forward, which refuses both a * replay and a rollback to a membership that used to be valid. */ async _onRoster(sessionId, frame) { const epoch = assertEpoch(frame.epoch); if (this.isAdmin) return; if (epoch < this.epoch) throw new GroupSessionError("roster epoch went backwards", "stale_epoch"); const adminSpki = fromB64(String(frame.adminSpki || "")); const { publicKey: adminKey, fingerprint: adminFp } = await importMemberIdentity(this.subtle, adminSpki); if (this.adminFp && adminFp !== this.adminFp) { throw new GroupSessionError("roster was signed by someone other than the admin", "wrong_admin"); } if (!Array.isArray(frame.members)) throw new GroupSessionError("roster carries no member list", "bad_roster"); if (frame.members.length > GROUP_LIMITS.MAX_MEMBERS) throw new GroupSessionError("roster exceeds the member limit", "too_many_members"); const memberFps = canonicalFingerprints(frame.members.map((fp) => String(fp || ""))); if (!memberFps.includes(this.selfFp)) throw new GroupSessionError("roster does not include us", "not_a_member"); if (!memberFps.includes(adminFp)) throw new GroupSessionError("roster does not include its author", "bad_roster"); const imported = []; for (const fp of memberFps) { if (fp === this.selfFp) { imported.push({ fp, name: "You", spki: this.identity.spki, publicKey: null }); continue; } const staged = this._pendingKeys.get(fp) || (fp === adminFp ? { spki: adminSpki, publicKey: adminKey, name: "Admin" } : null); if (!staged) throw new GroupSessionError("roster names a member whose key never arrived", "missing_member_key"); imported.push({ fp, name: staged.name, spki: staged.spki, publicKey: staged.publicKey }); } const ok = await verifyMemberOp(this.subtle, adminKey, { groupId: this.groupId, epoch, op: String(frame.op || ""), memberFps, name: assertName(frame.name) }, fromB64(String(frame.sig || ""), { max: GROUP_LIMITS.MAX_SIG_BYTES })); if (!ok) throw new GroupSessionError("roster signature did not verify", "bad_signature"); this.epoch = epoch; this.name = assertName(frame.name); this.adminFp = adminFp; const previous = this.members; this.members = /* @__PURE__ */ new Map(); for (const m of imported) { const old = previous.get(m.fp); this.members.set(m.fp, { fp: m.fp, name: m.fp === this.selfFp ? "You" : m.name, spki: m.spki, publicKey: m.fp === this.selfFp ? null : m.publicKey, sessionId: old?.sessionId || null, state: m.fp === this.selfFp ? MEMBER_STATE.SELF : old?.state === MEMBER_STATE.LINKED ? MEMBER_STATE.LINKED : MEMBER_STATE.PENDING }); } for (const [sid, fp] of [...this.sessionToFp]) { if (!this.members.has(fp)) this.sessionToFp.delete(sid); } this._emit("roster", { name: this.name, epoch: this.epoch, adminFp }); this._emitMembers(); await this._startCeremony(); } // ----------------------------------------------------------------------- // steps 4-6: the safety code ceremony // ----------------------------------------------------------------------- async _startCeremony() { try { this.ceremony?.destroy(); } catch (_) { } this._meshReset(); this.ceremony = new GroupSasCeremony({ groupId: this.groupId, epoch: this.epoch, selfFingerprint: this.selfFp, memberFingerprints: [...this.members.keys()] }); this._setPhase(GROUP_PHASE.COMMITTING); const commitment = await this.ceremony.ownCommitment(this.subtle); await this._broadcast({ type: GROUP_FRAMES.COMMIT, gid: this.groupId, epoch: this.epoch, fp: this.selfFp, commit: toB64(commitment) }); const epochAtStart = this.epoch; this._timer(() => { if (this.epoch !== epochAtStart) return; if (this.phase === GROUP_PHASE.COMMITTING || this.phase === GROUP_PHASE.REVEALING) { this._fail("ceremony_timed_out"); } }, TIMEOUTS.CEREMONY_MS); await this._drainPendingCeremony(); await this._maybeReveal(); } /** Hold a ceremony frame that outran our own roster. See _pendingCeremony. */ _holdCeremonyFrame(frame) { if (this._pendingCeremony.length >= GROUP_LIMITS.MAX_MEMBERS * 2) return; this._pendingCeremony.push(frame); } async _drainPendingCeremony() { if (this._draining) return; this._draining = true; try { const held = this._pendingCeremony; this._pendingCeremony = []; for (const frame of held) { try { if (frame.type === GROUP_FRAMES.COMMIT) await this._onCommit(frame); else if (frame.type === GROUP_FRAMES.REVEAL) await this._onReveal(frame); } catch (error) { this._log("warn", "held ceremony frame discarded", { code: error?.code }); } } } finally { this._draining = false; } } async _onCommit(frame) { if (!this.ceremony) return this._holdCeremonyFrame(frame); if (assertEpoch(frame.epoch) !== this.epoch) return; this.ceremony.acceptCommitment( assertFingerprint(String(frame.fp || "")), fromB64(String(frame.commit || ""), { max: GROUP_LIMITS.COMMIT_BYTES }) ); await this._drainPendingCeremony(); await this._maybeReveal(); } /** * Publish our nonce, but only once every commitment is in. * * The check lives in GroupSasCeremony.reveal(), which throws otherwise. This * method only asks whether the round is complete — it must never be changed * to reveal on a timer or on a partial round. */ async _maybeReveal() { if (!this.ceremony || this.ceremony.revealed) return; if (!this.ceremony.commitmentsComplete) return; this._setPhase(GROUP_PHASE.REVEALING); const nonce = this.ceremony.reveal(); await this._broadcast({ type: GROUP_FRAMES.REVEAL, gid: this.groupId, epoch: this.epoch, fp: this.selfFp, nonce: toB64(nonce) }); await this._maybeFinish(); } async _onReveal(frame) { if (!this.ceremony) return this._holdCeremonyFrame(frame); if (assertEpoch(frame.epoch) !== this.epoch) return; if (!this.ceremony.commitments.has(assertFingerprint(String(frame.fp || "")))) { return this._holdCeremonyFrame(frame); } await this.ceremony.acceptReveal( this.subtle, assertFingerprint(String(frame.fp || "")), fromB64(String(frame.nonce || ""), { max: GROUP_LIMITS.NONCE_BYTES }) ); await this._maybeFinish(); } async _maybeFinish() { if (!this.ceremony || !this.ceremony.revealsComplete) return; if (this.sasCode) return; const code = await this.ceremony.finish(this.subtle); this._setPhase(GROUP_PHASE.AWAITING_SAS); this.sasCode = code; this._emit("sas", { code }); } /** * The humans compared the digits and they matched. * * This is the only path to READY, and it is driven by a user action — never * by a frame arriving. It mirrors the 1:1 rule: completing a handshake proves * somebody completed it, and only the out-of-band comparison proves who. */ confirmSas() { if (this.phase !== GROUP_PHASE.AWAITING_SAS || !this.sasCode) { throw new GroupSessionError("there is no group code to confirm", "no_code"); } this.sasConfirmed = true; this.phase = GROUP_PHASE.READY; try { this.ceremony?.destroy(); } catch (_) { } this.ceremony = null; this._emit("confirmed", { members: this._memberSnapshot() }); this._scheduleMeshMaintain(); } /** Recompute the code for the current epoch — used only by tests and diagnostics. */ async _recomputeSas(contributions) { return computeGroupSas(this.subtle, { groupId: this.groupId, epoch: this.epoch, contributions }); } // ----------------------------------------------------------------------- // step 7: messages // ----------------------------------------------------------------------- async sendText(text) { if (this.phase !== GROUP_PHASE.READY || !this.sasConfirmed) { throw new GroupSessionError("the group code has not been confirmed", "not_ready"); } const body = String(text ?? ""); if (!body.trim()) throw new GroupSessionError("empty message", "empty"); const seq = ++this.seq; const bodyHash = await hashBody(this.subtle, body); const sig = await signGroupMessage(this.subtle, this.identity.keyPair.privateKey, { groupId: this.groupId, epoch: this.epoch, seq, senderFp: this.selfFp, bodyHash }); const frame = { type: GROUP_FRAMES.MESSAGE, gid: this.groupId, epoch: this.epoch, seq, fp: this.selfFp, ts: Date.now(), body, sig: toB64(sig) }; const { delivered, unreachable } = await this._broadcast(frame); return { seq, delivered, total: this.members.size - 1, unreachable }; } async _onMessage(frame, { relayed = false } = {}) { const epoch = assertEpoch(frame.epoch); const seq = assertEpoch(frame.seq); const senderFp = assertFingerprint(String(frame.fp || "")); if (senderFp === this.selfFp) return; const member = this.members.get(senderFp); if (!member || !member.publicKey) throw new GroupSessionError("message from a non-member", "not_a_member"); if (epoch !== this.epoch) throw new GroupSessionError("message from another epoch", "stale_epoch"); const body = String(frame.body ?? ""); const bodyHash = await hashBody(this.subtle, body); const ok = await verifyGroupMessage(this.subtle, member.publicKey, { groupId: this.groupId, epoch, seq, senderFp, bodyHash }, fromB64(String(frame.sig || ""), { max: GROUP_LIMITS.MAX_SIG_BYTES })); if (!ok) throw new GroupSessionError("message signature did not verify", "bad_signature"); const byMember = this.transcript.get(senderFp) || /* @__PURE__ */ new Map(); const hashHex = toB64(bodyHash); const previous = byMember.get(seq); if (previous !== void 0) { if (previous === hashHex) return; this._emit("inconsistency", { fp: senderFp, seq, name: member.name }); throw new GroupSessionError("member sent conflicting messages under one sequence number", "transcript_split"); } byMember.set(seq, hashHex); if (byMember.size > 512) byMember.delete(byMember.keys().next().value); this.transcript.set(senderFp, byMember); this._emit("message", { fp: senderFp, name: member.name, body, seq, ts: Number.isFinite(frame.ts) ? frame.ts : Date.now(), // Whether this particular copy came straight from its author or was // carried by another member. Worth showing: a relayed message is one // a third member knew the timing of, and the reader is the only one // in a position to notice that is still happening. relayed }); } // ----------------------------------------------------------------------- // inbound dispatch // ----------------------------------------------------------------------- /** * Handle one frame that arrived on a pairwise session. * * Everything here is attacker-supplied in the sense that matters: it comes * from a verified peer, but a group member is only as trustworthy as the * group makes them. Types are matched against an explicit list and anything * unrecognised is dropped rather than passed on. */ async handleFrame(sessionId, envelope, { relayed = false } = {}) { if (this._destroyed) return; if (!isGroupFrame(envelope)) return; const frame = decodeEnvelope(envelope); if (!frame || !GROUP_FRAME_TYPES.has(frame.type)) return; if (assertGroupId(String(frame.gid || "")) !== this.groupId) return; switch (frame.type) { case GROUP_FRAMES.RELAY: return this._onRelay(sessionId, frame); case GROUP_FRAMES.HELLO: return this._onHello(sessionId, frame); case GROUP_FRAMES.MEMBER: return this._onMemberKey(frame); case GROUP_FRAMES.ROSTER: return this._onRoster(sessionId, frame); case GROUP_FRAMES.COMMIT: return this._onCommit(frame); case GROUP_FRAMES.REVEAL: return this._onReveal(frame); case GROUP_FRAMES.MESSAGE: return this._onMessage(frame, { relayed }); case GROUP_FRAMES.LEAVE: return this._onLeave(frame); case GROUP_FRAMES.MESH_OFFER: return this._onMeshOffer(frame); case GROUP_FRAMES.MESH_ANSWER: return this._onMeshAnswer(frame); case GROUP_FRAMES.MESH_ABORT: return this._onMeshAbort(frame); case GROUP_FRAMES.PROBE: return relayed ? void 0 : this._onProbe(sessionId, frame); case GROUP_FRAMES.INVITE: return; // handled by the app, which decides whether to join at all default: return; } } /** * Single-hop relay. * * A frame addressed to us is unwrapped and handled. A frame addressed to * someone else is forwarded exactly once — `hopped` makes a second forward * impossible, so there is no loop to form and no path to lengthen. */ async _onRelay(sessionId, frame) { const to = assertFingerprint(String(frame.to || "")); const inner = frame.inner; if (!isGroupFrame(inner)) return; if (inner.type === GROUP_FRAMES.RELAY) return; if (to === this.selfFp) return this.handleFrame(sessionId, inner, { relayed: true }); if (frame.hopped === true) return; const member = this.members.get(to); if (!member || member.state !== MEMBER_STATE.LINKED || !member.sessionId) return; await this._wire(member.sessionId, { ...frame, hopped: true }); } async _onLeave(frame) { const fp = assertFingerprint(String(frame.fp || "")); const member = this.members.get(fp); if (!member || fp === this.selfFp) return; this._emit("left", { fp, name: member.name }); if (!this.isAdmin && fp === this.adminFp) { this.members.delete(fp); for (const [sid, f] of [...this.sessionToFp]) if (f === fp) this.sessionToFp.delete(sid); this._emit("ended", { reason: "admin_left" }); return; } if (!this.isAdmin) return; this.members.delete(fp); for (const [sid, f] of [...this.sessionToFp]) if (f === fp) this.sessionToFp.delete(sid); this._emitMembers(); if (this.members.size < GROUP_LIMITS.MIN_MEMBERS) { this._emit("ended", { reason: "last_member_left" }); return; } this.epoch += 1; await this.publishRoster(MEMBER_OPS.REMOVE); } /** * Tell the group we are leaving, best effort. * * Await this before destroy(): teardown clears the member map this walks to * find recipients, so a leave that is merely started can end up addressed to * nobody — and a peer that never hears it keeps a group nobody is in. */ async leave() { try { await this._broadcast({ type: GROUP_FRAMES.LEAVE, gid: this.groupId, fp: this.selfFp }); } catch (_) { } } /** * Admin: invite more people into a group that is already running. * * The group stays usable throughout. Nothing about the membership changes * until the new members have published their identity keys and a roster for * the next epoch actually goes out — at which point every member, old and * new, runs a fresh commit/reveal round and compares a new code. That is not * ceremony for its own sake: the safety code covers the member set, so a set * that has changed has a different code, and the old one no longer says * anything about who is in the room. * * If nobody answers, the round is abandoned and the group is left exactly as * it was — which is why the epoch is not touched until the roster is sent. * * @param {{sessionId: string, name: string}[]} peers */ async addMembers(peers) { if (!this.isAdmin) throw new GroupSessionError("only the admin invites", "not_admin"); if (!Array.isArray(peers) || peers.length === 0) return 0; if (this._pendingAdd) throw new GroupSessionError("an invitation round is already running", "add_in_flight"); if (this.members.size + peers.length > GROUP_LIMITS.MAX_MEMBERS) { throw new GroupSessionError(`a group is limited to ${GROUP_LIMITS.MAX_MEMBERS} members`, "too_many_members"); } for (const peer of peers) { if (this.sessionToFp.has(peer.sessionId)) { throw new GroupSessionError("that chat is already a member of this group", "already_a_member"); } } this._pendingAdd = { op: MEMBER_OPS.ADD, epoch: this.epoch + 1, before: new Set(this.members.keys()) }; for (const peer of peers) this._awaitingHello.set(peer.sessionId, peer.name || "Member"); const frame = { type: GROUP_FRAMES.INVITE, gid: this.groupId, epoch: this._pendingAdd.epoch, name: this.name, adminSpki: toB64(this.identity.spki) }; const round = this._pendingAdd; const results = await Promise.allSettled(peers.map((p) => this._wire(p.sessionId, frame))); const sent = results.filter((r) => r.status === "fulfilled").length; if (sent === 0) { for (const peer of peers) this._awaitingHello.delete(peer.sessionId); if (this._pendingAdd === round) this._pendingAdd = null; throw new GroupSessionError( "the invitation could not be sent \u2014 that chat is not connected", "invitations_could_not_be_sent" ); } peers.forEach((peer, i) => { if (results[i].status === "rejected") this._awaitingHello.delete(peer.sessionId); }); if (this._pendingAdd === round) { this._timer(() => { if (this._pendingAdd === round) this._finishAdd(); }, TIMEOUTS.HELLO_MS); } return sent; } /** * Close an add round: publish the new roster, or abandon it. * * Reached either when every invitee has answered or when the wait runs out. * A partial answer is still worth publishing — the people who did join are * in — but if nobody joined, the group is left untouched rather than pushed * through a re-keying that would achieve nothing except making everyone * compare a new code. */ async _finishAdd() { const round = this._pendingAdd; if (!round) return false; this._pendingAdd = null; this._awaitingHello.clear(); const joined = [...this.members.keys()].filter((fp) => !round.before.has(fp)); if (joined.length === 0) { this._emit("add_failed", { reason: "nobody_joined" }); return false; } this.epoch = round.epoch; this._emitMembers(); await this.publishRoster(round.op); return true; } /** * Admin: remove a member and re-key the group. * * The new epoch is what makes the removal effective — a new safety code every * remaining member must compare again, and a member set the removed member is * not in. There is no shared group key to rotate because there never was one: * every message travels over pairwise ratchets, so a removed member simply * stops being sent anything. */ async removeMember(fp) { if (!this.isAdmin) throw new GroupSessionError("only the admin removes members", "not_admin"); if (fp === this.selfFp) throw new GroupSessionError("the admin cannot remove themselves", "bad_target"); if (!this.members.has(fp)) return false; if (this.members.size - 1 < GROUP_LIMITS.MIN_MEMBERS) { throw new GroupSessionError("a group cannot drop below two members \u2014 leave it instead", "would_empty_group"); } this.members.delete(fp); for (const [sid, f] of [...this.sessionToFp]) if (f === fp) this.sessionToFp.delete(sid); this.epoch += 1; this._emitMembers(); await this.publishRoster(MEMBER_OPS.REMOVE); return true; } }; // src/group/groupSender.js var GROUP_SEND_GAP_MS = 260; var GROUP_SEND_ATTEMPTS = 4; var isRateLimit = (error) => /rate limit/i.test(error?.message || ""); function createGroupSender({ getManager, gapMs = GROUP_SEND_GAP_MS, attempts = GROUP_SEND_ATTEMPTS, now = () => Date.now(), sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) } = {}) { const queues = /* @__PURE__ */ new Map(); return async function sendGroupFrame(sessionId, frame) { const manager = getManager(sessionId); if (!manager || typeof manager.sendMessage !== "function") throw new Error("no such link"); if (typeof manager.isConnected === "function" && !manager.isConnected()) { throw new Error("link is down"); } const queue = queues.get(sessionId) || { chain: Promise.resolve(), lastAt: 0 }; const payload = JSON.stringify(frame); const run = queue.chain.then(async () => { const wait = gapMs - (now() - queue.lastAt); if (wait > 0) await sleep(wait); let lastError = null; for (let attempt = 0; attempt < attempts; attempt++) { try { await manager.sendMessage(payload); queue.lastAt = now(); return true; } catch (error) { lastError = error; if (!isRateLimit(error)) throw error; await sleep(gapMs * (attempt + 1)); } } throw lastError; }); queue.chain = run.catch(() => { }); queues.set(sessionId, queue); return run; }; } // src/components/ui/GroupChat.jsx var h = (...args) => React.createElement(...args); var C = { bg: "#0c0c0e", panel: "#141417", panel2: "#1b1b1f", line: "rgba(255,255,255,0.07)", line2: "rgba(255,255,255,0.13)", ink: "#f4f4f6", ink2: "#a7a7b0", ink3: "#6b6b73", accent: "#f0892a", good: "#3ecf8e", warn: "#e3b341", bad: "#e5727a", mono: "'JetBrains Mono', ui-monospace, monospace" }; var ICON = { users: '', send: '', shield: '', x: '', plus: '', relay: '' }; var svg = (markup, extra = {}) => h("span", { style: { display: "grid", placeItems: "center", ...extra }, dangerouslySetInnerHTML: { __html: markup } }); var btn = (accent = false) => ({ display: "inline-flex", alignItems: "center", justifyContent: "center", gap: "8px", padding: "11px 18px", borderRadius: "10px", cursor: "pointer", fontFamily: "inherit", fontSize: "14px", fontWeight: 700, border: accent ? "none" : `1px solid ${C.line2}`, background: accent ? C.accent : "transparent", color: accent ? "#1a0f04" : C.ink2 }); var overlay = { position: "fixed", inset: 0, zIndex: 90, display: "grid", placeItems: "center", background: "rgba(5,5,7,0.72)", backdropFilter: "blur(6px)", padding: "20px" }; var card = { width: "100%", maxWidth: "440px", background: C.panel, border: `1px solid ${C.line}`, borderRadius: "16px", padding: "24px", display: "flex", flexDirection: "column", gap: "18px", boxShadow: "0 24px 60px rgba(0,0,0,0.5)" }; function clampToBytes(value, max) { const enc = new TextEncoder(); let out = String(value); while (enc.encode(out).length > max) out = out.slice(0, -1); return out; } var label = { fontFamily: C.mono, fontSize: "10px", fontWeight: 700, letterSpacing: "1.3px", textTransform: "uppercase", color: C.ink3 }; function waitingWord(group) { switch (group.phase) { case GROUP_PHASE.FORMING: return "Waiting for the other members to join\u2026"; case GROUP_PHASE.COMMITTING: return "Waiting for every member to commit\u2026"; case GROUP_PHASE.REVEALING: return "Exchanging nonces\u2026"; case GROUP_PHASE.FAILED: return GROUP_ERROR_WORD[group.error] || "This group could not be formed."; default: return "Working\u2026"; } } var GROUP_ERROR_WORD = { invitations_could_not_be_sent: "The invitation could not be sent \u2014 that chat is not connected.", invitees_did_not_respond: "Nobody accepted the invitation in time.", roster_never_arrived: "The group owner never sent the member list.", ceremony_timed_out: "A member stopped responding before the code was ready.", bad_signature: "The member list was not signed by the group owner. Do not retry \u2014 tell them.", wrong_admin: "Someone other than the group owner tried to change the members.", fingerprint_mismatch: "A member\u2019s key did not match the identity claimed for it.", commitment_mismatch: "A member\u2019s revealed value did not match what they committed to.", commitment_changed: "A member changed their commitment part-way through.", missing_member_key: "A member was listed whose key never arrived.", not_a_member: "A frame arrived from someone outside the group.", bad_name: "The group name is too long.", too_many_members: "A group is limited to eight members.", frame_too_large: "A message was too large to send to the group." }; function GroupSasModal({ group, onConfirm, onCancel }) { if (!group) return null; const failed = group.phase === GROUP_PHASE.FAILED; const waiting = group.phase !== GROUP_PHASE.AWAITING_SAS || !group.sasCode; return h( "div", { style: overlay, role: "dialog", "aria-modal": "true" }, h("div", { style: card }, [ h("div", { key: "h", style: { display: "flex", flexDirection: "column", gap: "6px" } }, [ h("span", { key: "l", style: label }, "Group safety code"), h("h3", { key: "t", style: { margin: 0, fontSize: "19px", fontWeight: 700, color: C.ink } }, group.name) ]), waiting ? h("div", { key: "wait", style: { padding: "28px 16px", textAlign: "center", borderRadius: "12px", background: C.panel2, border: `1px solid ${C.line}`, color: C.ink2, fontSize: "14px" } }, [ h("div", { key: "d", style: { fontFamily: C.mono, fontSize: "26px", letterSpacing: "6px", color: failed ? C.bad : C.ink3 } }, "\xB7\xB7\xB7\xB7\xB7\xB7\xB7"), h("div", { key: "s", style: { marginTop: "10px", color: failed ? C.bad : C.ink2 } }, waitingWord(group)), failed && h("div", { key: "why", style: { marginTop: "6px", fontFamily: C.mono, fontSize: "11px", color: C.ink3 } }, group.error || "unknown") ]) : h("div", { key: "code", style: { padding: "22px 16px", textAlign: "center", borderRadius: "12px", background: "rgba(240,137,42,0.09)", border: "1px solid rgba(240,137,42,0.3)" } }, h("span", { style: { fontFamily: C.mono, fontSize: "clamp(30px, 9vw, 42px)", fontWeight: 700, letterSpacing: "9px", color: C.accent } }, group.sasCode)), h("p", { key: "why", style: { margin: 0, fontSize: "13.5px", lineHeight: 1.62, color: C.ink2 } }, [ "Read these digits aloud to ", h("b", { key: "b", style: { color: C.ink } }, `all ${group.members.length - 1} other members`), " \u2014 in person, or on a call where you recognise every voice. Everyone must see the same code." ]), h("p", { key: "warn", style: { margin: 0, padding: "11px 13px", borderRadius: "9px", fontSize: "12.5px", lineHeight: 1.55, background: "rgba(229,114,122,0.09)", border: "1px solid rgba(229,114,122,0.26)", color: "#f0a6ab" } }, failed ? "Nothing was sent and nothing was verified. Close this and try again once everyone is connected." : "If even one member reads a different code, someone is sitting between you. Cancel the group \u2014 do not confirm."), h("div", { key: "actions", style: { display: "flex", gap: "10px" } }, [ h( "button", { key: "c", onClick: onCancel, style: { ...btn(failed), flex: failed ? 2 : 1 } }, failed ? "Close" : "Cancel group" ), !failed && h("button", { key: "ok", onClick: onConfirm, disabled: waiting, style: { ...btn(true), flex: 2, opacity: waiting ? 0.4 : 1, cursor: waiting ? "not-allowed" : "pointer" } }, [svg(ICON.shield, { key: "i" }), "Everyone sees this code"]) ]) ]) ); } function CreateGroupModal({ candidates, relayOnly, onCreate, onCancel }) { const [name, setName] = React.useState(""); const [picked, setPicked] = React.useState([]); const max = GROUP_LIMITS.MAX_MEMBERS - 1; const toggle = (id) => setPicked((prev) => prev.includes(id) ? prev.filter((x) => x !== id) : prev.length >= max ? prev : [...prev, id]); const ready = name.trim().length > 0 && picked.length >= 1; return h( "div", { style: overlay, role: "dialog", "aria-modal": "true" }, h("div", { style: { ...card, maxWidth: "470px" } }, [ h("div", { key: "h", style: { display: "flex", flexDirection: "column", gap: "6px" } }, [ h("span", { key: "l", style: label }, "New group"), h("p", { key: "p", style: { margin: 0, fontSize: "13.5px", lineHeight: 1.6, color: C.ink2 } }, `Up to ${GROUP_LIMITS.MAX_MEMBERS} people, peer to peer. Everyone will compare one safety code before the group opens.`) ]), h("input", { key: "name", value: name, // Clamped by BYTES, because that is the limit the protocol // enforces. Counting characters here let a Cyrillic name through // the dialog that the admin's roster signing then rejected. onChange: (e) => setName(clampToBytes(e.target.value, GROUP_LIMITS.MAX_NAME_BYTES)), placeholder: "Group name", style: { width: "100%", padding: "12px 14px", borderRadius: "10px", outline: "none", background: C.panel2, border: `1px solid ${C.line2}`, color: C.ink, fontFamily: "inherit", fontSize: "14.5px" } }), h("div", { key: "pick", style: { display: "flex", flexDirection: "column", gap: "9px" } }, [ h("div", { key: "l", style: { display: "flex", justifyContent: "space-between", alignItems: "baseline" } }, [ h("span", { key: "a", style: label }, "Members"), h( "span", { key: "b", style: { ...label, color: picked.length >= max ? C.warn : C.ink3 } }, `${picked.length} / ${max}` ) ]), candidates.length === 0 ? h("div", { key: "empty", style: { padding: "18px 14px", borderRadius: "10px", textAlign: "center", background: C.panel2, border: `1px dashed ${C.line2}`, color: C.ink3, fontSize: "13px", lineHeight: 1.55 } }, "No verified chats yet. Open a 1:1 chat and compare its safety code first \u2014 a group is built out of connections you have already checked.") : h("div", { key: "list", className: "msc-scroll", style: { display: "flex", flexDirection: "column", gap: "6px", maxHeight: "240px", overflowY: "auto" } }, candidates.map((c) => { const on = picked.includes(c.id); const full = !on && picked.length >= max; return h("button", { key: c.id, onClick: () => toggle(c.id), disabled: full, style: { display: "flex", alignItems: "center", gap: "11px", padding: "10px 12px", borderRadius: "10px", cursor: full ? "not-allowed" : "pointer", textAlign: "left", background: on ? "rgba(240,137,42,0.1)" : "transparent", border: `1px solid ${on ? "rgba(240,137,42,0.32)" : C.line}`, opacity: full ? 0.4 : 1, fontFamily: "inherit" } }, [ h("span", { key: "av", style: { flex: "none", width: "32px", height: "32px", borderRadius: "9px", display: "grid", placeItems: "center", background: C.panel2, border: `1px solid ${C.line}`, fontFamily: C.mono, fontSize: "11px", fontWeight: 700, color: C.ink2 } }, c.mono), h("span", { key: "n", style: { flex: 1, minWidth: 0, fontSize: "14px", color: C.ink, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, c.name), h("span", { key: "tick", style: { flex: "none", width: "18px", height: "18px", borderRadius: "5px", background: on ? C.accent : "transparent", border: `1px solid ${on ? C.accent : C.line2}` } }) ]); })) ]), // A group is a bigger exposure than a 1:1 chat: without a relay, every // member's address is visible to every other member, including people // the user did not personally invite. Say it before they commit, not // after. !relayOnly && h("p", { key: "ip", style: { margin: 0, padding: "11px 13px", borderRadius: "9px", fontSize: "12.5px", lineHeight: 1.55, background: "rgba(227,179,65,0.08)", border: "1px solid rgba(227,179,65,0.26)", color: "#e3b341" } }, "Relay-only mode is off, so each member connects to you directly and learns your IP address \u2014 including members somebody else invited. Turn it on in network settings if that matters here."), h("div", { key: "actions", style: { display: "flex", gap: "10px" } }, [ h("button", { key: "c", onClick: onCancel, style: { ...btn(false), flex: 1 } }, "Cancel"), h("button", { key: "ok", onClick: () => ready && onCreate({ name: name.trim(), sessionIds: picked }), disabled: !ready, style: { ...btn(true), flex: 2, opacity: ready ? 1 : 0.4, cursor: ready ? "pointer" : "not-allowed" } }, "Create group") ]) ]) ); } function GroupErrorModal({ message, onDismiss }) { if (!message) return null; return h( "div", { style: overlay, role: "alertdialog", "aria-modal": "true" }, h("div", { style: { ...card, maxWidth: "400px" } }, [ h("span", { key: "l", style: label }, "Group not created"), h("p", { key: "m", style: { margin: 0, fontSize: "14px", lineHeight: 1.6, color: C.ink2 } }, message), h("button", { key: "ok", onClick: onDismiss, style: btn(true) }, "Close") ]) ); } function AddMembersModal({ candidates, remaining, onAdd, onCancel }) { const [picked, setPicked] = React.useState([]); const toggle = (id) => setPicked((prev) => prev.includes(id) ? prev.filter((x) => x !== id) : prev.length >= remaining ? prev : [...prev, id]); return h( "div", { style: overlay, role: "dialog", "aria-modal": "true" }, h("div", { style: { ...card, maxWidth: "440px" } }, [ h("div", { key: "h", style: { display: "flex", flexDirection: "column", gap: "6px" } }, [ h("span", { key: "l", style: label }, "Add members"), h("p", { key: "p", style: { margin: 0, fontSize: "13.5px", lineHeight: 1.6, color: C.ink2 } }, remaining > 0 ? `Room for ${remaining} more. Everyone will compare a new group code once they join.` : "This group is full.") ]), candidates.length === 0 ? h("div", { key: "empty", style: { padding: "18px 14px", borderRadius: "10px", textAlign: "center", background: C.panel2, border: `1px dashed ${C.line2}`, color: C.ink3, fontSize: "13px", lineHeight: 1.55 } }, "No other verified chats to add. Open a 1:1 chat and compare its safety code first.") : h("div", { key: "list", className: "msc-scroll", style: { display: "flex", flexDirection: "column", gap: "6px", maxHeight: "260px", overflowY: "auto" } }, candidates.map((c) => { const on = picked.includes(c.id); const full = !on && picked.length >= remaining; return h("button", { key: c.id, onClick: () => toggle(c.id), disabled: full, style: { display: "flex", alignItems: "center", gap: "11px", padding: "10px 12px", borderRadius: "10px", cursor: full ? "not-allowed" : "pointer", textAlign: "left", background: on ? "rgba(240,137,42,0.1)" : "transparent", border: `1px solid ${on ? "rgba(240,137,42,0.32)" : C.line}`, opacity: full ? 0.4 : 1, fontFamily: "inherit" } }, [ h("span", { key: "av", style: { flex: "none", width: "32px", height: "32px", borderRadius: "9px", display: "grid", placeItems: "center", background: C.panel2, border: `1px solid ${C.line}`, fontFamily: C.mono, fontSize: "11px", fontWeight: 700, color: C.ink2 } }, c.mono), h("span", { key: "n", style: { flex: 1, minWidth: 0, fontSize: "14px", color: C.ink, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, c.name), h("span", { key: "tick", style: { flex: "none", width: "18px", height: "18px", borderRadius: "5px", background: on ? C.accent : "transparent", border: `1px solid ${on ? C.accent : C.line2}` } }) ]); })), h("p", { key: "note", style: { margin: 0, padding: "11px 13px", borderRadius: "9px", fontSize: "12.5px", lineHeight: 1.55, background: C.panel2, border: `1px solid ${C.line}`, color: C.ink3 } }, "The group keeps working until they accept. There is no history for them to catch up on \u2014 they will only see what is sent from now on."), h("div", { key: "actions", style: { display: "flex", gap: "10px" } }, [ h("button", { key: "c", onClick: onCancel, style: { ...btn(false), flex: 1 } }, "Cancel"), h("button", { key: "ok", onClick: () => picked.length && onAdd(picked), disabled: picked.length === 0, style: { ...btn(true), flex: 2, opacity: picked.length ? 1 : 0.4, cursor: picked.length ? "pointer" : "not-allowed" } }, picked.length > 1 ? `Invite ${picked.length} people` : "Invite") ]) ]) ); } function GroupInviteModal({ invite, onAccept, onDecline }) { if (!invite) return null; return h( "div", { style: overlay, role: "dialog", "aria-modal": "true" }, h("div", { style: card }, [ h("div", { key: "h", style: { display: "flex", flexDirection: "column", gap: "6px" } }, [ h("span", { key: "l", style: label }, "Group invitation"), h("h3", { key: "t", style: { margin: 0, fontSize: "19px", fontWeight: 700, color: C.ink } }, invite.name) ]), h("p", { key: "p", style: { margin: 0, fontSize: "13.5px", lineHeight: 1.62, color: C.ink2 } }, [ h("b", { key: "b", style: { color: C.ink } }, invite.fromLabel), " invited you to a peer-to-peer group. You will compare one safety code with every member before anything is sent." ]), h("p", { key: "note", style: { margin: 0, padding: "11px 13px", borderRadius: "9px", fontSize: "12.5px", lineHeight: 1.55, background: C.panel2, border: `1px solid ${C.line}`, color: C.ink3 } }, "Other members will learn your presence in this group. There is no message history to catch up on \u2014 a group starts empty."), h("div", { key: "actions", style: { display: "flex", gap: "10px" } }, [ h("button", { key: "d", onClick: onDecline, style: { ...btn(false), flex: 1 } }, "Decline"), h("button", { key: "a", onClick: onAccept, style: { ...btn(true), flex: 2 } }, "Join group") ]) ]) ); } function MemberStrip({ group, onRemove, isAdmin }) { return h("div", { className: "msc-scroll", style: { display: "flex", gap: "7px", padding: "9px 16px", overflowX: "auto", borderBottom: `1px solid ${C.line}`, flex: "none" } }, group.members.map((m) => { const self = m.state === MEMBER_STATE.SELF; const lost = m.state === MEMBER_STATE.LOST; const dot = self || m.state === MEMBER_STATE.LINKED ? C.good : m.state === MEMBER_STATE.PENDING ? C.warn : C.bad; const via = m.state === MEMBER_STATE.PENDING; return h("span", { key: m.fp, title: self ? "You" : m.state === MEMBER_STATE.LINKED ? "Direct peer-to-peer link" : m.state === MEMBER_STATE.PENDING ? "No direct link yet \u2014 messages are relayed by another member while one is being built" : `${m.name} is offline and will not receive messages. They are still a member \u2014 removing them re-keys the group.`, style: { flex: "none", display: "inline-flex", alignItems: "center", gap: "6px", padding: "5px 10px", borderRadius: "20px", // A member who is offline should not read as one who is present. // They stay listed because membership is a signed, epoch-ordered // fact that a dropped connection does not change — but the chip // says plainly that nothing sent now reaches them. background: lost ? "transparent" : C.panel2, border: `1px solid ${lost ? "rgba(229,114,122,0.3)" : C.line}`, fontSize: "12.5px", color: self ? C.ink : lost ? C.ink3 : C.ink2, opacity: lost ? 0.75 : 1 } }, [ h("span", { key: "d", style: { width: "7px", height: "7px", borderRadius: "50%", background: dot } }), h("span", { key: "n", style: lost ? { textDecoration: "line-through" } : void 0 }, self ? "You" : m.name), lost && h("span", { key: "off", style: { fontFamily: C.mono, fontSize: "10px", color: C.bad, letterSpacing: "0.04em" } }, "offline"), via && svg(ICON.relay, { key: "r", color: C.warn }), isAdmin && !self && onRemove && h("button", { key: "x", onClick: () => onRemove(m.fp), title: `Remove ${m.name}`, style: { border: "none", background: "transparent", color: C.ink3, cursor: "pointer", display: "grid", padding: 0 }, dangerouslySetInnerHTML: { __html: '' } }) ]); })); } function Bubble({ msg }) { const mine = msg.type === "sent"; const system = msg.type === "system"; if (system) { return h("div", { style: { alignSelf: "center", maxWidth: "80%", textAlign: "center", padding: "6px 12px", borderRadius: "9px", background: C.panel2, border: `1px solid ${C.line}`, fontSize: "12px", color: C.ink3, lineHeight: 1.5 } }, msg.message); } return h("div", { style: { alignSelf: mine ? "flex-end" : "flex-start", maxWidth: "min(74%, 560px)", display: "flex", flexDirection: "column", gap: "3px" } }, [ !mine && h("span", { key: "who", style: { fontSize: "11.5px", fontWeight: 600, color: C.accent, paddingLeft: "3px" } }, msg.senderName || "Member"), h("div", { key: "b", style: { padding: "9px 13px", borderRadius: mine ? "13px 13px 4px 13px" : "13px 13px 13px 4px", background: mine ? "rgba(240,137,42,0.14)" : C.panel2, border: `1px solid ${mine ? "rgba(240,137,42,0.26)" : C.line}`, color: C.ink, fontSize: "14.5px", lineHeight: 1.5, wordBreak: "break-word", whiteSpace: "pre-wrap" } }, msg.message), h("span", { key: "t", style: { fontFamily: C.mono, fontSize: "10px", color: C.ink3, alignSelf: mine ? "flex-end" : "flex-start", padding: "0 3px" } }, [ new Date(msg.timestamp || Date.now()).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }), msg.relayed ? " \xB7 relayed" : "" ].join("")) ]); } function GroupChatView({ group, input, setInput, onSend, onLeave, onRemoveMember, onAddMembers, isAdmin, scrollRef }) { const ready = group.phase === GROUP_PHASE.READY && group.sasConfirmed; const degraded = group.members.some((m) => m.state === MEMBER_STATE.PENDING); const submit = (e) => { e.preventDefault(); if (!ready || !input.trim()) return; onSend(input); }; return h("div", { style: { display: "flex", flexDirection: "column", height: "100%", minHeight: 0, background: C.bg } }, [ // header h("div", { key: "head", style: { flex: "none", display: "flex", alignItems: "center", gap: "12px", padding: "0 16px", height: "64px", borderBottom: `1px solid ${C.line}` } }, [ h("span", { key: "av", style: { flex: "none", width: "38px", height: "38px", borderRadius: "11px", display: "grid", placeItems: "center", background: "rgba(240,137,42,0.12)", border: "1px solid rgba(240,137,42,0.24)", color: C.accent, fontFamily: C.mono, fontSize: "12px", fontWeight: 700 } }, groupInitials(group.name)), h("div", { key: "meta", style: { flex: 1, minWidth: 0 } }, [ h("div", { key: "n", style: { fontSize: "15px", fontWeight: 700, color: C.ink, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, group.name), h("div", { key: "s", style: { fontSize: "11.5px", color: degraded ? C.warn : C.ink3, display: "flex", alignItems: "center", gap: "5px" } }, [ svg(ICON.users, { key: "i", width: "13px", height: "13px" }), `${group.members.length} members`, ready && group.sasCode ? ` \xB7 code ${group.sasCode}` : "" ]) ]), isAdmin && onAddMembers && group.members.length < GROUP_LIMITS.MAX_MEMBERS && h("button", { key: "add", onClick: onAddMembers, title: "Invite more members", style: { ...btn(false), padding: "8px 12px", fontSize: "12.5px" } }, [svg(ICON.plus, { key: "i" }), "Add"]), h("button", { key: "leave", onClick: onLeave, title: "Leave this group", style: { ...btn(false), padding: "8px 12px", fontSize: "12.5px", color: C.bad, borderColor: "rgba(229,114,122,0.3)" } }, "Leave") ]), h(MemberStrip, { key: "strip", group, onRemove: onRemoveMember, isAdmin }), degraded && ready && h("div", { key: "relay-note", style: { flex: "none", padding: "8px 16px", fontSize: "12px", lineHeight: 1.5, color: C.warn, background: "rgba(227,179,65,0.08)", borderBottom: `1px solid ${C.line}` } }, "Some members have no direct link to you yet. Their messages travel through another member, who can see that you are talking but cannot read past the signature or change what you said. The group keeps trying to connect them directly."), // transcript h("div", { key: "msgs", ref: scrollRef, className: "msc-scroll", style: { flex: 1, minHeight: 0, overflowY: "auto", padding: "18px 16px", display: "flex", flexDirection: "column", gap: "11px" } }, group.messages.length === 0 ? [h("div", { key: "empty", style: { margin: "auto", textAlign: "center", color: C.ink3, fontSize: "13.5px", lineHeight: 1.6, maxWidth: "320px" } }, ready ? "Nothing here yet. Messages are signed by their sender and travel over each member\u2019s own encrypted link." : "Compare the group code with every member to open this group.")] : group.messages.map((m) => h(Bubble, { key: m.id, msg: m }))), // composer h("form", { key: "composer", onSubmit: submit, style: { flex: "none", display: "flex", gap: "9px", padding: "12px 16px", borderTop: `1px solid ${C.line}`, alignItems: "flex-end" } }, [ h("input", { key: "in", value: input, onChange: (e) => setInput(e.target.value), placeholder: ready ? `Message ${group.name}` : "Confirm the group code first", disabled: !ready, maxLength: GROUP_LIMITS.MAX_BODY_BYTES, style: { flex: 1, minWidth: 0, padding: "12px 14px", borderRadius: "11px", outline: "none", background: C.panel2, border: `1px solid ${C.line2}`, color: C.ink, fontFamily: "inherit", fontSize: "14.5px", opacity: ready ? 1 : 0.5 } }), h("button", { key: "send", type: "submit", disabled: !ready || !input.trim(), title: "Send", style: { ...btn(true), flex: "none", width: "44px", height: "44px", padding: 0, borderRadius: "11px", opacity: !ready || !input.trim() ? 0.4 : 1 } }, svg(ICON.send)) ]) ]); } // 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 h2 = 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 h2("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 && h2("svg", { key: "ring", width: 50, height: 50, viewBox: "0 0 50 50", style: { position: "absolute", inset: 0, transform: "rotate(-90deg)" } }, [ h2("circle", { key: "bg", cx: 25, cy: 25, r: 23, fill: "none", stroke: "rgba(240,137,42,0.2)", strokeWidth: 2.5 }), h2("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 ? h2("i", { className: "fas fa-triangle-exclamation", style: { fontSize: "14px" } }) : transferring ? h2("svg", { width: 15, height: 15, viewBox: "0 0 24 24", fill: "currentColor", style: { opacity: 0.5 } }, h2("path", { d: "M8 5.2v13.6l11-6.8z" })) : playing ? h2("svg", { width: 16, height: 16, viewBox: "0 0 24 24", fill: "currentColor" }, [h2("rect", { key: "a", x: 6, y: 5, width: 4, height: 14, rx: 1.2 }), h2("rect", { key: "b", x: 14, y: 5, width: 4, height: 14, rx: 1.2 })]) : h2("svg", { width: 16, height: 16, viewBox: "0 0 24 24", fill: "currentColor" }, h2("path", { d: "M8 5.2v13.6l11-6.8z" })); const label2 = failed ? "Failed" : transferring ? dir === "up" ? "Uploading" : "Downloading" : "Voice"; const timeText = transferring ? pct + "%" : sbFmtClock(playing || progress > 0 ? elapsed : dur); return h2("div", { style: { display: "flex", alignItems: "center", gap: "13px", padding: "13px 15px 12px" } }, [ h2("div", { key: "pw", style: { position: "relative", flex: "none", width: "50px", height: "50px", display: "grid", placeItems: "center" } }, [ ring, h2("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) ]), h2("div", { key: "body", style: { flex: 1, minWidth: 0 } }, [ h2("div", { key: "wave", onClick: seek, style: { display: "flex", alignItems: "center", gap: "2px", height: "30px", cursor: transferring || failed || !src ? "default" : "pointer" } }, barEls), h2("div", { key: "meta", style: { display: "flex", alignItems: "center", justifyContent: "space-between", marginTop: "6px" } }, [ h2("span", { key: "t", style: { fontFamily: SB_MONO, fontSize: "10.5px", fontWeight: 500, color: transferring ? "#f0b072" : "#9a9aa2" } }, timeText), h2("span", { key: "l", style: { fontFamily: SB_MONO, fontSize: "9.5px", fontWeight: 600, color: failed ? "#e5727a" : transferring ? "#f0892a" : "#56565e", textTransform: "uppercase", letterSpacing: "0.8px" } }, label2) ]) ]) ]); }; var VoiceRecorder = ({ onSend, onCancel }) => { const h2 = 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 h2("div", { style: { display: "flex", alignItems: "center", gap: "12px" } }, [ h2("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" } }, [ h2("i", { key: "i", className: "fas fa-microphone-slash", style: { fontSize: "14px" } }), "No audio captured \u2014 check microphone permission and try again." ]), h2( "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" } }, h2("i", { className: "fas fa-xmark", style: { fontSize: "16px" } }) ) ]); } const barEls = liveBars.map((hgt, i) => h2("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 h2("div", { style: { display: "flex", alignItems: "center", gap: "12px" } }, [ h2("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" } }, h2("i", { className: "fas fa-trash-can", style: { fontSize: "15px" } })), h2("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)" } }, [ h2( "span", { key: "dot", style: { position: "relative", flex: "none", width: "9px", height: "9px" } }, h2("span", { style: { position: "absolute", inset: 0, borderRadius: "50%", background: "#e5727a", animation: "vmRec 1.3s ease-in-out infinite" } }) ), h2("span", { key: "time", style: { flex: "none", fontFamily: SB_MONO, fontSize: "13px", fontWeight: 600, color: "#f4f4f6", minWidth: "42px" } }, sbFmtClock(elapsed)), h2("div", { key: "wave", style: { flex: 1, minWidth: 0, display: "flex", alignItems: "center", justifyContent: "flex-end", gap: "2px", height: "30px", overflow: "hidden" } }, barEls) ]), h2("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)" } }, h2("svg", { width: 20, height: 20, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round" }, [h2("path", { key: "a", d: "M22 2L11 13" }), h2("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 h2 = Math.floor(sec / 3600), m = Math.floor(sec % 3600 / 60), s = sec % 60; const pad = (n) => String(n).padStart(2, "0"); return h2 > 0 ? h2 + ":" + 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 h2 = 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 h2("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 h2("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) => h2(el[0], Object.assign({ key: i }, el[1])))); }; const leftPanel = h2("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" } }, [ h2( "div", { key: "herowrap", style: { flex: 1, display: "flex", flexDirection: "column", justifyContent: "center", position: "relative", zIndex: 2 } }, h2("div", { key: "hero", style: { maxWidth: "560px" } }, [ h2("h1", { key: "h1", style: { margin: "0 0 14px", fontSize: "34px", fontWeight: 800, letterSpacing: "-1.1px", lineHeight: 1.1, color: "#f4f4f6" } }, [ "A direct line", h2("br", { key: "br" }), "only you two can read." ]), h2( "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." ), // P2P / mesh animation. // // The loop tells the product's story in one shot: a direct line to // one peer, then two more joining it over 14 seconds — which is what // v6.0 actually added. Motion is pure CSS (offset-path along the // same geometry the lines are drawn from), so there is no rAF loop // burning battery on a landing page and nothing to tear down. h2("div", { key: "channel", style: { display: "flex", alignItems: "center", gap: "32px", flexWrap: "wrap" } }, [ h2("svg", { key: "svg", viewBox: "0 0 380 200", role: "img", "aria-label": "A direct encrypted line to one peer, with two more peers joining the mesh", // Scales down on narrow screens; the labels sit just outside // the viewBox, so the box must not clip them. style: { display: "block", width: "100%", maxWidth: "380px", height: "auto", overflow: "visible" } }, [ // the established 1:1 line h2("line", { key: "l0", x1: 30, y1: 100, x2: 330, y2: 100, stroke: C_ORANGE, strokeWidth: 1.5, strokeOpacity: 0.75 }), h2("circle", { key: "p0", r: 3.6, fill: "#f0a455", style: { offsetPath: "path('M30,100 L330,100')", animation: "sbTrav 2.8s linear infinite" } }), // first peer joins h2("g", { key: "g1", style: { animation: "sbIn1 14s linear infinite" } }, [ h2("line", { key: "a", x1: 30, y1: 100, x2: 250, y2: 26, stroke: C_ORANGE, strokeWidth: 1.4, strokeOpacity: 0.7 }), h2("line", { key: "b", x1: 250, y1: 26, x2: 330, y2: 100, stroke: C_GREEN, strokeWidth: 1.2, strokeOpacity: 0.3 }), h2("circle", { key: "p", r: 3.2, fill: "#f0a455", style: { offsetPath: "path('M30,100 L250,26')", animation: "sbTrav 3s linear .6s infinite" } }), h2("circle", { key: "n", cx: 250, cy: 26, r: 5, fill: "#0c0c0e", stroke: "#6b6760", strokeWidth: 1.2 }), h2("text", { key: "t", x: 250, y: 8, textAnchor: "middle", fontFamily: MONO, fontSize: 12, fill: "#8f8b84" }, "mara") ]), h2("circle", { key: "r1", cx: 250, cy: 26, r: 5, fill: "none", stroke: C_ORANGE, strokeOpacity: 0.6, style: { animation: "sbRing1 14s linear infinite" } }), // second peer joins, and the mesh closes h2("g", { key: "g2", style: { animation: "sbIn2 14s linear infinite" } }, [ h2("line", { key: "a", x1: 30, y1: 100, x2: 250, y2: 174, stroke: C_ORANGE, strokeWidth: 1.4, strokeOpacity: 0.7 }), h2("line", { key: "b", x1: 250, y1: 174, x2: 330, y2: 100, stroke: C_GREEN, strokeWidth: 1.2, strokeOpacity: 0.3 }), h2("line", { key: "c", x1: 250, y1: 26, x2: 250, y2: 174, stroke: C_GREEN, strokeWidth: 1.2, strokeOpacity: 0.3 }), h2("circle", { key: "p", r: 3.2, fill: "#f0a455", style: { offsetPath: "path('M30,100 L250,174')", animation: "sbTrav 3s linear .3s infinite" } }), h2("circle", { key: "q", r: 2.8, fill: C_GREEN, style: { offsetPath: "path('M250,26 L250,174')", animation: "sbTrav 3.4s linear 1.2s infinite" } }), h2("circle", { key: "n", cx: 250, cy: 174, r: 5, fill: "#0c0c0e", stroke: "#6b6760", strokeWidth: 1.2 }), h2("text", { key: "t", x: 250, y: 194, textAnchor: "middle", fontFamily: MONO, fontSize: 12, fill: "#8f8b84" }, "tobi") ]), h2("circle", { key: "r2", cx: 250, cy: 174, r: 5, fill: "none", stroke: C_ORANGE, strokeOpacity: 0.6, style: { animation: "sbRing2 14s linear infinite" } }), // the two endpoints of the original line h2("circle", { key: "pe", cx: 330, cy: 100, r: 5, fill: "#0c0c0e", stroke: "#6b6760", strokeWidth: 1.2 }), h2("text", { key: "pt", x: 346, y: 104, fontFamily: MONO, fontSize: 12, fill: "#8f8b84" }, "peer"), h2("circle", { key: "yh", cx: 30, cy: 100, r: 15, fill: C_ORANGE, fillOpacity: 0.09 }), h2("circle", { key: "yc", cx: 30, cy: 100, r: 6, fill: C_ORANGE }), h2("text", { key: "yt", x: 30, y: 126, textAnchor: "middle", fontFamily: MONO, fontSize: 12, fill: "#d8cfc1" }, "you") ]), // the log, timed to the same 14s loop as the nodes appearing h2("div", { key: "log", style: { display: "flex", flexDirection: "column", gap: "12px", fontFamily: MONO, fontSize: "12px", color: "#85817b" } }, [ h2("div", { key: "r0", style: { animation: "sbRow0 14s linear infinite" } }, "peer \xB7 session 1"), h2("div", { key: "r1", style: { animation: "sbRow1 14s linear infinite" } }, "mara joined \xB7 +2"), h2("div", { key: "r2", style: { animation: "sbRow2 14s linear infinite" } }, "tobi joined \xB7 +3") ]) ]) ]) ), h2( "div", { key: "badges", style: { position: "relative", zIndex: 2, display: "flex", flexWrap: "wrap", gap: "8px" } }, ["ECDH P-384", "AES-256-GCM", "Perfect Forward Secrecy"].map( (label2) => h2("span", { key: label2, 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" } }, [ h2("span", { key: "dot", style: { width: "5px", height: "5px", borderRadius: "50%", background: C_GREEN } }), label2 ]) ) ) ]); const segToggle = atIntro && h2("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" } }, [ h2("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)" } }), h2("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"]), h2("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) => h2("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 = h2("div", { key: "codeblock", style: { borderRadius: "13px", border: "1px solid rgba(255,255,255,0.08)", background: "#141416", overflow: "hidden", marginBottom: "16px" } }, [ h2("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)" } }, [ h2("span", { key: "dot", style: { width: "7px", height: "7px", borderRadius: "50%", background: accent } }), h2("span", { key: "tag", style: { fontFamily: MONO, fontSize: "10.5px", fontWeight: 600, color: "#8a8a92" } }, `${isCreate ? "offer" : "answer"} \xB7 or copy text`), h2("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. h2("div", { key: "codewrap", style: { position: "relative" } }, [ h2("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 && h2("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 && h2("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" } }, [ h2("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" })), h2("span", { key: "tx", style: { flex: 1 } }, [ h2("span", { key: "t", style: { display: "block", fontSize: "14.5px", fontWeight: 700, color: "#f4f4f6" } }, "Show QR code"), h2("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) => h2("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 = h2("div", { key: "verify", style: { animation: "sbUp .3s ease" } }, [ !verified && backButton("vback"), h2("div", { key: "head", style: { display: "flex", alignItems: "center", gap: "11px", marginBottom: "8px" } }, [ h2("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 })), h2("h2", { key: "t", style: { margin: 0, fontSize: "21px", fontWeight: 800, letterSpacing: "-0.4px", color: "#f4f4f6" } }, "Security verification") ]), h2("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."), h2("div", { key: "cells", style: { display: "flex", gap: "6px", justifyContent: "center", marginBottom: "20px", flexWrap: "wrap" } }, cells), verified ? h2("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" } }, [ h2("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" })), h2("div", { key: "t", style: { fontSize: "18px", fontWeight: 800, color: "#f4f4f6" } }, "Channel verified"), h2("div", { key: "s", style: { fontSize: "13.5px", color: "#8a8a92", marginTop: "5px" } }, "Both parties confirmed. Opening the secure chat\u2026") ]) : h2("div", { key: "form" }, [ h2("div", { key: "lbl", style: { fontSize: "12.5px", fontWeight: 600, color: "#9a9aa2", marginBottom: "8px" } }, "Enter the verified code"), h2("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 && h2("p", { key: "err", style: { color: "#e5727a", fontSize: "12.5px", margin: "0 0 16px" } }, sasError), h2("div", { key: "status", style: { display: "flex", flexDirection: "column", gap: "8px", marginBottom: "16px" } }, [ h2("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" } }, [ h2("span", { key: "l", style: { fontSize: "13px", color: "#cfcfd4", fontWeight: 600 } }, "Your confirmation"), h2("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"]) ]), h2("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" } }, [ h2("span", { key: "l", style: { fontSize: "13px", color: "#cfcfd4", fontWeight: 600 } }, "Peer confirmation"), h2("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"]) ]) ]), h2("div", { key: "btns", style: { display: "flex", gap: "10px" } }, [ h2("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"]), h2("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 = h2("div", { key: "gen", style: { animation: "sbUp .28s ease" } }, [ h2("div", { key: "head", style: { display: "flex", alignItems: "center", gap: "13px", marginBottom: "22px" } }, [ h2("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" })), h2("div", { key: "tx" }, [ h2("h2", { key: "t", style: { margin: 0, fontSize: "20px", fontWeight: 800, letterSpacing: "-0.4px", color: "#f4f4f6" } }, isCreate ? "Securing your channel" : "Building your answer"), h2("p", { key: "s", style: { margin: "3px 0 0", fontSize: "13px", color: "#8a8a92" } }, "Forging keys strong enough to resist tampering.") ]) ]), h2( "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((label2, i) => { const done = genProgress > i; const active = genProgress === i; return h2("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" } }, [ h2( "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" }) : h2("span", { style: { width: "6px", height: "6px", borderRadius: "50%", background: active ? C_ORANGE : "#56565e", animation: active ? "sbBlink 1s ease-in-out infinite" : "none" } }) ), h2("span", { key: "l", style: { fontSize: "13.5px", color: done ? "#cfcfd4" : active ? "#e8e8eb" : "#6b6b73", transition: "color .3s" } }, label2) ]); }) ) ]); } else if (isOfferCred || isAnswerCred) { inner = h2("div", { key: "cred", style: { animation: "sbUp .3s ease" } }, [ backButton("cback"), h2("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"), h2("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 && h2("div", { key: "offerextra", style: { marginTop: "4px" } }, [ h2("div", { key: "lbl", style: { fontSize: "12.5px", fontWeight: 600, color: "#9a9aa2", marginBottom: "8px" } }, "Then receive the answer your peer sends back"), h2( "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" } }, h2("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" } }) ), h2("div", { key: "btns", style: { display: "flex", gap: "10px" } }, [ h2("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"]), h2("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 && h2("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" }), h2("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 = h2("div", { key: "introC", style: { animation: "sbUp .28s ease" } }, [ h2("h2", { key: "h", style: { margin: "0 0 6px", fontSize: "23px", fontWeight: 800, letterSpacing: "-0.5px", color: "#f4f4f6" } }, "Create a new channel"), h2("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."), h2("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 = h2("div", { key: "introJ", style: { animation: "sbUp .28s ease" } }, [ h2("h2", { key: "h", style: { margin: "0 0 6px", fontSize: "23px", fontWeight: 800, letterSpacing: "-0.5px", color: "#f4f4f6" } }, "Join a channel"), h2("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."), h2("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" } }, [ h2("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" })), h2("span", { key: "tx", style: { flex: 1 } }, [ h2("span", { key: "t", style: { display: "block", fontSize: "14.5px", fontWeight: 700, color: "#f4f4f6" } }, "Scan QR with camera"), h2("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" }) ]), h2("div", { key: "or", style: { display: "flex", alignItems: "center", gap: "12px", marginBottom: "14px" } }, [ h2("span", { key: "a", style: { flex: 1, height: "1px", background: "rgba(255,255,255,0.07)" } }), h2("span", { key: "m", style: { fontSize: "11px", fontWeight: 600, color: "#56565e", textTransform: "uppercase", letterSpacing: "0.7px" } }, "or paste code"), h2("span", { key: "b", style: { flex: 1, height: "1px", background: "rgba(255,255,255,0.07)" } }) ]), h2( "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" } }, h2("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" } }) ), h2("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 SB_DESKTOP_VERSION = "0.3.0"; const SB_DESKTOP_RELEASE = `https://github.com/SecureBitChat/securebit-desktop/releases/download/v${SB_DESKTOP_VERSION}`; const DOWNLOADS = { mac: { name: "macOS", format: ".dmg \xB7 Apple Silicon & Intel", icon: "fab fa-apple", url: `${SB_DESKTOP_RELEASE}/SecureBit.Chat_${SB_DESKTOP_VERSION}_x64.dmg` }, win: { name: "Windows", format: ".exe \xB7 64-bit installer", icon: "fab fa-windows", url: `${SB_DESKTOP_RELEASE}/SecureBit.Chat_${SB_DESKTOP_VERSION}_x64-setup.exe` }, linux: { name: "Linux", format: ".AppImage", icon: "fab fa-linux", url: `${SB_DESKTOP_RELEASE}/SecureBit.Chat_${SB_DESKTOP_VERSION}_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 && h2("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" } }, [ h2("div", { key: "mh", style: { display: "flex", alignItems: "center", gap: "10px", padding: "14px 16px", borderBottom: "1px solid rgba(255,255,255,0.06)" } }, [ h2("div", { key: "t", style: { flex: 1, lineHeight: 1.2 } }, [ h2("div", { key: "a", style: { fontSize: "14px", fontWeight: 800, color: "#f4f4f6" } }, "Download SecureBit"), h2("div", { key: "b", style: { fontSize: "11.5px", color: "#7b7b83" } }, "Free \xB7 open source") ]), h2("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") ]), h2( "div", { key: "rec", style: { padding: "12px 12px 6px" } }, h2("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" } }, [ h2("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 } }, h2("i", { className: DOWNLOADS[detectedOS].icon, style: { fontSize: "17px" } })), h2("span", { key: "tx", style: { flex: 1, minWidth: 0 } }, [ h2("span", { key: "n", style: { display: "block", fontSize: "13.5px", fontWeight: 700, color: "#f4f4f6" } }, DOWNLOADS[detectedOS].name), h2("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 }) ]) ), h2( "div", { key: "others", style: { padding: "0 12px 8px", display: "flex", flexDirection: "column", gap: "2px" } }, otherOS.map((k) => h2("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" } }, [ h2("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" } }, h2("i", { className: DOWNLOADS[k].icon, style: { fontSize: "15px" } })), h2("span", { key: "tx", style: { flex: 1, minWidth: 0 } }, [ h2("span", { key: "n", style: { display: "block", fontSize: "13px", fontWeight: 600, color: "#e8e8eb" } }, DOWNLOADS[k].name), h2("span", { key: "f", style: { display: "block", fontSize: "11px", color: "#7b7b83", marginTop: "1px" } }, DOWNLOADS[k].format) ]), fa("fa-download", { color: "#8a8a92" }) ])) ), h2("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" }), h2("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 = h2("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" } }, [ h2("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 }), h2("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" } }) ]), h2("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 ? h2(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 = h2("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" } }, [ h2( "div", { key: "scroll", className: "custom-scrollbar", style: { flex: 1, overflowY: "auto", display: "flex", flexDirection: "column", padding: "42px 44px" } }, h2("div", { style: { maxWidth: "430px", width: "100%", margin: "auto" } }, [ h2("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 && h2( "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" } }, h2("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" } }, [ h2("div", { key: "head", style: { display: "flex", alignItems: "center", gap: "11px", padding: "18px 20px", borderBottom: "1px solid rgba(255,255,255,0.06)" } }, [ h2("span", { key: "d", style: { width: "9px", height: "9px", borderRadius: "50%", background: accent } }), h2("div", { key: "tx", style: { flex: 1, lineHeight: 1.2 } }, [ h2("div", { key: "t", style: { fontSize: "15.5px", fontWeight: 800, color: "#f4f4f6" } }, isCreate ? "Share your invitation" : "Send back your answer"), h2("div", { key: "s", style: { fontSize: "12px", color: "#7b7b83" } }, `${isCreate ? "offer" : "answer"} \xB7 one-time`) ]), h2("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")) ]), h2("div", { key: "body", style: { padding: "22px 24px 24px" } }, [ h2( "div", { key: "qr", style: { position: "relative", width: "100%", aspectRatio: "1", borderRadius: "18px", overflow: "hidden", background: "#fff", padding: "18px", display: "grid", placeItems: "center" } }, h2("img", { src: qrCodeUrl, alt: "QR code", style: { width: "100%", height: "100%", objectFit: "contain", display: "block" } }) ), h2("div", { key: "ctrls", style: { display: "flex", flexDirection: "column", alignItems: "center", gap: "12px", marginTop: "18px" } }, [ (qrFramesTotal || 0) >= 1 && h2("div", { key: "frame", style: { display: "flex", alignItems: "center", gap: "9px" } }, [ h2("span", { key: "l", style: { fontFamily: MONO, fontSize: "12px", fontWeight: 600, color: "#9a9aa2" } }, `Frame ${Math.max(1, qrFrameIndex || 1)} / ${qrFramesTotal || 1}`), h2("div", { key: "dots", style: { display: "flex", gap: "5px" } }, Array.from({ length: qrFramesTotal || 1 }, (_, i) => h2("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 && h2("div", { key: "nav", style: { display: "flex", alignItems: "center", gap: "6px" } }, [ h2("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")), h2("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"), h2("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")) ]), h2("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 = h2("div", { key: "hero", style: { display: "flex", flexWrap: "wrap", minHeight: "100vh", width: "100%", background: "#0f0f11", color: "#e8e8eb" } }, [leftPanel, rightPanel]); const uniqueSection = atIntro && h2(UniqueFeatureSlider, { key: "unique-features-slider" }); const partnersSection = atIntro && h2(BecomePartner, { key: "become-partner" }); const roadmapSection = atIntro && h2(Roadmap, { key: "roadmap" }); const communitySection = atIntro && h2(CommunityCTA, { key: "community-cta" }); const keyframeStyle = h2("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}}@keyframes sbTrav{0%{offset-distance:0%;opacity:0}15%{opacity:1}80%{opacity:1}100%{offset-distance:100%;opacity:0}}@keyframes sbRing1{0%,24%{r:5;opacity:0}26%{opacity:.8}34%{r:22;opacity:0}100%{r:22;opacity:0}}@keyframes sbRing2{0%,54%{r:5;opacity:0}56%{opacity:.8}64%{r:22;opacity:0}100%{r:22;opacity:0}}@keyframes sbIn1{0%,24%{opacity:0}27%{opacity:1}100%{opacity:1}}@keyframes sbIn2{0%,54%{opacity:0}57%{opacity:1}100%{opacity:1}}@keyframes sbRow0{0%,4%{opacity:0;transform:translateY(4px)}8%{opacity:1;transform:none}100%{opacity:1;transform:none}}@keyframes sbRow1{0%,25%{opacity:0;transform:translateY(4px)}29%{opacity:1;transform:none}100%{opacity:1;transform:none}}@keyframes sbRow2{0%,55%{opacity:0;transform:translateY(4px)}59%{opacity:1;transform:none}100%{opacity:1;transform:none}}@media (prefers-reduced-motion: reduce){.sb-start [style*="sbTrav"],.sb-start [style*="sbRing"]{animation:none!important;opacity:0!important}.sb-start [style*="sbIn1"],.sb-start [style*="sbIn2"],.sb-start [style*="sbRow0"],.sb-start [style*="sbRow1"],.sb-start [style*="sbRow2"]{animation:none!important;opacity:1!important;transform:none!important}}' } }); if (compact) { return h2("div", { className: "sb-start", style: { flex: 1, minHeight: 0, width: "100%", display: "flex", flexDirection: "column", background: "#0f0f11", color: "#e8e8eb" } }, [keyframeStyle, rightPanel, qrModal]); } return h2("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 = ``; const xIcon = ``; 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 `
${passed ? checkIcon : xIcon}
${esc(pretty(k))}
${esc(desc)}
`; }).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 = `
${score} / 100 pts
Real-time security verification

Security level: ${esc(level)}

Active
Tests passed
${passedCount} / ${totalCount}
Verified at
${esc(verifiedAt)}
Source
${isReal ? "Real cryptographic tests" : "Simulated data"}
${testsHTML}
${isReal ? "Real-time verification using actual cryptographic functions \u2014 no mock data." : "Warning: connection may not be fully established \u2014 values may be simulated."}
`; 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" : status === "reconnecting" ? "Restoring connection\u2026" : 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 = ''; const VIDEO_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 + var(--sb-safe-bottom, 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: '', chevR: '', plus: '', users: '', burger: '' }; var SessionsSidebar = ({ chats, groups = [], collapsed, drawerOpen, onToggleCollapse, onSelect, onSelectGroup, onNewChat, onNewGroup, onRename, onCloseDrawer, myStatus, onSetStatus }) => { const h2 = 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 = {}) => h2("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 = (svg2, style) => h2("span", { style: Object.assign({ display: "grid", placeItems: "center" }, style || {}), dangerouslySetInnerHTML: { __html: svg2 } }); const avatar = (c, size, ring) => h2("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, h2("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) => h2("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 && h2("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"), h2("div", { key: "body", style: { flex: 1, minWidth: 0 } }, [ h2("div", { key: "top", style: { display: "flex", alignItems: "center", gap: "7px" } }, [ editingId === c.id ? renameInput() : h2("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 && h2("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) ]), h2("div", { key: "prev", style: { fontSize: "12px", color: c.active ? "#8a8a92" : "#6b6b73", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, c.preview) ]) ]); const dockItem = (c) => h2("div", { key: c.id, style: { position: "relative" } }, [ c.active && h2("span", { key: "bar", style: { position: "absolute", left: "-13px", top: "9px", bottom: "9px", width: "3px", borderRadius: "0 3px 3px 0", background: "#f0892a" } }), h2("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, h2("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 && h2("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) => h2( "div", { style: { width: size + "px", height: size + "px", flex: "none", display: "grid", placeItems: "center" } }, h2("img", { src: "/logo/securebit-mark.svg", alt: "SecureBit", style: { width: "100%", height: "100%", objectFit: "contain", display: "block" } }) ); const collapseBtn = (svg2, title) => h2("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: svg2 } }); const myMeta = MY_STATUS_OPTIONS.find((o) => o.key === myStatus) || MY_STATUS_OPTIONS[0]; const PRES_SVG = { user: '', check: '', chevUp: '', lock: '' }; const presenceMenu = (pos) => presenceOpen ? h2("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) }, [ h2("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) => h2("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" } }, [ h2("span", { key: "d", style: { flex: "none", width: "10px", height: "10px", borderRadius: "50%", background: o.dot } }), h2("span", { key: "t", style: { flex: 1, minWidth: 0 } }, [ h2("span", { key: "w", style: { display: "block", fontSize: "13.5px", fontWeight: 600, color: "#e8e8eb" } }, o.word), h2("span", { key: "de", style: { display: "block", fontSize: "11.5px", color: "#6b6b73" } }, o.desc) ]), o.key === myStatus && h2("span", { key: "c", style: { flex: "none", display: "grid", placeItems: "center" }, dangerouslySetInnerHTML: { __html: PRES_SVG.check } }) ])), h2("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)" } }, [ h2("span", { key: "i", style: { flex: "none", marginTop: "1px", display: "grid" }, dangerouslySetInnerHTML: { __html: PRES_SVG.lock } }), h2("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 = h2("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" }), h2("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" } }, [ h2("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" } }, [ h2("span", { key: "i", style: { display: "grid" }, dangerouslySetInnerHTML: { __html: PRES_SVG.user } }), h2("span", { key: "dot", style: { position: "absolute", right: "-2px", bottom: "-2px", width: "11px", height: "11px", borderRadius: "50%", background: myMeta.dot, border: "2px solid #0c0c0e" } }) ]), h2("div", { key: "tx", style: { flex: 1, minWidth: 0, textAlign: "left" } }, [ h2("div", { key: "y", style: { fontSize: "13.5px", fontWeight: 700, color: "#f4f4f6" } }, "You"), h2("div", { key: "w", style: { fontSize: "12px", color: "#8a8a92" } }, myMeta.word) ]), h2("span", { key: "ch", style: { display: "grid", placeItems: "center" }, dangerouslySetInnerHTML: { __html: PRES_SVG.chevUp } }) ]) ]); const presencePanelCollapsed = h2("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" }), h2("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" } }, [ h2("span", { key: "i", style: { display: "grid" }, dangerouslySetInnerHTML: { __html: PRES_SVG.user } }), h2("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 = [ h2("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)" } }, [ h2("div", { key: "brand", style: { display: "flex", alignItems: "center", gap: "10px" } }, [brandMark(30), h2("span", { key: "t", style: { fontSize: "15px", fontWeight: 800, letterSpacing: "-0.3px", color: "#f4f4f6" } }, "SecureBit")]), collapseBtn(SB_SVG.chevL, "Collapse") ]), h2("div", { key: "label", style: { flex: "none", display: "flex", alignItems: "center", justifyContent: "space-between", padding: "16px 16px 9px" } }, [ h2("span", { key: "l", style: { fontFamily: "'JetBrains Mono',monospace", fontSize: "10px", fontWeight: 600, color: "#56565e", textTransform: "uppercase", letterSpacing: "1.3px" } }, "Chats"), h2("span", { key: "c", style: { fontFamily: "'JetBrains Mono',monospace", fontSize: "10px", fontWeight: 600, color: "#6b6b73" } }, String(chats.length)) ]), h2("div", { key: "list", className: "msc-scroll", style: { flex: 1, overflowY: "auto", padding: "0 10px" } }, [ ...chats.map(expandedRow), h2("div", { key: "gh", style: { marginTop: "14px", padding: "0 2px 6px", display: "flex", alignItems: "baseline", justifyContent: "space-between" } }, [ h2("span", { key: "l", style: { fontFamily: "'JetBrains Mono',monospace", fontSize: "10px", fontWeight: 600, color: "#56565e", textTransform: "uppercase", letterSpacing: "1.3px" } }, "Group chats"), h2("button", { key: "add", onClick: onNewGroup, title: "New group", style: { border: "none", background: "transparent", color: "#f0892a", cursor: "pointer", fontFamily: "'JetBrains Mono',monospace", fontSize: "10px", fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.9px", padding: 0 } }, "+ New") ]), ...groups.length === 0 ? [h2("div", { key: "gph", onClick: onNewGroup, title: "Create a group", style: { display: "flex", alignItems: "center", gap: "12px", padding: "11px 12px", borderRadius: "11px", background: "transparent", border: "1px dashed rgba(255,255,255,0.09)", cursor: "pointer" } }, [ h2("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 } }), h2("div", { key: "b", style: { flex: 1, minWidth: 0 } }, [ h2("div", { key: "t", style: { fontSize: "14px", fontWeight: 600, color: "#8a8a92" } }, "New group"), h2("div", { key: "s", style: { fontSize: "11.5px", color: "#56565e" } }, "Up to 8 peers \xB7 P2P mesh") ]) ])] : groups.map((g) => h2("div", { key: g.id, onClick: () => onSelectGroup(g.id), style: { position: "relative", display: "flex", alignItems: "center", gap: "12px", padding: "11px 12px", marginBottom: "4px", borderRadius: "11px", background: g.active ? "#161618" : "transparent", border: "1px solid " + (g.active ? "rgba(255,255,255,0.08)" : "transparent"), cursor: "pointer" } }, [ g.active && h2("span", { key: "bar", style: { position: "absolute", left: 0, top: "12px", bottom: "12px", width: "3px", borderRadius: "0 3px 3px 0", background: "#f0892a" } }), avatar(g, 38, g.active ? "#161618" : "#0c0c0e"), h2("div", { key: "body", style: { flex: 1, minWidth: 0 } }, [ h2("div", { key: "top", style: { display: "flex", alignItems: "center", gap: "7px" } }, [ h2("span", { key: "n", style: { flex: 1, minWidth: 0, fontSize: "14px", fontWeight: g.active ? 700 : 600, letterSpacing: "-0.2px", color: g.active ? "#f4f4f6" : "#cfcfd4", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, g.name), g.unread && h2("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 } }, g.unread) ]), h2("div", { key: "prev", style: { fontSize: "12px", color: g.active ? "#8a8a92" : "#6b6b73", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, g.preview) ]) ])) ]), h2("div", { key: "new", style: { flex: "none", padding: "12px" } }, h2("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 = [ h2("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")]), h2("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), h2("div", { key: "sep", style: { width: "30px", height: "1px", background: "rgba(255,255,255,0.07)", margin: "2px 0" } }), ...groups.map((g) => h2("div", { key: g.id, style: { position: "relative" } }, [ g.active && h2("span", { key: "bar", style: { position: "absolute", left: "-13px", top: "9px", bottom: "9px", width: "3px", borderRadius: "0 3px 3px 0", background: "#f0892a" } }), h2("div", { key: "tile", onClick: () => onSelectGroup(g.id), title: g.name + " \u2014 " + g.headerSub, style: { position: "relative", width: "44px", height: "44px", borderRadius: "12px", display: "grid", placeItems: "center", cursor: "pointer", background: g.active ? "rgba(255,255,255,0.06)" : "rgba(255,255,255,0.03)", border: "1px solid rgba(255,255,255," + (g.active ? "0.14" : "0.07") + ")", fontSize: "13px", fontWeight: 700, letterSpacing: "-0.3px", color: g.active ? "#f4f4f6" : "#9a9aa2" } }, [ g.mono, h2("span", { key: "dot", style: { position: "absolute", right: "-2px", bottom: "-2px", width: "11px", height: "11px", borderRadius: "50%", background: g.dot, border: "2.5px solid #0c0c0e" } }), g.unread && h2("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" } }, g.unread) ]) ])), h2("div", { key: "gph", onClick: onNewGroup, title: "New group", style: { position: "relative", width: "44px", height: "44px", borderRadius: "12px", display: "grid", placeItems: "center", cursor: "pointer", background: "transparent", border: "1px dashed rgba(255,255,255,0.1)", color: "#56565e" }, dangerouslySetInnerHTML: { __html: SB_SVG.users } }) ]), h2("div", { key: "new", style: { flex: "none", padding: "13px 0" } }, h2("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 h2(React.Fragment, null, [ // Responsive behaviour (inline styles can't express media queries). h2("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;min-height:0 !important;overflow:hidden;}.sb-chat-header{position:sticky;top:0;z-index:20;}.sb-app-col{height:100% !important;min-height:0 !important;}.chat-container{height:100% !important;min-height:0 !important;}.sb-scroll{min-height:0 !important;overscroll-behavior:contain;-webkit-overflow-scrolling:touch;}@media (max-width:768px){textarea,input,select{font-size:16px !important;}}@media (max-width:768px){.sb-rename-btn{display:none !important;}}" } }), // Desktop rail h2("aside", { key: "rail", className: "sb-rail", style: railStyle }, inner), // Mobile drawer overlay h2("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" } }, h2("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. h2("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" } }, h2("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 statusRef = React.useRef(/* @__PURE__ */ new Map()); const [groupsState, groupsDispatch] = React.useReducer(groupsReducer, void 0, createInitialGroupState); const groupRuntimesRef = React.useRef(/* @__PURE__ */ new Map()); const activeGroupId = groupsState.activeGroupId; const activeGroup = activeGroupId ? groupsState.groups[activeGroupId] : null; const activeGroupIdRef = React.useRef(null); activeGroupIdRef.current = activeGroupId; const [groupInput, setGroupInput] = React.useState(""); const [showCreateGroup, setShowCreateGroup] = React.useState(false); const [showGroupCode, setShowGroupCode] = React.useState(false); const [pendingInvite, setPendingInvite] = React.useState(null); const [groupError, setGroupError] = React.useState(null); const [showAddMembers, setShowAddMembers] = React.useState(false); const groupScrollRef = React.useRef(null); const meshLinksRef = React.useRef(/* @__PURE__ */ new Map()); const lastUnreachableRef = React.useRef(/* @__PURE__ */ new Map()); const sendGroupFrame = React.useMemo(() => createGroupSender({ // A group frame leaves over whichever kind of link carries that member: // an ordinary 1:1 chat, or a connection the group dialled itself. getManager: (sessionId) => managersRef.current.get(sessionId) || meshLinksRef.current.get(sessionId)?.manager || null }), []); const buildGroupMessage = (body, type, extra = {}) => ({ id: Date.now() + Math.random(), message: body, type, timestamp: Date.now(), ...extra }); const groupEmitter = React.useCallback((gid) => (event, payload = {}) => { switch (event) { case "phase": groupsDispatch({ type: GROUP_ACTIONS.SET_PHASE, id: gid, phase: payload.phase }); break; case "members": groupsDispatch({ type: GROUP_ACTIONS.SET_MEMBERS, id: gid, members: payload.members, epoch: payload.epoch }); break; case "roster": groupsDispatch({ type: GROUP_ACTIONS.RENAME, id: gid, name: payload.name }); break; case "sas": groupsDispatch({ type: GROUP_ACTIONS.SET_SAS, id: gid, code: payload.code }); groupsDispatch({ type: GROUP_ACTIONS.SET_ACTIVE_GROUP, id: gid }); setShowGroupCode(true); break; case "confirmed": groupsDispatch({ type: GROUP_ACTIONS.CONFIRM_SAS, id: gid }); break; case "message": groupsDispatch({ type: GROUP_ACTIONS.ADD_MESSAGE, id: gid, message: buildGroupMessage(payload.body, "received", { senderName: payload.name, senderFp: payload.fp, timestamp: payload.ts, relayed: payload.relayed === true }) }); if (gid !== activeGroupIdRef.current) { groupsDispatch({ type: GROUP_ACTIONS.INCREMENT_UNREAD, id: gid }); } break; case "ended": destroyGroupRef.current(gid, { announce: false }); break; case "add_failed": groupsDispatch({ type: GROUP_ACTIONS.ADD_MESSAGE, id: gid, message: buildGroupMessage("Nobody accepted the invitation. The group is unchanged.", "system") }); break; case "left": groupsDispatch({ type: GROUP_ACTIONS.ADD_MESSAGE, id: gid, message: buildGroupMessage(`${payload.name} left the group.`, "system") }); break; case "inconsistency": groupsDispatch({ type: GROUP_ACTIONS.ADD_MESSAGE, id: gid, message: buildGroupMessage( `${payload.name} sent conflicting messages to different members. That message was discarded.`, "system" ) }); break; case "error": groupsDispatch({ type: GROUP_ACTIONS.SET_ERROR, id: gid, error: payload.error }); break; default: break; } }, []); const handleGroupFrame = React.useCallback((sessionId, frame) => { const runtime = groupRuntimesRef.current.get(frame.gid); if (!runtime) { if (groupFrameType(frame) === GROUP_FRAMES.INVITE) { let invite = null; try { invite = decodeEnvelope(frame); } catch (_) { return; } setPendingInvite((current) => current || { gid: invite.gid, name: String(invite.name || "Group").slice(0, GROUP_LIMITS.MAX_NAME_BYTES), frame: invite, sessionId }); } return; } runtime.handleFrame(sessionId, frame).catch((error) => { const reason = error?.code || (error?.message ? `frame_rejected: ${String(error.message).slice(0, 120)}` : "frame_rejected"); groupsDispatch({ type: GROUP_ACTIONS.SET_ERROR, id: frame.gid, error: reason }); }); }, []); const handleGroupFrameRef = React.useRef(handleGroupFrame); handleGroupFrameRef.current = handleGroupFrame; const syncGroupLinks = React.useCallback((sessionId, connected) => { for (const runtime of groupRuntimesRef.current.values()) { try { runtime.setSessionState(sessionId, connected); } catch (_) { } } }, []); const syncGroupLinksRef = React.useRef(syncGroupLinks); syncGroupLinksRef.current = syncGroupLinks; 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); const resync = () => { if (document.visibilityState !== "visible") return; setIsOffline(navigator.onLine === false); }; window.addEventListener("offline", goOffline); window.addEventListener("online", goOnline); document.addEventListener("visibilitychange", resync); return () => { window.removeEventListener("offline", goOffline); window.removeEventListener("online", goOnline); document.removeEventListener("visibilitychange", resync); }; }, []); React.useEffect(() => { const vv = typeof window !== "undefined" ? window.visualViewport : null; const root = document.documentElement; let lastH = -1, lastInset = ""; const applyHeight = () => { const h2 = Math.round(vv ? vv.height : window.innerHeight || 0); if (h2 && h2 !== lastH) { lastH = h2; root.style.setProperty("--sb-vh", h2 + "px"); } }; const applyInset = () => { const covered = !!vv && window.innerHeight - vv.height > 120; const v = covered ? "0px" : "env(safe-area-inset-bottom, 0px)"; if (v !== lastInset) { lastInset = v; root.style.setProperty("--sb-safe-bottom", v); } }; const apply = () => { applyHeight(); applyInset(); }; apply(); if (vv) vv.addEventListener("resize", apply); window.addEventListener("resize", apply); window.addEventListener("orientationchange", apply); return () => { if (vv) vv.removeEventListener("resize", 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 flushSessionQueue = React.useCallback((id) => { const q = queuesRef.current.get(id); if (!q) return; const mgr = managersRef.current.get(id); const out = q.outgoing; q.outgoing = []; const deferred = []; for (const item of out) { if (!mgr || mgr.isConnected?.() !== true) { deferred.push(item); continue; } try { 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" })); } } catch (_) { deferred.push(item); } } if (deferred.length) q.outgoing = deferred.concat(q.outgoing); 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); } } }, []); const flushOfflineQueues = React.useCallback(() => { for (const id of queuesRef.current.keys()) flushSessionQueue(id); }, [flushSessionQueue]); React.useEffect(() => { if (isOffline) return; flushOfflineQueues(); }, [isOffline, flushOfflineQueues]); React.useEffect(() => { const timer = setInterval(() => { for (const [id, q] of queuesRef.current.entries()) { if (!q.outgoing.length && !q.incoming.length) continue; const mgr = managersRef.current.get(id); if (mgr?.isConnected?.() !== true) continue; if (mgr?.isReconnecting?.() === true) continue; flushSessionQueue(id); } }, 2e3); return () => clearInterval(timer); }, [flushSessionQueue]); 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; } if (isGroupFrame(parsedMessage)) { handleGroupFrameRef.current(id, parsedMessage); 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) => { const prevStatus = statusRef.current.get(id); statusRef.current.set(id, status); setConnectionStatus2(status); syncGroupLinksRef.current(id, status === "connected" || status === "verified"); if (status === "reconnecting") return; if (status === "connected" && prevStatus === "reconnecting") { flushSessionQueue(id); } if (status === "recovery_failed") { setConnectionStatus2("disconnected"); if (id === activeIdRef.current) { document.dispatchEvent(new CustomEvent("peer-disconnect")); document.dispatchEvent(new CustomEvent("disconnected")); } setTimeout(() => { if (sessionCarriesGroupMemberRef.current(id)) return; destroySession(id); }, 2500); return; } 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); setTimeout(() => { if (sessionCarriesGroupMemberRef.current(id)) return; destroySession(id); }, 2500); } }; 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.6.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 sessionCarriesGroupMember = React.useCallback((id) => { for (const runtime of groupRuntimesRef.current.values()) { if (runtime.sessionToFp && runtime.sessionToFp.has(id)) return true; } return false; }, []); const sessionCarriesGroupMemberRef = React.useRef(sessionCarriesGroupMember); sessionCarriesGroupMemberRef.current = sessionCarriesGroupMember; 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); statusRef.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, label2) => { dispatch({ type: SESSION_ACTIONS.RENAME, id, label: label2 }); }, []); const closeMeshLink = React.useCallback((sessionId) => { const entry = meshLinksRef.current.get(sessionId); if (!entry) return; meshLinksRef.current.delete(sessionId); try { entry.manager.disconnect(); } catch (_) { } }, []); const closeMeshLinkRef = React.useRef(closeMeshLink); closeMeshLinkRef.current = closeMeshLink; const buildMeshLink = React.useCallback((gid, fp) => { const sessionId = "mesh:" + Array.from(crypto.getRandomValues(new Uint8Array(8))).map((b) => b.toString(16).padStart(2, "0")).join(""); const entry = { manager: null, gid, fp, sessionId }; const onMessage = (message) => { if (typeof message !== "string" || !message.trim().startsWith("{")) return; let parsed; try { parsed = JSON.parse(message); } catch (_) { return; } if (isGroupFrame(parsed)) handleGroupFrameRef.current(sessionId, parsed); }; const onStatusChange = (status) => { const dead = status === "disconnected" || status === "failed" || status === "peer_disconnected" || status === "recovery_failed"; syncGroupLinksRef.current(sessionId, !dead && entry.manager?.isVerified === true); if (status === "recovery_failed" || status === "failed") { const runtime = groupRuntimesRef.current.get(gid); try { runtime?.unbindSession(fp); } catch (_) { } closeMeshLinkRef.current(sessionId); } }; const onVerificationRequired = (code) => { if (!code || !entry.manager) return; try { entry.manager.markGroupLinkVerified(`group:${gid}`); } catch (_) { closeMeshLinkRef.current(sessionId); syncGroupLinksRef.current(sessionId, false); } }; const manager = new EnhancedSecureWebRTCManager( onMessage, onStatusChange, () => { }, // key exchange: no fingerprint UI to update onVerificationRequired, () => { }, // answer errors surface as a failed dial () => { }, // no verification UI to keep in step { // A mesh link has no window of its own, so it must not // announce itself to the application: its lifecycle events // would reset the header and the connection banner belonging // to whichever chat the user is actually looking at. emitGlobalEvents: false, webrtc: { relayOnly: relayOnlyMode, iceServers: Array.isArray(customIceServers) && customIceServers.length ? customIceServers : Array.isArray(window.SECUREBIT_ICE_SERVERS) ? window.SECUREBIT_ICE_SERVERS : void 0 } } ); entry.manager = manager; meshLinksRef.current.set(sessionId, entry); return entry; }, [relayOnlyMode, customIceServers]); const buildMeshLinkRef = React.useRef(buildMeshLink); buildMeshLinkRef.current = buildMeshLink; const makeMeshAdapter = React.useCallback((gid) => ({ createOffer: async (fp) => { const entry = buildMeshLinkRef.current(gid, fp); try { const offer = await entry.manager.createSecureOffer(); if (!offer || typeof offer.sbq2 !== "string") { throw new Error("mesh dial needs a compact descriptor"); } return { sessionId: entry.sessionId, descriptor: offer.sbq2 }; } catch (error) { closeMeshLinkRef.current(entry.sessionId); throw error; } }, createAnswer: async (fp, descriptor) => { const entry = buildMeshLinkRef.current(gid, fp); try { const answer = await entry.manager.createSecureAnswer({ t: "offer", sbq2: descriptor }); if (!answer || typeof answer.sbq2 !== "string") { throw new Error("mesh answer needs a compact descriptor"); } return { sessionId: entry.sessionId, descriptor: answer.sbq2 }; } catch (error) { closeMeshLinkRef.current(entry.sessionId); throw error; } }, acceptAnswer: async (sessionId, descriptor) => { const entry = meshLinksRef.current.get(sessionId); if (!entry) throw new Error("that mesh dial is no longer open"); await entry.manager.handleSecureAnswer({ t: "answer", sbq2: descriptor }); }, close: (sessionId) => closeMeshLinkRef.current(sessionId), /** * The key fingerprint of a pairwise session, for link probes. * * Both endpoints of a session derive the same value from the shared * secret and nobody else can, which is exactly what makes it usable as * proof that a probe was written for THIS link. Only a verified session * has one worth anything. */ linkFingerprint: (sessionId) => { const manager = managersRef.current.get(sessionId) || meshLinksRef.current.get(sessionId)?.manager || null; if (!manager || manager.isVerified !== true) return ""; return typeof manager.keyFingerprint === "string" ? manager.keyFingerprint : ""; } }), []); React.useEffect(() => { for (const [gid, runtime] of groupRuntimesRef.current) { const group = groupsState.groups[gid]; if (!group || group.phase !== GROUP_PHASE.READY || !group.sasConfirmed) continue; for (const sid of sessionsState.order) { const session = sessionsState.sessions[sid]; if (!session || !session.sas.isVerified) continue; runtime.probeSession(sid).catch(() => { }); } } }, [groupsState, sessionsState]); const groupCandidates = React.useMemo( () => sessionsState.order.map((id) => sessionsState.sessions[id]).filter((s) => s && s.sas && s.sas.isVerified).map((s) => ({ id: s.id, name: s.peerLabel, mono: monoInitials(s.peerLabel) })), [sessionsState] ); const handleSelectGroup = React.useCallback((gid) => { groupsDispatch({ type: GROUP_ACTIONS.SET_ACTIVE_GROUP, id: gid }); groupsDispatch({ type: GROUP_ACTIONS.CLEAR_UNREAD, id: gid }); setSidebarDrawerOpen(false); }, []); const handleCreateGroup = React.useCallback(async ({ name, sessionIds }) => { setShowCreateGroup(false); const gid = GroupSession.newId(); const runtime = new GroupSession({ groupId: gid, name, isAdmin: true, subtle: crypto.subtle, send: sendGroupFrame, emit: groupEmitter(gid), mesh: makeMeshAdapter(gid) }); groupRuntimesRef.current.set(gid, runtime); try { await runtime.init(); groupsDispatch({ type: GROUP_ACTIONS.CREATE_GROUP, entry: createGroupEntry({ id: gid, name, selfFp: runtime.selfFp, adminFp: runtime.selfFp, isAdmin: true, members: runtime._memberSnapshot() }) }); await runtime.invite(sessionIds.map((sid) => ({ sessionId: sid, name: sessionsState.sessions[sid]?.peerLabel || "Member" }))); } catch (error) { groupRuntimesRef.current.delete(gid); try { runtime.destroy(); } catch (_) { } groupsDispatch({ type: GROUP_ACTIONS.REMOVE_GROUP, id: gid }); setGroupError(error?.code === "invitations_could_not_be_sent" ? "The invitation could not be sent. That chat is not connected right now \u2014 reopen it and try again." : `The group could not be created (${error?.code || "unknown error"}).`); } }, [sendGroupFrame, groupEmitter, sessionsState]); const handleAcceptInvite = React.useCallback(async () => { const invite = pendingInvite; if (!invite) return; setPendingInvite(null); const runtime = new GroupSession({ groupId: invite.gid, name: invite.name, isAdmin: false, subtle: crypto.subtle, send: sendGroupFrame, emit: groupEmitter(invite.gid), mesh: makeMeshAdapter(invite.gid) }); groupRuntimesRef.current.set(invite.gid, runtime); try { await runtime.init(); groupsDispatch({ type: GROUP_ACTIONS.CREATE_GROUP, entry: createGroupEntry({ id: invite.gid, name: invite.name, selfFp: runtime.selfFp }) }); await runtime.acceptInvite(invite.sessionId, invite.frame); } catch (error) { groupRuntimesRef.current.delete(invite.gid); try { runtime.destroy(); } catch (_) { } groupsDispatch({ type: GROUP_ACTIONS.SET_ERROR, id: invite.gid, error: error?.code || "join_failed" }); } }, [pendingInvite, sendGroupFrame, groupEmitter]); const handleConfirmGroupSas = React.useCallback(() => { const gid = activeGroupIdRef.current; const runtime = gid && groupRuntimesRef.current.get(gid); if (!runtime) return; try { runtime.confirmSas(); setShowGroupCode(false); } catch (error) { groupsDispatch({ type: GROUP_ACTIONS.SET_ERROR, id: gid, error: error?.code || "confirm_failed" }); } }, []); const destroyGroup = React.useCallback((gid, { announce = true } = {}) => { const runtime = groupRuntimesRef.current.get(gid); groupRuntimesRef.current.delete(gid); lastUnreachableRef.current.delete(gid); groupsDispatch({ type: GROUP_ACTIONS.REMOVE_GROUP, id: gid }); setShowGroupCode(false); if (runtime) { const done = announce ? Promise.resolve().then(() => runtime.leave()).catch(() => { }) : Promise.resolve(); done.then(() => { try { runtime.destroy(); } catch (_) { } }); } }, []); const destroyGroupRef = React.useRef(destroyGroup); destroyGroupRef.current = destroyGroup; const handleSendGroupMessage = React.useCallback(async (text) => { const gid = activeGroupIdRef.current; const runtime = gid && groupRuntimesRef.current.get(gid); if (!runtime) return; const body = String(text || "").trim(); if (!body) return; setGroupInput(""); try { const { unreachable } = await runtime.sendText(body); groupsDispatch({ type: GROUP_ACTIONS.ADD_MESSAGE, id: gid, message: buildGroupMessage(body, "sent") }); const signature = unreachable.map((m) => m.fp).sort().join(","); if (signature !== (lastUnreachableRef.current.get(gid) || "")) { lastUnreachableRef.current.set(gid, signature); if (unreachable.length > 0) { const names = unreachable.map((m) => m.name); const who = names.length === 1 ? `${names[0]} is` : `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]} are`; groupsDispatch({ type: GROUP_ACTIONS.ADD_MESSAGE, id: gid, message: buildGroupMessage( `${who} offline and will not receive messages until they reconnect.`, "system" ) }); } } } catch (error) { groupsDispatch({ type: GROUP_ACTIONS.ADD_MESSAGE, id: gid, message: buildGroupMessage(`Could not send: ${error?.message || "unknown error"}`, "system") }); } }, []); const addCandidates = React.useMemo(() => { if (!activeGroup) return []; const taken = new Set(activeGroup.members.map((m) => m.sessionId).filter(Boolean)); return groupCandidates.filter((c) => !taken.has(c.id)); }, [activeGroup, groupCandidates]); const handleAddMembers = React.useCallback(async (sessionIds) => { setShowAddMembers(false); const gid = activeGroupIdRef.current; const runtime = gid && groupRuntimesRef.current.get(gid); if (!runtime) return; try { await runtime.addMembers(sessionIds.map((sid) => ({ sessionId: sid, name: sessionsState.sessions[sid]?.peerLabel || "Member" }))); } catch (error) { setGroupError(error?.code === "invitations_could_not_be_sent" ? "The invitation could not be sent. That chat is not connected right now." : error?.code === "too_many_members" ? `A group is limited to ${GROUP_LIMITS.MAX_MEMBERS} members.` : `Could not invite (${error?.code || "unknown error"}).`); } }, [sessionsState]); const handleRemoveGroupMember = React.useCallback(async (fp) => { const gid = activeGroupIdRef.current; const runtime = gid && groupRuntimesRef.current.get(gid); if (!runtime) return; try { await runtime.removeMember(fp); } catch (error) { groupsDispatch({ type: GROUP_ACTIONS.SET_ERROR, id: gid, error: error?.code || "remove_failed" }); } }, []); React.useEffect(() => { if (!activeGroupId) return; groupsDispatch({ type: GROUP_ACTIONS.CLEAR_UNREAD, id: activeGroupId }); }, [activeGroupId]); React.useEffect(() => { const el = groupScrollRef.current; if (el) el.scrollTop = el.scrollHeight; }, [activeGroup && activeGroup.messages.length]); React.useEffect(() => () => { for (const runtime of groupRuntimesRef.current.values()) { try { runtime.destroy(); } catch (_) { } } groupRuntimesRef.current.clear(); for (const sessionId of [...meshLinksRef.current.keys()]) { closeMeshLinkRef.current(sessionId); } }, []); 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(); statusRef.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 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("SB2:") || scannedData.charCodeAt(0) === 2) { qrChunksBufferRef.current = { id: null, total: 0, seen: /* @__PURE__ */ new Set(), items: [] }; if (showOfferStep) { setAnswerInput(scannedData); } else { setOfferInput(scannedData); } setMessages((prev) => [...prev, { message: "Invitation captured.", type: "success" }]); setShowQRScannerModal(false); return Promise.resolve(true); } 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()}`, // SB1 payloads are split into exactly four // frames by the generator above. SBQ2 never // reaches here — it is one frame and is // handled at the top of this function. total: 4, 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()}`, // SB1 payloads are split into exactly four // frames by the generator above. SBQ2 never // reaches here — it is one frame and is // handled at the top of this function. total: 4, 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") { setMessages((prev) => [...prev, { message: "This QR code uses a retired format that could not transfer the invitation. Ask your peer to generate a new one, or 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 mgr = webrtcManagerRef.current; const channelUsable = mgr?.isConnected?.() === true && mgr?.isReconnecting?.() !== true; if (!channelUsable && mgr?.isConnected) { 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 (!channelUsable) { addMessageWithAutoScroll("Not sent \u2014 the secure channel is not ready. Reconnect to continue.", "system"); 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 reconnecting = webrtcManagerRef.current?.isReconnecting?.() === true; const notReady = reconnecting || webrtcManagerRef.current?.isConnected?.() !== true; if (notReady) { if (localUrl) { try { URL.revokeObjectURL(localUrl); } catch (_) { } } addMessageWithAutoScroll( reconnecting ? "Restoring the connection \u2014 try sending the voice message again in a moment." : "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"); }; const prevConnStatusRef = React.useRef(connectionStatus); React.useEffect(() => { const resumed = prevConnStatusRef.current === "reconnecting"; prevConnStatusRef.current = connectionStatus; if (connectionStatus === "connected" && isVerified && !resumed) { 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 previewMode = React.useMemo(() => { try { return new URLSearchParams(window.location.search).get("preview") === "chat"; } catch { return false; } }, []); const isConnectedAndVerified = (connectionStatus === "connected" || connectionStatus === "verified" || connectionStatus === "reconnecting") && isVerified; React.useEffect(() => { document.body.classList.toggle("sb-in-chat", isConnectedAndVerified || previewMode); return () => document.body.classList.remove("sb-in-chat"); }, [isConnectedAndVerified, previewMode]); 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 groupChats = decorateGroups(groupsState); const showSidebar = sessionsState.order.length > 1 || groupsState.order.length > 0 || sessionsState.order.some((id) => { const s = sessionsState.sessions[id]; return s && s.sas && s.sas.isVerified; }); if (previewMode) { const previewMessages = [ { message: "Preview mode \u2014 no connection is open.", type: "system", id: 1, timestamp: Date.now() - 3e5 }, { message: "This renders the real chat components so the layout can be checked without a handshake.", type: "received", id: 2, timestamp: Date.now() - 24e4 }, { message: "Header pinned, list scrolls, composer sits above the keyboard.", type: "sent", id: 3, timestamp: Date.now() - 18e4 }, ...Array.from({ length: 30 }, (_, i) => ({ message: "Filler message " + (i + 1) + " \u2014 enough content to make the list scroll.", type: i % 2 ? "sent" : "received", id: 10 + i, timestamp: Date.now() - (30 - i) * 5e3 })) ]; const noop = () => { }; return React.createElement("div", { className: "minimal-bg sb-app-shell", style: { display: "flex", flexDirection: "row", height: "100vh", width: "100%", overflow: "hidden" } }, [ React.createElement(SessionsSidebar, { key: "sidebar", chats: [{ id: "preview", label: "Preview peer", unread: 0, active: true, verified: true }], collapsed: sidebarCollapsed, drawerOpen: sidebarDrawerOpen, onToggleCollapse: () => setSidebarCollapsed((v) => !v), onSelect: noop, onNewChat: noop, onRename: noop, onCloseDrawer: () => setSidebarDrawerOpen(false), myStatus, onSetStatus: setMyStatus }), React.createElement("button", { key: "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: "col", className: "minimal-bg sb-app-col", style: { flex: 1, minWidth: 0, height: "100vh", overflow: "hidden", display: "flex", flexDirection: "column" } }, React.createElement( "main", { key: "main" }, React.createElement(EnhancedChatInterface, { title: "Preview peer", isOffline: false, peerPresence: "available", onRenameTitle: noop, messages: previewMessages, messageInput, setMessageInput, onSendMessage: noop, onSendVoice: noop, onDisconnect: noop, keyFingerprint: "preview", isVerified: true, chatMessagesRef, scrollToBottom: noop, webrtcManager: null, status: "connected", pendingIncomingFiles: [], onIncomingDecision: noop, codeMode, setCodeMode, viewOnceMode, setViewOnceMode, viewOnceTtl, setViewOnceTtl, disappearTtl, setDisappearTtl, nowTick, onUnsendMessage: noop, onMessageExpire: noop }) )) ]); } 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, groups: groupChats, collapsed: sidebarCollapsed, drawerOpen: sidebarDrawerOpen, onToggleCollapse: () => setSidebarCollapsed((v) => !v), // Picking a 1:1 chat drops the group out of the foreground, and // vice versa — one conversation is on screen at a time. onSelect: (id) => { groupsDispatch({ type: GROUP_ACTIONS.SET_ACTIVE_GROUP, id: null }); handleSelectSession(id); }, onSelectGroup: handleSelectGroup, onNewChat: handleNewChat, onNewGroup: () => { setShowCreateGroup(true); setSidebarDrawerOpen(false); }, onRename: handleRenameSession, onCloseDrawer: () => setSidebarDrawerOpen(false), myStatus, onSetStatus: setMyStatus }), // ---- Group dialogs ---- showCreateGroup && React.createElement(CreateGroupModal, { key: "create-group", candidates: groupCandidates, relayOnly: relayOnlyMode, onCreate: handleCreateGroup, onCancel: () => setShowCreateGroup(false) }), pendingInvite && React.createElement(GroupInviteModal, { key: "group-invite", invite: { name: pendingInvite.name, fromLabel: sessionsState.sessions[pendingInvite.sessionId]?.peerLabel || "A verified contact" }, onAccept: handleAcceptInvite, onDecline: () => setPendingInvite(null) }), groupError && React.createElement(GroupErrorModal, { key: "group-error", message: groupError, onDismiss: () => setGroupError(null) }), showAddMembers && activeGroup && React.createElement(AddMembersModal, { key: "add-members", candidates: addCandidates, remaining: Math.max(0, GROUP_LIMITS.MAX_MEMBERS - activeGroup.members.length), onAdd: handleAddMembers, onCancel: () => setShowAddMembers(false) }), showGroupCode && activeGroup && React.createElement(GroupSasModal, { key: "group-sas", group: activeGroup, onConfirm: handleConfirmGroupSas, onCancel: () => { if (activeGroup.phase === GROUP_PHASE.READY) setShowGroupCode(false); else destroyGroup(activeGroup.id); } }), // 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 }), // A group takes the whole column when it is the active conversation. // It renders its own header and composer, so none of the 1:1 chrome // (which is bound to a single webrtcManager) applies. activeGroup && React.createElement("main", { key: "group-main", style: { flex: 1, minHeight: 0, display: "flex", flexDirection: "column" } }, React.createElement(GroupChatView, { group: activeGroup, input: groupInput, setInput: setGroupInput, onSend: handleSendGroupMessage, onLeave: () => destroyGroup(activeGroup.id), onRemoveMember: handleRemoveGroupMember, onAddMembers: () => setShowAddMembers(true), isAdmin: activeGroup.isAdmin, scrollRef: groupScrollRef })), !activeGroup && React.createElement( "main", { key: "main" }, /* @__PURE__ */ (() => { return isConnectedAndVerified; })() ? (() => { return React.createElement(EnhancedChatInterface, { title: active ? active.peerLabel : "", isOffline, peerPresence: active ? active.peerPresence : null, onRenameTitle: (label2) => { if (activeSessionId) dispatch({ type: SESSION_ACTIONS.RENAME, id: activeSessionId, label: label2 }); }, 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: '' } }), 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: '' } }), 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