Dateien nach "frontend/src/tools" hochladen
This commit is contained in:
@@ -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 [users, setUsers] = useState([]);
|
const [initError, setInitError] = useState(false);
|
||||||
const [activeUser, setActiveUser] = useState(null);
|
const [users, setUsers] = useState([]);
|
||||||
const [messages, setMessages] = useState([]);
|
const [activeUser, setActiveUser] = useState(null);
|
||||||
const [sharedKey, setSharedKey] = useState(null);
|
const [messages, setMessages] = useState([]);
|
||||||
const [decrypted, setDecrypted] = useState({});
|
const [sharedKey, setSharedKey] = useState(null); // null | CryptoKey | 'error'
|
||||||
const [text, setText] = useState('');
|
const [decrypted, setDecrypted] = useState({});
|
||||||
const [sending, setSending] = useState(false);
|
const [text, setText] = useState('');
|
||||||
|
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()
|
||||||
setKp(k);
|
.then(async k => {
|
||||||
try { await api('/tools/nachrichten/keys', { body: { public_key: JSON.stringify(k.pubJwk) } }); }
|
setKp(k);
|
||||||
catch {}
|
try {
|
||||||
});
|
const jwk = await getPublicKeyJwk(k);
|
||||||
loadUsers();
|
await api('/tools/nachrichten/keys', { body: { public_key: JSON.stringify(jwk) } });
|
||||||
|
} catch {}
|
||||||
|
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,47 +107,56 @@ 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());
|
||||||
await api(`/tools/nachrichten/messages/${activeUser.id}`, { body: { encrypted_content, iv } });
|
await api(`/tools/nachrichten/messages/${activeUser.id}`, { body: { encrypted_content, iv } });
|
||||||
setText('');
|
setText('');
|
||||||
await loadMessages(activeUser.id);
|
await loadMessages(activeUser.id);
|
||||||
} catch (e) { toast(e.message, 'error'); }
|
} catch(e) { toast(e.message, 'error'); }
|
||||||
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));
|
||||||
} catch (e) { toast(e.message, 'error'); }
|
} catch(e) { toast(e.message, 'error'); }
|
||||||
};
|
};
|
||||||
|
|
||||||
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([]);
|
||||||
await loadUsers();
|
await loadUsers();
|
||||||
} 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,69 +231,80 @@ 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',
|
||||||
cursor:'pointer', padding:'0 2px', opacity:0.3, lineHeight:1,
|
cursor:'pointer', padding:'0 2px', opacity:0.3, lineHeight:1,
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user