import { useState, useEffect, useRef } from 'react'; import { api, S } from '../lib.js'; import { UserIcon, TrashIcon } from '../icons.jsx'; import { getOrCreateKeyPair, getPublicKeyJwk, getFingerprint, deriveSharedKey, encryptMsg, decryptMsg, } from '../crypto.js'; // ── Emoji-Konvertierung ─────────────────────────────────────────────────────── const EMOJI_MAP = { ':D':'😁', ':)':'🙂', ':-)':'🙂', '(:':'🙂', ';)':'😉', ';-)':'😉', ':P':'😛', ':-P':'😛', ':p':'😛', ':O':'😮', ':-O':'😮', ':o':'😮', ':(':'😢', ':-(':'😢', ":'(":'😭', '>:(':"😠", '>:-(':"😠", ':*':'😘', ':-*':'😘', '<3':'❤️', ':)':"😈", ':3':"🐱", ':$':"😳", }; // Ersetze Kürzel am Ende des Textes (nach Leerzeichen oder Satzanfang) function convertEmojis(text) { // Letztes "Wort" vor dem Leerzeichen/Newline prüfen return text.replace(/(\S+)(\s)$/, (_, word, space) => { return (EMOJI_MAP[word] ?? word) + space; }); } // ── Emoji-Picker Daten ──────────────────────────────────────────────────────── const EMOJI_GROUPS = [ { label:'Smileys', emojis:['😀','😁','😂','🤣','😊','😇','🙂','😉','😌','😍','🥰','😘','😎','🤩','😏','😒','😞','😢','😭','😤','😠','😡','🤬','😈','👿','💀','😱','😨','😰','😓','🤔','🤐','😶','😐','😑','😬','🙄','😴','🤧','🤒','😷','🤕'] }, { label:'Gesten', emojis:['👍','👎','👌','✌️','🤞','🤟','🤙','👋','🤚','🖐','✋','🤜','🤛','👏','🙌','🤝','🙏','💪','🫶','❤️','🧡','💛','💚','💙','💜','🖤','💔','💯','💥','✨','🎉','🔥','⭐','🌟'] }, { label:'Dinge', emojis:['😂💀','🎮','🏆','🎯','🎲','🃏','🎸','🎵','🎤','📱','💻','⌨️','🖱️','📷','🎬','📺','🚀','🛸','🌍','🌈','☀️','🌙','⭐','🍕','🍔','🍟','🌮','🍩','🍺','☕','🧃'] }, ]; function EmojiPicker({ onSelect, onClose }) { const [group, setGroup] = useState(0); return (
e.preventDefault()}>
{EMOJI_GROUPS.map((g,i) => ( ))}
{EMOJI_GROUPS[group].emojis.filter(e => e.length <= 2 || [...e].length <= 2).concat( EMOJI_GROUPS[group].emojis.filter(e => [...e].length > 2) ).map((em,i) => ( ))}
); } export default function Nachrichten({ toast, mobile, nav }) { const [kp, setKp] = useState(null); const [initError, setInitError] = useState(false); const [users, setUsers] = useState([]); const [activeUser, setActiveUser] = useState(null); const [messages, setMessages] = useState([]); const [sharedKey, setSharedKey] = useState(null); // null | CryptoKey | 'error' const [decrypted, setDecrypted] = useState({}); const [partnerFp, setPartnerFp] = useState(''); const [text, setText] = useState(''); const [sending, setSending] = useState(false); const [showPicker, setShowPicker] = useState(false); const [mirc, setMirc] = useState(false); // Preference vom Server laden useEffect(() => { api('/auth/preferences').then(p => { if (p.chat_design === 'mirc') setMirc(true); }).catch(()=>{}); }, []); const toggleDesign = () => setMirc(v => { const next = !v; api('/auth/preferences', { method:'PUT', body:{ chat_design: next ? 'mirc' : 'modern' } }).catch(()=>{}); return next; }); const bottomRef = useRef(null); const pollRef = useRef(null); const pickerRef = useRef(null); const heartbeatRef = useRef(null); // Presence Heartbeat: solange Nachrichten offen + Tab sichtbar → aktiv useEffect(() => { const sendPresence = (active) => api('/tools/nachrichten/presence', { method:'POST', body:{ active } }).catch(()=>{}); const start = () => { if (document.visibilityState === 'visible') { sendPresence(true); heartbeatRef.current = setInterval(() => { if (document.visibilityState === 'visible') sendPresence(true); }, 30000); } }; const onVisibility = () => { if (document.visibilityState === 'visible') { sendPresence(true); if (!heartbeatRef.current) heartbeatRef.current = setInterval(() => sendPresence(true), 30000); } else { sendPresence(false); clearInterval(heartbeatRef.current); heartbeatRef.current = null; } }; start(); document.addEventListener('visibilitychange', onVisibility); return () => { sendPresence(false); clearInterval(heartbeatRef.current); document.removeEventListener('visibilitychange', onVisibility); }; }, []); useEffect(() => { if (!showPicker) return; const handler = e => { if (pickerRef.current && !pickerRef.current.contains(e.target)) setShowPicker(false); }; document.addEventListener('mousedown', handler); document.addEventListener('touchstart', handler); return () => { document.removeEventListener('mousedown', handler); document.removeEventListener('touchstart', handler); }; }, [showPicker]); // ── Initialisierung ────────────────────────────────────────────────────────── useEffect(() => { getOrCreateKeyPair() .then(async k => { setKp(k); try { const jwk = await getPublicKeyJwk(k); await api('/tools/nachrichten/keys', { body: { public_key: JSON.stringify(jwk) } }); } catch {} loadUsers(); }) .catch(() => setInitError(true)); }, []); // ── Polling ────────────────────────────────────────────────────────────────── useEffect(() => { clearInterval(pollRef.current); if (!activeUser) return; loadMessages(activeUser.id); pollRef.current = setInterval(() => loadMessages(activeUser.id), 10_000); return () => clearInterval(pollRef.current); }, [activeUser?.id]); // ── Shared Key + Partner-Fingerprint ───────────────────────────────────────── useEffect(() => { setSharedKey(null); setDecrypted({}); setPartnerFp(''); if (!kp || !activeUser?.public_key) return; try { const pubJwk = JSON.parse(activeUser.public_key); deriveSharedKey(kp.privateKey, pubJwk) .then(setSharedKey) .catch(() => setSharedKey('error')); // Fingerprint des Partner-Keys berechnen (aus dem auf dem Server gespeicherten Public Key) crypto.subtle.importKey('jwk', pubJwk, { name:'X25519' }, true, []) .then(k => getFingerprint({ publicKey: k })) .then(setPartnerFp) .catch(() => setPartnerFp('?')); } catch { setSharedKey('error'); } }, [activeUser?.id, kp]); // ── Entschlüsseln ──────────────────────────────────────────────────────────── useEffect(() => { if (sharedKey === null || sharedKey === 'error' || !messages.length) { setDecrypted({}); return; } let cancelled = false; (async () => { const result = {}; for (const m of messages) { if (cancelled) return; try { result[m.id] = await decryptMsg(sharedKey, m.encrypted_content, m.iv); } catch { result[m.id] = null; // null = nicht entschlüsselbar } } if (!cancelled) setDecrypted(result); })(); return () => { cancelled = true; }; }, [messages, sharedKey]); // ── Scroll to bottom ───────────────────────────────────────────────────────── const prevLengthRef = useRef(0); useEffect(() => { const isFirstLoad = prevLengthRef.current === 0 && messages.length > 0; prevLengthRef.current = messages.length; if (!bottomRef.current) return; bottomRef.current.scrollIntoView({ behavior: isFirstLoad ? 'instant' : 'smooth' }); }, [messages.length]); // ── Handlers ───────────────────────────────────────────────────────────────── const loadUsers = async () => { try { const u = await api('/tools/nachrichten/users'); setUsers(u); // Nav aus Suche: direkt zu User springen if (nav?.userId) { const target = u.find(x => x.id === nav.userId); if (target) setActiveUser(target); } } catch {} }; const loadMessages = async userId => { try { setMessages(await api(`/tools/nachrichten/messages/${userId}`)); await loadUsers(); } catch {} }; const selectUser = u => { setActiveUser(u); setMessages([]); setDecrypted({}); prevLengthRef.current = 0; }; const send = async (overrideText) => { const msg = (overrideText ?? text).trim(); if (!msg || !kp || !activeUser) return; if (!activeUser.public_key) { toast('Empfänger hat noch keinen Schlüssel', 'error'); return; } if (sharedKey === 'error') { toast('Schlüssel inkompatibel – Empfänger muss Schlüssel neu generieren', 'error'); return; } if (!sharedKey) { toast('Schlüssel wird vorbereitet…', 'error'); return; } setSending(true); try { const { encrypted_content, iv } = await encryptMsg(sharedKey, msg); await api(`/tools/nachrichten/messages/${activeUser.id}`, { body: { encrypted_content, iv } }); setText(''); await loadMessages(activeUser.id); } catch(e) { toast(e.message, 'error'); } setSending(false); }; const deleteMsg = async id => { try { await api(`/tools/nachrichten/messages/${id}`, { method:'DELETE' }); setMessages(p => p.filter(m => m.id !== id)); } catch(e) { toast(e.message, 'error'); } }; const deleteConversation = async () => { if (!window.confirm(`Gesamten Verlauf mit ${activeUser.username} löschen?`)) return; try { await api(`/tools/nachrichten/conversation/${activeUser.id}`, { method:'DELETE' }); setMessages([]); await loadUsers(); } catch(e) { toast(e.message, 'error'); } }; // ── Layout ─────────────────────────────────────────────────────────────────── const showList = !mobile || !activeUser; const showChat = !mobile || !!activeUser; if (initError) return (
⚠ Verschlüsselung konnte nicht initialisiert werden.
Dein Browser unterstützt X25519 möglicherweise nicht (erfordert Chrome 113+/Firefox 130+/Safari 17.4+).
); const mobileStyle = mobile ? { position: 'fixed', top: 0, left: 0, right: 0, bottom: 'calc(56px + env(safe-area-inset-bottom, 0px))', zIndex: 10, } : { height: '100vh', }; return (
{/* ── Konversationsliste ─────────────────────────────────────────── */} {showList && (
NACHRICHTEN
{users.length === 0 ? (
Keine anderen Benutzer vorhanden
) : users.map(u => ( ))}
)} {/* ── Chat ──────────────────────────────────────────────────────── */} {showChat && (
{!activeUser ? (
Benutzer auswählen
) : ( <> {/* Header */}
{mobile && ( )}
{activeUser.username}
{sharedKey === 'error' ? (
⚠ SCHLÜSSEL INKOMPATIBEL
) : partnerFp ? (
🔒 {partnerFp}
) : (
🔒 E2E VERSCHLÜSSELT · X25519 + AES-256-GCM
)}
{/* Nachrichten */}
{!activeUser.public_key && ( ⚠ Dieser Benutzer hat die Nachrichten noch nicht geöffnet und besitzt noch kein Schlüsselpaar. )} {sharedKey === 'error' && ( ⚠ Schlüssel inkompatibel – der Empfänger verwendet ein anderes Schlüsselformat. Er muss in Einstellungen → Sicherheit einen neuen Schlüssel generieren. )} {messages.length === 0 && activeUser.public_key && sharedKey !== 'error' && (
{mirc ? '*** Noch keine Nachrichten ***' : 'Noch keine Nachrichten'}
)} {Object.values(decrypted).some(v => v === null) && ( 🔑 Einige Nachrichten können nicht entschlüsselt werden. Importiere deinen alten Schlüssel unter Einstellungen → Sicherheit. )} {mirc ? ( /* ── mIRC Design ─────────────────────────────────────── */
{messages.map(m => { const isMine = !!m.is_mine; const txt = decrypted[m.id]; const time = m.created_at ? (() => { const d = new Date(m.created_at.replace(' ','T')); return isNaN(d)?'':d.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'}); })() : ''; const nick = isMine ? 'du' : activeUser.username; const nickColor = isMine ? '#00ff00' : '#00ffff'; return (
e.currentTarget.style.background='rgba(255,255,255,0.04)'} onMouseLeave={e=>e.currentTarget.style.background=isMine?'rgba(0,255,0,0.03)':'transparent'}> [{time}] < {nick} > {txt===undefined ? '…' : txt===null ? '[verschlüsselt]' : txt} {isMine && m.read_by_recipient && ✓✓}
); })}
) : ( /* ── Modernes Design ─────────────────────────────────── */ <> {messages.map(m => { const isMine = !!m.is_mine; const txt = decrypted[m.id]; return (
{!isMine && (
👤
)}
{txt === undefined ? ( ) : txt === null ? ( 🔒 Nicht entschlüsselbar ) : ( {txt} )}
{formatMsgTime(m.created_at)} {isMine && ( {m.read_by_recipient ? '✓✓' : '✓'} )}
); })} )}
{/* Eingabe */}
{!activeUser.public_key || sharedKey === 'error' ? (
Senden nicht möglich
) : (
{showPicker && ( { setText(t => t + em); setShowPicker(false); }} onClose={() => setShowPicker(false)} /> )}
{mirc && ( [du] )}