Dateien nach "frontend/src/tools" hochladen

This commit is contained in:
2026-05-28 00:23:06 +02:00
parent 17917e2997
commit 0659f55dc7

View File

@@ -1,133 +1,105 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { api, S } from '../lib.js'; import { api, S } from '../lib.js';
import { UserIcon, TrashIcon } from '../icons.jsx'; import { UserIcon, TrashIcon } from '../icons.jsx';
import {
getOrCreateKeyPair,
getPublicKeyJwk,
deriveSharedKey,
encryptMsg,
decryptMsg,
} from '../crypto.js';
// ── E2E Crypto ────────────────────────────────────────────────────────────────
const LS_KEY = 'dd_msg_keypair';
async function getOrCreateKeyPair() {
const stored = localStorage.getItem(LS_KEY);
if (stored) {
try {
const { pub, priv } = JSON.parse(stored);
const publicKey = await crypto.subtle.importKey('jwk', pub, { name:'ECDH', namedCurve:'P-256' }, true, []);
const privateKey = await crypto.subtle.importKey('jwk', priv, { name:'ECDH', namedCurve:'P-256' }, true, ['deriveKey']);
return { publicKey, privateKey, pubJwk: pub };
} catch { localStorage.removeItem(LS_KEY); }
}
const kp = await crypto.subtle.generateKey({ name:'ECDH', namedCurve:'P-256' }, true, ['deriveKey']);
const pub = await crypto.subtle.exportKey('jwk', kp.publicKey);
const priv = await crypto.subtle.exportKey('jwk', kp.privateKey);
localStorage.setItem(LS_KEY, JSON.stringify({ pub, priv }));
return { publicKey: kp.publicKey, privateKey: kp.privateKey, pubJwk: pub };
}
async function deriveSharedKey(myPrivate, theirPublicJwk) {
const theirKey = await crypto.subtle.importKey(
'jwk', theirPublicJwk, { name:'ECDH', namedCurve:'P-256' }, false, []
);
return crypto.subtle.deriveKey(
{ name:'ECDH', public: theirKey },
myPrivate,
{ name:'AES-GCM', length:256 },
false, ['encrypt','decrypt']
);
}
async function encryptMsg(sharedKey, text) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const enc = await crypto.subtle.encrypt({ name:'AES-GCM', iv }, sharedKey, new TextEncoder().encode(text));
return {
encrypted_content: btoa(String.fromCharCode(...new Uint8Array(enc))),
iv: btoa(String.fromCharCode(...iv)),
};
}
async function decryptMsg(sharedKey, encrypted_content, ivB64) {
const enc = Uint8Array.from(atob(encrypted_content), c => c.charCodeAt(0));
const ivArr = Uint8Array.from(atob(ivB64), c => c.charCodeAt(0));
const dec = await crypto.subtle.decrypt({ name:'AES-GCM', iv: ivArr }, sharedKey, enc);
return new TextDecoder().decode(dec);
}
// ── Main Component ────────────────────────────────────────────────────────────
export default function Nachrichten({ toast, mobile }) { export default function Nachrichten({ toast, mobile }) {
const [kp, setKp] = useState(null); const [kp, setKp] = useState(null);
const [initError, setInitError] = useState(false);
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [activeUser, setActiveUser] = useState(null); const [activeUser, setActiveUser] = useState(null);
const [messages, setMessages] = useState([]); const [messages, setMessages] = useState([]);
const [sharedKey, setSharedKey] = useState(null); const [sharedKey, setSharedKey] = useState(null); // null | CryptoKey | 'error'
const [decrypted, setDecrypted] = useState({}); const [decrypted, setDecrypted] = useState({});
const [text, setText] = useState(''); const [text, setText] = useState('');
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const bottomRef = useRef(null); const bottomRef = useRef(null);
const pollRef = useRef(null); const pollRef = useRef(null);
// Init key pair + register public key // ── Initialisierung ──────────────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
getOrCreateKeyPair().then(async k => { getOrCreateKeyPair()
.then(async k => {
setKp(k); setKp(k);
try { await api('/tools/nachrichten/keys', { body: { public_key: JSON.stringify(k.pubJwk) } }); } try {
catch {} const jwk = await getPublicKeyJwk(k);
}); await api('/tools/nachrichten/keys', { body: { public_key: JSON.stringify(jwk) } });
} catch {}
loadUsers(); loadUsers();
})
.catch(() => setInitError(true));
}, []); }, []);
const loadUsers = async () => { // ── Polling ──────────────────────────────────────────────────────────────────
try { setUsers(await api('/tools/nachrichten/users')); } catch {}
};
// Poll messages in active conversation
useEffect(() => { useEffect(() => {
clearInterval(pollRef.current); clearInterval(pollRef.current);
if (!activeUser) return; if (!activeUser) return;
loadMessages(activeUser.id); loadMessages(activeUser.id);
pollRef.current = setInterval(() => loadMessages(activeUser.id), 15000); pollRef.current = setInterval(() => loadMessages(activeUser.id), 15_000);
return () => clearInterval(pollRef.current); return () => clearInterval(pollRef.current);
}, [activeUser?.id]); }, [activeUser?.id]);
// Derive shared key when conversation or own key changes // ── Shared Key ───────────────────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
setSharedKey(null); setSharedKey(null);
setDecrypted({}); setDecrypted({});
if (!kp || !activeUser?.public_key) return; if (!kp || !activeUser?.public_key) return;
try { try {
deriveSharedKey(kp.privateKey, JSON.parse(activeUser.public_key)) const pubJwk = JSON.parse(activeUser.public_key);
deriveSharedKey(kp.privateKey, pubJwk)
.then(setSharedKey) .then(setSharedKey)
.catch(() => {}); .catch(() => setSharedKey('error'));
} catch {} } catch {
setSharedKey('error');
}
}, [activeUser?.id, kp]); }, [activeUser?.id, kp]);
// Decrypt messages whenever messages or sharedKey changes // ── Entschlüsseln ────────────────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
if (!sharedKey || !messages.length) { setDecrypted({}); return; } if (sharedKey === null || sharedKey === 'error' || !messages.length) {
setDecrypted({});
return;
}
let cancelled = false; let cancelled = false;
(async () => { (async () => {
const result = {}; const result = {};
for (const m of messages) { for (const m of messages) {
if (cancelled) return; if (cancelled) return;
try { result[m.id] = await decryptMsg(sharedKey, m.encrypted_content, m.iv); } try {
catch { result[m.id] = '🔒 Nicht entschlüsselbar'; } result[m.id] = await decryptMsg(sharedKey, m.encrypted_content, m.iv);
} catch {
result[m.id] = null; // null = nicht entschlüsselbar
}
} }
if (!cancelled) setDecrypted(result); if (!cancelled) setDecrypted(result);
})(); })();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [messages, sharedKey]); }, [messages, sharedKey]);
// Scroll to bottom on new messages // ── Scroll to bottom ─────────────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages.length]); }, [messages.length]);
const loadMessages = async (userId) => { // ── Handlers ─────────────────────────────────────────────────────────────────
const loadUsers = async () => {
try { setUsers(await api('/tools/nachrichten/users')); } catch {}
};
const loadMessages = async userId => {
try { try {
const msgs = await api(`/tools/nachrichten/messages/${userId}`); setMessages(await api(`/tools/nachrichten/messages/${userId}`));
setMessages(msgs);
await loadUsers(); await loadUsers();
} catch {} } catch {}
}; };
const selectUser = (u) => { const selectUser = u => {
setActiveUser(u); setActiveUser(u);
setMessages([]); setMessages([]);
setDecrypted({}); setDecrypted({});
@@ -135,8 +107,9 @@ export default function Nachrichten({ toast, mobile }) {
const send = async () => { const send = async () => {
if (!text.trim() || !kp || !activeUser) return; if (!text.trim() || !kp || !activeUser) return;
if (!activeUser.public_key) { toast('Empfänger hat noch kein Schlüsselpaar', 'error'); return; } if (!activeUser.public_key) { toast('Empfänger hat noch keinen Schlüssel', 'error'); return; }
if (!sharedKey) { toast('Schlüssel wird noch vorbereitet…', '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); setSending(true);
try { try {
const { encrypted_content, iv } = await encryptMsg(sharedKey, text.trim()); const { encrypted_content, iv } = await encryptMsg(sharedKey, text.trim());
@@ -147,7 +120,7 @@ export default function Nachrichten({ toast, mobile }) {
setSending(false); setSending(false);
}; };
const deleteMsg = async (id) => { const deleteMsg = async id => {
try { try {
await api(`/tools/nachrichten/messages/${id}`, { method:'DELETE' }); await api(`/tools/nachrichten/messages/${id}`, { method:'DELETE' });
setMessages(p => p.filter(m => m.id !== id)); setMessages(p => p.filter(m => m.id !== id));
@@ -155,6 +128,7 @@ export default function Nachrichten({ toast, mobile }) {
}; };
const deleteConversation = async () => { const deleteConversation = async () => {
if (!window.confirm(`Gesamten Verlauf mit ${activeUser.username} löschen?`)) return;
try { try {
await api(`/tools/nachrichten/conversation/${activeUser.id}`, { method:'DELETE' }); await api(`/tools/nachrichten/conversation/${activeUser.id}`, { method:'DELETE' });
setMessages([]); setMessages([]);
@@ -162,20 +136,27 @@ export default function Nachrichten({ toast, mobile }) {
} catch(e) { toast(e.message, 'error'); } } catch(e) { toast(e.message, 'error'); }
}; };
// ── Layout ───────────────────────────────────────────────────────────────────
const showList = !mobile || !activeUser; const showList = !mobile || !activeUser;
const showChat = !mobile || !!activeUser; const showChat = !mobile || !!activeUser;
const h = mobile
const containerH = mobile
? 'calc(100dvh - 56px - env(safe-area-inset-bottom, 0px))' ? 'calc(100dvh - 56px - env(safe-area-inset-bottom, 0px))'
: '100vh'; : '100vh';
return ( if (initError) return (
<div style={{ display:'flex', height:containerH, overflow:'hidden', background:'#111114' }}> <div style={{ padding:32, color:'rgba(255,255,255,0.45)', fontFamily:'monospace', fontSize:12, lineHeight:2 }}>
Verschlüsselung konnte nicht initialisiert werden.<br/>
Dein Browser unterstützt X25519 möglicherweise nicht (erfordert Chrome 113+/Firefox 130+/Safari 17.4+).
</div>
);
{/* ── Conversation List ────────────────────────────────────────── */} return (
<div style={{ display:'flex', height:h, overflow:'hidden', background:'#111114' }}>
{/* ── Konversationsliste ─────────────────────────────────────────── */}
{showList && ( {showList && (
<div style={{ <div style={{
width: mobile ? '100%' : 240, width: mobile ? '100%' : 230,
borderRight: mobile ? 'none' : '1px solid rgba(255,255,255,0.06)', borderRight: mobile ? 'none' : '1px solid rgba(255,255,255,0.06)',
display:'flex', flexDirection:'column', background:'#0d0d0f', flexShrink:0, display:'flex', flexDirection:'column', background:'#0d0d0f', flexShrink:0,
}}> }}>
@@ -184,41 +165,42 @@ export default function Nachrichten({ toast, mobile }) {
</div> </div>
<div style={{ flex:1, overflowY:'auto' }}> <div style={{ flex:1, overflowY:'auto' }}>
{users.length === 0 ? ( {users.length === 0 ? (
<div style={{ color:'rgba(255,255,255,0.3)', fontSize:11, fontFamily:'monospace', <div style={{ color:'rgba(255,255,255,0.28)', fontSize:11, fontFamily:'monospace',
padding:18, textAlign:'center', lineHeight:1.6 }}> padding:18, textAlign:'center', lineHeight:1.8 }}>
Keine anderen Benutzer Keine anderen Benutzer vorhanden
</div> </div>
) : users.map(u => ( ) : users.map(u => (
<button key={u.id} onClick={() => selectUser(u)} style={{ <button key={u.id} onClick={() => selectUser(u)} style={{
width:'100%', padding:'11px 14px', border:'none', textAlign:'left', cursor:'pointer', width:'100%', padding:'11px 14px', border:'none', textAlign:'left', cursor:'pointer',
background: activeUser?.id === u.id ? 'rgba(78,205,196,0.07)' : 'transparent', background: activeUser?.id === u.id ? 'rgba(78,205,196,0.07)' : 'transparent',
borderLeft: `2px solid ${activeUser?.id === u.id ? '#4ecdc4' : 'transparent'}`, borderLeft: `2px solid ${activeUser?.id === u.id ? '#4ecdc4' : 'transparent'}`,
display:'flex', alignItems:'center', gap:10, transition:'background 0.1s', display:'flex', alignItems:'center', gap:10,
}}> }}>
<div style={{ width:32, height:32, borderRadius:'50%', flexShrink:0, <div style={{ width:32, height:32, borderRadius:'50%', flexShrink:0,
background:'linear-gradient(135deg,rgba(255,107,157,0.25),rgba(78,205,196,0.25))', background:'linear-gradient(135deg,rgba(255,107,157,0.2),rgba(78,205,196,0.2))',
border:'1px solid rgba(255,255,255,0.08)', border:'1px solid rgba(255,255,255,0.07)',
display:'flex', alignItems:'center', justifyContent:'center' }}> display:'flex', alignItems:'center', justifyContent:'center' }}>
<UserIcon size={14} color="rgba(255,255,255,0.55)"/> <UserIcon size={14} color="rgba(255,255,255,0.5)"/>
</div> </div>
<div style={{ flex:1, minWidth:0 }}> <div style={{ flex:1, minWidth:0 }}>
<div style={{ <div style={{
color: u.unread > 0 ? '#fff' : 'rgba(255,255,255,0.65)', color: u.unread > 0 ? '#fff' : 'rgba(255,255,255,0.65)',
fontSize:12, fontFamily:'monospace',
fontWeight: u.unread > 0 ? 700 : 400, fontWeight: u.unread > 0 ? 700 : 400,
fontSize:12, fontFamily:'monospace',
whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis',
}}>{u.username}</div> }}>{u.username}</div>
{u.last_message_at && ( {u.last_message_at && (
<div style={{ color:'rgba(255,255,255,0.28)', fontSize:9, fontFamily:'monospace', marginTop:2 }}> <div style={{ color:'rgba(255,255,255,0.26)', fontSize:9, fontFamily:'monospace', marginTop:2 }}>
{new Date(u.last_message_at).toLocaleDateString('de-DE', {new Date(u.last_message_at).toLocaleDateString('de-DE',
{ day:'numeric', month:'short', hour:'2-digit', minute:'2-digit' })} { day:'numeric', month:'short', hour:'2-digit', minute:'2-digit' })}
</div> </div>
)} )}
</div> </div>
{u.unread > 0 && ( {u.unread > 0 && (
<div style={{ background:'#4ecdc4', color:'#000', borderRadius:10, <span style={{ background:'#4ecdc4', color:'#000', borderRadius:10,
fontSize:9, fontFamily:'monospace', padding:'2px 7px', fontSize:9, fontFamily:'monospace', padding:'2px 7px', fontWeight:700, flexShrink:0 }}>
fontWeight:700, flexShrink:0 }}>{u.unread}</div> {u.unread}
</span>
)} )}
</button> </button>
))} ))}
@@ -226,14 +208,14 @@ export default function Nachrichten({ toast, mobile }) {
</div> </div>
)} )}
{/* ── Chat Panel ───────────────────────────────────────────────── */} {/* ── Chat ──────────────────────────────────────────────────────── */}
{showChat && ( {showChat && (
<div style={{ flex:1, display:'flex', flexDirection:'column', overflow:'hidden', minWidth:0 }}> <div style={{ flex:1, display:'flex', flexDirection:'column', overflow:'hidden', minWidth:0 }}>
{!activeUser ? ( {!activeUser ? (
<div style={{ flex:1, display:'flex', alignItems:'center', justifyContent:'center', <div style={{ flex:1, display:'flex', alignItems:'center', justifyContent:'center',
flexDirection:'column', gap:10 }}> flexDirection:'column', gap:10 }}>
<div style={{ fontSize:40, opacity:0.15 }}></div> <div style={{ fontSize:38, opacity:0.1 }}></div>
<div style={{ color:'rgba(255,255,255,0.2)', fontFamily:'monospace', fontSize:11 }}> <div style={{ color:'rgba(255,255,255,0.18)', fontFamily:'monospace', fontSize:11 }}>
Benutzer auswählen Benutzer auswählen
</div> </div>
</div> </div>
@@ -249,68 +231,79 @@ export default function Nachrichten({ toast, mobile }) {
</button> </button>
)} )}
<div style={{ width:28, height:28, borderRadius:'50%', flexShrink:0, <div style={{ width:28, height:28, borderRadius:'50%', flexShrink:0,
background:'linear-gradient(135deg,rgba(255,107,157,0.25),rgba(78,205,196,0.25))', background:'linear-gradient(135deg,rgba(255,107,157,0.2),rgba(78,205,196,0.2))',
border:'1px solid rgba(255,255,255,0.08)', border:'1px solid rgba(255,255,255,0.07)',
display:'flex', alignItems:'center', justifyContent:'center' }}> display:'flex', alignItems:'center', justifyContent:'center' }}>
<UserIcon size={12} color="rgba(255,255,255,0.55)"/> <UserIcon size={12} color="rgba(255,255,255,0.5)"/>
</div> </div>
<div style={{ flex:1 }}> <div style={{ flex:1, minWidth:0 }}>
<div style={{ color:'#fff', fontSize:13, fontFamily:'monospace' }}>{activeUser.username}</div> <div style={{ color:'#fff', fontSize:13, fontFamily:'monospace',
<div style={{ color:'rgba(78,205,196,0.55)', fontSize:8, fontFamily:'monospace', letterSpacing:1.5, marginTop:1 }}> whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis' }}>
🔒 E2E VERSCHLÜSSELT {activeUser.username}
</div>
<div style={{ color: sharedKey === 'error' ? '#ff6b9d' : 'rgba(78,205,196,0.5)',
fontSize:8, fontFamily:'monospace', letterSpacing:1.5, marginTop:1 }}>
{sharedKey === 'error' ? '⚠ SCHLÜSSEL INKOMPATIBEL' : '🔒 E2E VERSCHLÜSSELT · X25519 + AES-256-GCM'}
</div> </div>
</div> </div>
<button onClick={deleteConversation} style={{ ...S.btn('#ff6b9d', true), fontSize:10 }}> <button onClick={deleteConversation} style={{ ...S.btn('#ff6b9d', true), fontSize:10, flexShrink:0 }}>
Verlauf löschen Verlauf löschen
</button> </button>
</div> </div>
{/* Messages */} {/* Nachrichten */}
<div style={{ flex:1, overflowY:'auto', padding:'14px', display:'flex', flexDirection:'column', gap:10 }}> <div style={{ flex:1, overflowY:'auto', padding:'14px', display:'flex', flexDirection:'column', gap:10 }}>
{!activeUser.public_key && ( {!activeUser.public_key && (
<div style={{ background:'rgba(255,230,109,0.06)', border:'1px solid rgba(255,230,109,0.2)', <InfoBox color="#ffe66d">
borderRadius:8, padding:'10px 14px', color:'#ffe66d', Dieser Benutzer hat die Nachrichten noch nicht geöffnet und besitzt noch kein Schlüsselpaar.
fontSize:11, fontFamily:'monospace', textAlign:'center', lineHeight:1.7 }}> </InfoBox>
Dieser Benutzer hat das Nachrichten-Tool noch nicht geöffnet und besitzt noch kein Schlüsselpaar.
</div>
)} )}
{messages.length === 0 && activeUser.public_key && ( {sharedKey === 'error' && (
<InfoBox color="#ff6b9d">
Schlüssel inkompatibel der Empfänger verwendet ein anderes Schlüsselformat (z. B. altes P-256).
Er muss in Einstellungen Sicherheit einen neuen Schlüssel generieren.
</InfoBox>
)}
{messages.length === 0 && activeUser.public_key && sharedKey !== 'error' && (
<div style={{ flex:1, display:'flex', alignItems:'center', justifyContent:'center', <div style={{ flex:1, display:'flex', alignItems:'center', justifyContent:'center',
color:'rgba(255,255,255,0.18)', fontFamily:'monospace', fontSize:11 }}> color:'rgba(255,255,255,0.18)', fontFamily:'monospace', fontSize:11 }}>
Noch keine Nachrichten schreib als Erste(r) Noch keine Nachrichten
</div> </div>
)} )}
{messages.map(m => { {messages.map(m => {
const isMine = !!m.is_mine; const isMine = !!m.is_mine;
const txt = decrypted[m.id]; const txt = decrypted[m.id];
return ( return (
<div key={m.id} style={{ display:'flex', justifyContent: isMine ? 'flex-end' : 'flex-start', <div key={m.id} style={{ display:'flex',
gap:6, alignItems:'flex-end' }}> justifyContent: isMine ? 'flex-end' : 'flex-start', gap:6, alignItems:'flex-end' }}>
{!isMine && ( {!isMine && (
<div style={{ width:22, height:22, borderRadius:'50%', flexShrink:0, <div style={{ width:22, height:22, borderRadius:'50%', flexShrink:0,
background:'linear-gradient(135deg,rgba(255,107,157,0.2),rgba(78,205,196,0.2))', background:'rgba(255,255,255,0.04)', border:'1px solid rgba(255,255,255,0.07)',
border:'1px solid rgba(255,255,255,0.07)',
display:'flex', alignItems:'center', justifyContent:'center' }}> display:'flex', alignItems:'center', justifyContent:'center' }}>
<UserIcon size={10} color="rgba(255,255,255,0.45)"/> <UserIcon size={10} color="rgba(255,255,255,0.4)"/>
</div> </div>
)} )}
<div style={{ maxWidth:'75%', display:'flex', flexDirection:'column', <div style={{ maxWidth:'75%', display:'flex', flexDirection:'column',
alignItems: isMine ? 'flex-end' : 'flex-start', gap:4 }}> alignItems: isMine ? 'flex-end' : 'flex-start', gap:4 }}>
<div style={{ <div style={{
background: isMine ? 'rgba(78,205,196,0.1)' : 'rgba(255,255,255,0.05)', background: isMine ? 'rgba(78,205,196,0.09)' : 'rgba(255,255,255,0.04)',
border: `1px solid ${isMine ? 'rgba(78,205,196,0.17)' : 'rgba(255,255,255,0.07)'}`, border: `1px solid ${isMine ? 'rgba(78,205,196,0.15)' : 'rgba(255,255,255,0.07)'}`,
borderRadius: isMine ? '12px 12px 3px 12px' : '12px 12px 12px 3px', borderRadius: isMine ? '12px 12px 3px 12px' : '12px 12px 12px 3px',
padding:'8px 12px', padding:'8px 12px',
}}> }}>
{txt === undefined ? ( {txt === undefined ? (
<span style={{ color:'rgba(255,255,255,0.2)', fontSize:12 }}></span> <span style={{ color:'rgba(255,255,255,0.18)', fontSize:12 }}></span>
) : txt === null ? (
<span style={{ color:'rgba(255,107,157,0.5)', fontSize:11, fontFamily:'monospace' }}>
🔒 Nicht entschlüsselbar
</span>
) : ( ) : (
<span style={{ color:'#e6e6e6', fontSize:13, fontFamily:'monospace', <span style={{ color:'#e6e6e6', fontSize:13, fontFamily:'monospace',
lineHeight:1.55, whiteSpace:'pre-wrap', wordBreak:'break-word' }}>{txt}</span> lineHeight:1.55, whiteSpace:'pre-wrap', wordBreak:'break-word' }}>{txt}</span>
)} )}
</div> </div>
<div style={{ display:'flex', alignItems:'center', gap:5 }}> <div style={{ display:'flex', alignItems:'center', gap:5 }}>
<span style={{ color:'rgba(255,255,255,0.2)', fontSize:9, fontFamily:'monospace' }}> <span style={{ color:'rgba(255,255,255,0.18)', fontSize:9, fontFamily:'monospace' }}>
{new Date(m.created_at).toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' })} {new Date(m.created_at).toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' })}
</span> </span>
<button onClick={() => deleteMsg(m.id)} style={{ background:'transparent', border:'none', <button onClick={() => deleteMsg(m.id)} style={{ background:'transparent', border:'none',
@@ -326,20 +319,20 @@ export default function Nachrichten({ toast, mobile }) {
<div ref={bottomRef}/> <div ref={bottomRef}/>
</div> </div>
{/* Input */} {/* Eingabe */}
<div style={{ padding:'10px 14px', borderTop:'1px solid rgba(255,255,255,0.06)', <div style={{ padding:'10px 14px', borderTop:'1px solid rgba(255,255,255,0.06)',
background:'#0d0d0f', flexShrink:0 }}> background:'#0d0d0f', flexShrink:0 }}>
{!activeUser.public_key ? ( {!activeUser.public_key || sharedKey === 'error' ? (
<div style={{ color:'rgba(255,255,255,0.22)', fontSize:11, fontFamily:'monospace', <div style={{ color:'rgba(255,255,255,0.2)', fontSize:11, fontFamily:'monospace',
textAlign:'center', padding:'8px 0' }}> textAlign:'center', padding:'8px 0' }}>
Senden nicht möglich kein Schlüssel verfügbar Senden nicht möglich
</div> </div>
) : ( ) : (
<div style={{ display:'flex', gap:8, alignItems:'flex-end' }}> <div style={{ display:'flex', gap:8, alignItems:'flex-end' }}>
<textarea <textarea
value={text} onChange={e => setText(e.target.value)} value={text} onChange={e => setText(e.target.value)}
onKeyDown={e => { if (e.key==='Enter' && !e.shiftKey) { e.preventDefault(); send(); }}} onKeyDown={e => { if (e.key==='Enter' && !e.shiftKey) { e.preventDefault(); send(); }}}
placeholder="Nachricht… (Enter = senden, Shift+Enter = neue Zeile)" placeholder="Nachricht… (Enter senden · Shift+Enter neue Zeile)"
rows={2} rows={2}
style={{ ...S.inp, resize:'none', flex:1, fontSize:13, padding:'8px 10px', style={{ ...S.inp, resize:'none', flex:1, fontSize:13, padding:'8px 10px',
lineHeight:1.45, minHeight:42 }} lineHeight:1.45, minHeight:42 }}
@@ -358,3 +351,13 @@ export default function Nachrichten({ toast, mobile }) {
</div> </div>
); );
} }
function InfoBox({ color, children }) {
return (
<div style={{ background:`${color}08`, border:`1px solid ${color}25`,
borderRadius:8, padding:'10px 14px', color, fontSize:11,
fontFamily:'monospace', textAlign:'center', lineHeight:1.7 }}>
{children}
</div>
);
}