241 lines
12 KiB
TypeScript
241 lines
12 KiB
TypeScript
/* eslint-disable */
|
|
// @ts-nocheck
|
|
"use client";
|
|
|
|
import React, { useEffect, useRef, useState } from "react";
|
|
import { XCUQuantumCipher } from "../lib/xcu-quantum-cipher";
|
|
|
|
interface Message {
|
|
id: string;
|
|
sender: string;
|
|
text: string;
|
|
timestamp: number;
|
|
isSelf: boolean;
|
|
isResonanceAudio?: boolean;
|
|
}
|
|
|
|
export const JumlahChat = ({
|
|
roomName,
|
|
participantName,
|
|
participantId,
|
|
webTransport,
|
|
onClose,
|
|
}: {
|
|
roomName: string;
|
|
participantName: string;
|
|
participantId: number;
|
|
webTransport: { datagrams: { readable: ReadableStream, writable: WritableStream } } | null;
|
|
onClose: () => void;
|
|
}) => {
|
|
const [messages, setMessages] = useState<Message[]>([]);
|
|
const [input, setInput] = useState("");
|
|
const [typingHeat, setTypingHeat] = useState(0);
|
|
const [isRecording, setIsRecording] = useState(false);
|
|
|
|
const cipherRef = useRef<XCUQuantumCipher | null>(null);
|
|
const chatEndRef = useRef<HTMLDivElement>(null);
|
|
const typingTimeout = useRef<NodeJS.Timeout | null>(null);
|
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
|
const audioChunksRef = useRef<Blob[]>([]);
|
|
|
|
useEffect(() => {
|
|
const initCipher = async () => {
|
|
const cipher = new XCUQuantumCipher(roomName);
|
|
await cipher.initialize();
|
|
cipherRef.current = cipher;
|
|
};
|
|
initCipher();
|
|
}, [roomName]);
|
|
|
|
useEffect(() => {
|
|
chatEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
}, [messages, typingHeat]);
|
|
|
|
useEffect(() => {
|
|
if (!webTransport || !webTransport.datagrams) return;
|
|
let isActive = true;
|
|
const readDatagrams = async () => {
|
|
try {
|
|
const reader = webTransport.datagrams.readable.getReader();
|
|
while (isActive) {
|
|
const { value, done } = await reader.read();
|
|
if (done) break;
|
|
if (value && value.length >= 8) {
|
|
const type = value[0];
|
|
const senderId = new DataView(value.buffer).getUint16(2, true);
|
|
if (senderId === participantId) continue;
|
|
const payload = value.slice(8);
|
|
if (type === 7 && cipherRef.current) {
|
|
try {
|
|
const dec = await cipherRef.current.decrypt(payload);
|
|
const parsed = JSON.parse(dec);
|
|
setMessages((prev) => [...prev, { id: crypto.randomUUID(), sender: parsed.sender, text: parsed.text, timestamp: parsed.timestamp, isSelf: false }]);
|
|
} catch (e) {}
|
|
} else if (type === 8) {
|
|
setTypingHeat((prev) => Math.min(prev + 20, 100));
|
|
if (typingTimeout.current) clearTimeout(typingTimeout.current);
|
|
typingTimeout.current = setTimeout(() => setTypingHeat(0), 1000);
|
|
} else if (type === 9 && cipherRef.current) {
|
|
try {
|
|
const dec = await cipherRef.current.decrypt(payload);
|
|
const parsed = JSON.parse(dec);
|
|
setMessages((prev) => [...prev, { id: crypto.randomUUID(), sender: parsed.sender, text: parsed.audioBase64, timestamp: parsed.timestamp, isSelf: false, isResonanceAudio: true }]);
|
|
} catch {}
|
|
}
|
|
}
|
|
}
|
|
} catch {}
|
|
};
|
|
readDatagrams();
|
|
return () => { isActive = false; };
|
|
}, [webTransport, participantId]);
|
|
|
|
const sendTypingResonance = async () => {
|
|
if (!webTransport || !webTransport.datagrams) return;
|
|
let writer: WritableStreamDefaultWriter | null = null;
|
|
try {
|
|
writer = webTransport.datagrams.writable.getWriter();
|
|
const buf = new Uint8Array(8);
|
|
buf[0] = 8; new DataView(buf.buffer).setUint16(2, participantId, true);
|
|
await writer.write(buf);
|
|
} catch {} finally { if (writer) writer.releaseLock(); }
|
|
};
|
|
|
|
const sendMessage = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!input.trim() || !cipherRef.current || !webTransport) return;
|
|
const payloadStr = JSON.stringify({ sender: participantName, text: input, timestamp: Date.now() });
|
|
const encPayload = await cipherRef.current.encrypt(payloadStr);
|
|
const header = new Uint8Array(8);
|
|
header[0] = 7; new DataView(header.buffer).setUint16(2, participantId, true);
|
|
const fullPacket = new Uint8Array(8 + encPayload.length);
|
|
fullPacket.set(header, 0); fullPacket.set(encPayload, 8);
|
|
let writer: WritableStreamDefaultWriter | null = null;
|
|
try {
|
|
writer = webTransport.datagrams.writable.getWriter();
|
|
if (writer) await writer.write(fullPacket);
|
|
setMessages((prev) => [...prev, { id: crypto.randomUUID(), sender: participantName, text: input, timestamp: Date.now(), isSelf: true }]);
|
|
} catch (e) {} finally { if (writer) writer.releaseLock(); }
|
|
setInput("");
|
|
};
|
|
|
|
const startRecording = async () => {
|
|
try {
|
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
const mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm;codecs=opus' });
|
|
mediaRecorderRef.current = mediaRecorder; audioChunksRef.current = [];
|
|
mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) audioChunksRef.current.push(e.data); };
|
|
mediaRecorder.onstop = async () => {
|
|
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
|
|
const reader = new FileReader();
|
|
reader.readAsDataURL(audioBlob);
|
|
reader.onloadend = async () => {
|
|
const base64data = reader.result as string;
|
|
if (cipherRef.current && webTransport) {
|
|
const payloadStr = JSON.stringify({ sender: participantName, audioBase64: base64data, timestamp: Date.now() });
|
|
const encPayload = await cipherRef.current.encrypt(payloadStr);
|
|
const header = new Uint8Array(8);
|
|
header[0] = 9; new DataView(header.buffer).setUint16(2, participantId, true);
|
|
const fullPacket = new Uint8Array(8 + encPayload.length);
|
|
fullPacket.set(header, 0); fullPacket.set(encPayload, 8);
|
|
let writer: WritableStreamDefaultWriter | null = null;
|
|
try {
|
|
writer = webTransport.datagrams.writable.getWriter();
|
|
if (writer) await writer.write(fullPacket);
|
|
} catch (e) {} finally { if (writer) writer.releaseLock(); }
|
|
setMessages((prev) => [...prev, { id: crypto.randomUUID(), sender: participantName, text: base64data, timestamp: Date.now(), isSelf: true, isResonanceAudio: true }]);
|
|
}
|
|
};
|
|
stream.getTracks().forEach(t => t.stop());
|
|
};
|
|
mediaRecorder.start(); setIsRecording(true);
|
|
} catch (err) {}
|
|
};
|
|
|
|
const stopRecording = () => { if (mediaRecorderRef.current && isRecording) { mediaRecorderRef.current.stop(); setIsRecording(false); } };
|
|
|
|
return (
|
|
<div className="flex flex-col h-full bg-transparent">
|
|
{/* Header */}
|
|
<div className="p-6 border-b border-white/5 flex items-center justify-between bg-black/20">
|
|
<div className="flex items-center gap-3">
|
|
<h2 className="text-sm font-black uppercase tracking-widest text-white/90">In-Meeting Chat</h2>
|
|
<span className="px-2 py-0.5 rounded text-[8px] font-black uppercase tracking-widest bg-emerald-500/20 text-emerald-400 border border-emerald-500/30">BYOK XChaCha20</span>
|
|
</div>
|
|
<button onClick={onClose} className="text-gray-500 hover:text-white transition-colors">
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Messages Area */}
|
|
<div className="flex-1 p-6 overflow-y-auto custom-scroll flex flex-col gap-6">
|
|
{messages.length === 0 ? (
|
|
<div className="my-auto text-center space-y-4">
|
|
<div className="w-12 h-12 bg-emerald-500/10 rounded-2xl flex items-center justify-center mx-auto text-emerald-500">
|
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"></path></svg>
|
|
</div>
|
|
<p className="text-[10px] text-gray-500 uppercase font-black tracking-widest leading-relaxed">
|
|
Kanal Transmisi Kuantum Aktif.<br />BYOK XChaCha20-Poly1305 E2EE.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
messages.map((msg) => (
|
|
<div key={msg.id} className={`flex flex-col ${msg.isSelf ? "items-end" : "items-start"}`}>
|
|
<div className="flex items-center gap-2 mb-1.5 px-1">
|
|
<span className="text-[9px] font-black uppercase tracking-widest text-white/40">{msg.sender}</span>
|
|
<span className="text-[8px] font-mono text-white/20">{new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
|
|
</div>
|
|
<div className={`px-4 py-3 rounded-2xl text-[13px] leading-relaxed max-w-[90%] shadow-xl border ${msg.isSelf ? "bg-emerald-600 border-emerald-500 text-black font-medium rounded-tr-none" : "bg-white/5 border-white/5 text-white/90 rounded-tl-none"}`}>
|
|
{msg.isResonanceAudio ? (
|
|
<audio controls src={msg.text} className="h-8 w-44 filter invert brightness-200" />
|
|
) : (
|
|
msg.text
|
|
)}
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
|
|
{/* Telepathic Resonance Heatmap */}
|
|
{typingHeat > 0 && (
|
|
<div className="flex items-center gap-3 px-1">
|
|
<span className="text-[9px] font-black text-emerald-500 uppercase tracking-widest animate-pulse">Partisipan Mengetik</span>
|
|
<div className="flex items-center gap-1.5">
|
|
{[...Array(4)].map((_, i) => (
|
|
<div key={i} className="w-1 bg-emerald-500 rounded-full animate-bounce" style={{ height: '8px', animationDelay: `${i * 0.1}s` }} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div ref={chatEndRef} />
|
|
</div>
|
|
|
|
{/* Input Area */}
|
|
<div className="p-6 border-t border-white/5">
|
|
<form onSubmit={sendMessage} className="relative group">
|
|
<input
|
|
type="text" value={input} onChange={(e) => { setInput(e.target.value); sendTypingResonance(); }}
|
|
placeholder="Ketik pesan..."
|
|
className="w-full bg-white/5 border border-white/5 rounded-2xl pl-4 pr-24 py-4 text-xs text-white placeholder-white/20 focus:outline-none focus:border-emerald-500/50 focus:bg-white/10 transition-all shadow-inner"
|
|
/>
|
|
<div className="absolute right-2 top-2 bottom-2 flex items-center gap-1">
|
|
<button
|
|
type="button" onMouseDown={startRecording} onMouseUp={stopRecording} onMouseLeave={stopRecording}
|
|
className={`p-2.5 rounded-xl transition-all ${isRecording ? "bg-red-500 animate-pulse scale-110 shadow-lg" : "text-white/40 hover:text-white hover:bg-white/5"}`}
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"></path></svg>
|
|
</button>
|
|
<button
|
|
type="submit" disabled={!input.trim()}
|
|
className="bg-emerald-500 hover:bg-emerald-400 disabled:opacity-20 text-black p-2.5 rounded-xl transition-all shadow-lg"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M14 5l7 7m0 0l-7 7m7-7H3"></path></svg>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
<p className="text-[8px] text-center text-white/20 mt-4 uppercase font-black tracking-widest">Quantum Resonance Encryption Active</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|