Files
dickendock/frontend/src/tools/nachrichten.jsx

517 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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':'💔',
'xD':'😆', 'XD':'😆', 'x)':"😄",
':/':"😕", ':-/':"😕", ':|':"😐", ':-|':"😐",
'B)':"😎", 'B-)':"😎", '^_^':"😊", '-_-':"😑",
'O:)':"😇", 'O:-)':"😇", '>:)':"😈",
':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 (
<div style={{ position:'absolute', bottom:'100%', right:0, marginBottom:6, zIndex:100,
background:'#1a1a1e', border:'1px solid rgba(255,255,255,0.12)', borderRadius:12,
width:272, boxShadow:'0 8px 32px rgba(0,0,0,0.5)' }}
onMouseDown={e => e.preventDefault()}>
<div style={{ display:'flex', borderBottom:'1px solid rgba(255,255,255,0.07)', padding:'6px 8px', gap:4 }}>
{EMOJI_GROUPS.map((g,i) => (
<button key={i} onClick={() => setGroup(i)} style={{ flex:1, background: group===i ?
'rgba(78,205,196,0.15)' : 'transparent', border:'none', borderRadius:7,
color: group===i ? '#4ecdc4' : 'rgba(255,255,255,0.4)', cursor:'pointer',
fontSize:9, fontFamily:'monospace', padding:'4px 2px' }}>{g.label}</button>
))}
</div>
<div style={{ display:'flex', flexWrap:'wrap', padding:8, gap:2, maxHeight:180, overflowY:'auto' }}>
{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) => (
<button key={i} onClick={() => onSelect(em)} style={{ background:'transparent', border:'none',
borderRadius:6, cursor:'pointer', fontSize:20, padding:'4px 5px',
transition:'background 0.1s' }}
onMouseEnter={e => e.target.style.background='rgba(255,255,255,0.08)'}
onMouseLeave={e => e.target.style.background='transparent'}>
{em}
</button>
))}
</div>
</div>
);
}
export default function Nachrichten({ toast, mobile }) {
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 bottomRef = useRef(null);
const pollRef = useRef(null);
const pickerRef = useRef(null);
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 ─────────────────────────────────────────────────────────
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages.length]);
// ── Handlers ─────────────────────────────────────────────────────────────────
const loadUsers = async () => {
try { setUsers(await api('/tools/nachrichten/users')); } catch {}
};
const loadMessages = async userId => {
try {
setMessages(await api(`/tools/nachrichten/messages/${userId}`));
await loadUsers();
} catch {}
};
const selectUser = u => {
setActiveUser(u);
setMessages([]);
setDecrypted({});
};
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;
const h = mobile
? 'calc(100dvh - 56px - env(safe-area-inset-bottom, 0px))'
: '100vh';
if (initError) return (
<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>
);
return (
<div style={{ display:'flex', height:h, overflow:'hidden', background:'#111114' }}>
{/* ── Konversationsliste ─────────────────────────────────────────── */}
{showList && (
<div style={{
width: mobile ? '100%' : 230,
borderRight: mobile ? 'none' : '1px solid rgba(255,255,255,0.06)',
display:'flex', flexDirection:'column', background:'#0d0d0f', flexShrink:0,
}}>
<div style={{ padding:'16px 14px 12px', borderBottom:'1px solid rgba(255,255,255,0.06)',
display:'flex', alignItems:'center', justifyContent:'space-between' }}>
<div style={{ ...S.head, marginBottom:0 }}>NACHRICHTEN</div>
<button onClick={() => { loadUsers(); if (activeUser) loadMessages(activeUser.id); }} title="Neu laden"
style={{ background:'transparent', border:'1px solid rgba(255,255,255,0.1)', borderRadius:7,
color:'rgba(255,255,255,0.35)', cursor:'pointer', padding:'3px 8px',
fontSize:13, fontFamily:'monospace', lineHeight:1 }}></button>
</div>
<div style={{ flex:1, overflowY:'auto' }}>
{users.length === 0 ? (
<div style={{ color:'rgba(255,255,255,0.28)', fontSize:11, fontFamily:'monospace',
padding:18, textAlign:'center', lineHeight:1.8 }}>
Keine anderen Benutzer vorhanden
</div>
) : users.map(u => (
<button key={u.id} onClick={() => selectUser(u)} style={{
width:'100%', padding:'11px 14px', border:'none', textAlign:'left', cursor:'pointer',
background: activeUser?.id === u.id ? 'rgba(78,205,196,0.07)' : 'transparent',
borderLeft: `2px solid ${activeUser?.id === u.id ? '#4ecdc4' : 'transparent'}`,
display:'flex', alignItems:'center', gap:10,
}}>
<div style={{ width:32, height:32, borderRadius:'50%', flexShrink:0,
background:'linear-gradient(135deg,rgba(255,107,157,0.2),rgba(78,205,196,0.2))',
border:'1px solid rgba(255,255,255,0.07)',
display:'flex', alignItems:'center', justifyContent:'center' }}>
<UserIcon size={14} color="rgba(255,255,255,0.5)"/>
</div>
<div style={{ flex:1, minWidth:0 }}>
<div style={{
color: u.unread > 0 ? '#fff' : 'rgba(255,255,255,0.65)',
fontWeight: u.unread > 0 ? 700 : 400,
fontSize:12, fontFamily:'monospace',
whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis',
}}>{u.username}</div>
{u.last_message_at && (
<div style={{ color:'rgba(255,255,255,0.26)', fontSize:9, fontFamily:'monospace', marginTop:2 }}>
{new Date(u.last_message_at).toLocaleDateString('de-DE',
{ day:'numeric', month:'short', hour:'2-digit', minute:'2-digit' })}
</div>
)}
</div>
{u.unread > 0 && (
<span style={{ background:'#4ecdc4', color:'#000', borderRadius:10,
fontSize:9, fontFamily:'monospace', padding:'2px 7px', fontWeight:700, flexShrink:0 }}>
{u.unread}
</span>
)}
</button>
))}
</div>
</div>
)}
{/* ── Chat ──────────────────────────────────────────────────────── */}
{showChat && (
<div style={{ flex:1, display:'flex', flexDirection:'column', overflow:'hidden', minWidth:0 }}>
{!activeUser ? (
<div style={{ flex:1, display:'flex', alignItems:'center', justifyContent:'center',
flexDirection:'column', gap:10 }}>
<div style={{ fontSize:38, opacity:0.1 }}></div>
<div style={{ color:'rgba(255,255,255,0.18)', fontFamily:'monospace', fontSize:11 }}>
Benutzer auswählen
</div>
</div>
) : (
<>
{/* Header */}
<div style={{ padding:'10px 14px', borderBottom:'1px solid rgba(255,255,255,0.06)',
display:'flex', alignItems:'center', gap:10, background:'#0d0d0f', flexShrink:0 }}>
{mobile && (
<button onClick={() => setActiveUser(null)} style={{ background:'transparent', border:'none',
cursor:'pointer', color:'#4ecdc4', fontFamily:'monospace', fontSize:20, padding:'0 4px', lineHeight:1 }}>
</button>
)}
<div style={{ width:28, height:28, borderRadius:'50%', flexShrink:0,
background:'linear-gradient(135deg,rgba(255,107,157,0.2),rgba(78,205,196,0.2))',
border:'1px solid rgba(255,255,255,0.07)',
display:'flex', alignItems:'center', justifyContent:'center' }}>
<UserIcon size={12} color="rgba(255,255,255,0.5)"/>
</div>
<div style={{ flex:1, minWidth:0 }}>
<div style={{ color:'#fff', fontSize:13, fontFamily:'monospace',
whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis' }}>
{activeUser.username}
</div>
{sharedKey === 'error' ? (
<div style={{ color:'#ff6b9d', fontSize:8, fontFamily:'monospace', letterSpacing:1.5, marginTop:1 }}>
SCHLÜSSEL INKOMPATIBEL
</div>
) : partnerFp ? (
<div title={`Fingerprint von ${activeUser.username} vergleiche mit dessen Anzeige unter Einstellungen → Sicherheit`}
style={{ color:'rgba(78,205,196,0.55)', fontSize:8, fontFamily:'monospace', letterSpacing:1, marginTop:2,
cursor:'help', whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis' }}>
🔒 {partnerFp}
</div>
) : (
<div style={{ color:'rgba(78,205,196,0.35)', fontSize:8, fontFamily:'monospace', letterSpacing:1.5, marginTop:1 }}>
🔒 E2E VERSCHLÜSSELT · X25519 + AES-256-GCM
</div>
)}
</div>
<button onClick={deleteConversation} style={{ ...S.btn('#ff6b9d', true), fontSize:10, flexShrink:0 }}>
Verlauf löschen
</button>
</div>
{/* Nachrichten */}
<div style={{ flex:1, overflowY:'auto', padding:'14px', display:'flex', flexDirection:'column', gap:10 }}>
{!activeUser.public_key && (
<InfoBox color="#ffe66d">
Dieser Benutzer hat die Nachrichten noch nicht geöffnet und besitzt noch kein Schlüsselpaar.
</InfoBox>
)}
{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',
color:'rgba(255,255,255,0.18)', fontFamily:'monospace', fontSize:11 }}>
Noch keine Nachrichten
</div>
)}
{/* Hinweis wenn Nachrichten nicht entschlüsselt werden können */}
{Object.values(decrypted).some(v => v === null) && (
<InfoBox color="#ffe66d">
🔑 Einige Nachrichten können nicht entschlüsselt werden dein privater Schlüssel passt nicht mehr.{' '}
Importiere deinen alten Schlüssel unter{' '}
<strong>Einstellungen Sicherheit Schlüssel importieren</strong>,
oder erzeuge einen neuen Schlüssel (löscht alle bisherigen Nachrichten).
</InfoBox>
)}
{messages.map(m => {
const isMine = !!m.is_mine;
const txt = decrypted[m.id];
return (
<div key={m.id} style={{ display:'flex',
justifyContent: isMine ? 'flex-end' : 'flex-start', gap:6, alignItems:'flex-end' }}>
{!isMine && (
<div style={{ width:22, height:22, borderRadius:'50%', flexShrink:0,
background:'rgba(255,255,255,0.04)', border:'1px solid rgba(255,255,255,0.07)',
display:'flex', alignItems:'center', justifyContent:'center' }}>
<UserIcon size={10} color="rgba(255,255,255,0.4)"/>
</div>
)}
<div style={{ maxWidth:'75%', display:'flex', flexDirection:'column',
alignItems: isMine ? 'flex-end' : 'flex-start', gap:4 }}>
<div style={{
background: isMine ? 'rgba(78,205,196,0.09)' : 'rgba(255,255,255,0.04)',
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',
padding:'8px 12px',
}}>
{txt === undefined ? (
<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',
lineHeight:1.55, whiteSpace:'pre-wrap', wordBreak:'break-word' }}>{txt}</span>
)}
</div>
<div style={{ display:'flex', alignItems:'center', gap:5 }}>
<span style={{ color:'rgba(255,255,255,0.18)', fontSize:9, fontFamily:'monospace', whiteSpace:'nowrap' }}>
{formatMsgTime(m.created_at)}
</span>
{isMine && (
<span title={m.read_by_recipient ? 'Gelesen' : 'Gesendet'}
style={{ fontSize:10, lineHeight:1, color: m.read_by_recipient ? '#4ecdc4' : 'rgba(255,255,255,0.2)' }}>
{m.read_by_recipient ? '✓✓' : '✓'}
</span>
)}
<button onClick={() => deleteMsg(m.id)} style={{ background:'transparent', border:'none',
cursor:'pointer', padding:'0 2px', opacity:0.3, lineHeight:1,
display:'flex', alignItems:'center' }}>
<TrashIcon size={10} color="#ff6b9d"/>
</button>
</div>
</div>
</div>
);
})}
<div ref={bottomRef}/>
</div>
{/* Eingabe */}
<div style={{ padding:'10px 14px', borderTop:'1px solid rgba(255,255,255,0.06)',
background:'#0d0d0f', flexShrink:0 }}>
{!activeUser.public_key || sharedKey === 'error' ? (
<div style={{ color:'rgba(255,255,255,0.2)', fontSize:11, fontFamily:'monospace',
textAlign:'center', padding:'8px 0' }}>
Senden nicht möglich
</div>
) : (
<div style={{ display:'flex', gap:8, alignItems:'flex-end', position:'relative' }}>
<div ref={pickerRef} style={{ position:'absolute', bottom:'100%', right:0 }}>
{showPicker && (
<EmojiPicker
onSelect={em => { setText(t => t + em); setShowPicker(false); }}
onClose={() => setShowPicker(false)}
/>
)}
</div>
<textarea
value={text}
onChange={e => {
const val = e.target.value;
const last = val[val.length - 1];
if (last === ' ' || last === '\n') {
setText(convertEmojis(val));
} else {
setText(val);
}
}}
onFocus={() => setShowPicker(false)}
onKeyDown={e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
const converted = convertEmojis(text + ' ').trimEnd();
if (converted !== text) setText(converted);
send(converted);
}
}}
placeholder="Nachricht… (Enter senden · Shift+Enter neue Zeile)"
rows={2}
style={{ ...S.inp, resize:'none', flex:1, fontSize:13, padding:'8px 10px',
lineHeight:1.45, minHeight:42 }}
/>
<div style={{ display:'flex', flexDirection:'column', gap:4, flexShrink:0, alignSelf:'stretch' }}>
<button onClick={() => setShowPicker(v => !v)} style={{
background: showPicker ? 'rgba(78,205,196,0.12)' : 'rgba(255,255,255,0.05)',
border:`1px solid ${showPicker ? 'rgba(78,205,196,0.3)' : 'rgba(255,255,255,0.1)'}`,
borderRadius:8, cursor:'pointer', fontSize:17, lineHeight:1,
padding:'5px 8px', flexShrink:0, alignSelf:'flex-end' }}>😊</button>
</div>
</div>
)}
</div>
</>
)}
</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>
);
}
function formatMsgTime(isoStr) {
if (!isoStr) return '';
const d = new Date(isoStr.replace(' ', 'T'));
if (isNaN(d)) return '';
const now = new Date();
const isToday = d.toDateString() === now.toDateString();
const isThisYear = d.getFullYear() === now.getFullYear();
const time = d.toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' });
if (isToday) return `Heute, ${time}`;
if (isThisYear) return `${d.toLocaleDateString('de-DE', { day:'numeric', month:'short' })}, ${time}`;
return `${d.toLocaleDateString('de-DE', { day:'numeric', month:'short', year:'numeric' })}, ${time}`;
}