generated from Dicken/dickendock
Initial commit
This commit is contained in:
650
frontend/src/tools/nachrichten.jsx
Normal file
650
frontend/src/tools/nachrichten.jsx
Normal file
@@ -0,0 +1,650 @@
|
||||
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, 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);
|
||||
const prevUserRef = useRef(null);
|
||||
|
||||
function scrollToBottom(behavior = 'instant') {
|
||||
bottomRef.current?.scrollIntoView({ behavior });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!bottomRef.current) return;
|
||||
const userChanged = prevUserRef.current !== activeUser?.id;
|
||||
const isFirstLoad = prevLengthRef.current === 0 && messages.length > 0;
|
||||
prevLengthRef.current = messages.length;
|
||||
prevUserRef.current = activeUser?.id;
|
||||
if (userChanged || isFirstLoad) {
|
||||
scrollToBottom('instant');
|
||||
} else if (messages.length > 0) {
|
||||
scrollToBottom('smooth');
|
||||
}
|
||||
}, [messages.length, activeUser?.id]);
|
||||
|
||||
// Wenn Handy-Tastatur aufklappt → ans Ende scrollen NUR wenn schon am Ende war
|
||||
useEffect(() => {
|
||||
if (!window.visualViewport) return;
|
||||
const onResize = () => {
|
||||
// Prüfe ob bottomRef nah am sichtbaren Bereich war (= User war am Ende)
|
||||
if (!bottomRef.current) return;
|
||||
const rect = bottomRef.current.getBoundingClientRect();
|
||||
const wasAtBottom = rect.top <= (window.visualViewport?.height ?? window.innerHeight) + 100;
|
||||
if (wasAtBottom) {
|
||||
setTimeout(() => scrollToBottom('instant'), 50);
|
||||
}
|
||||
};
|
||||
window.visualViewport.addEventListener('resize', onResize);
|
||||
return () => window.visualViewport.removeEventListener('resize', onResize);
|
||||
}, []);
|
||||
|
||||
// ── 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 (
|
||||
<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>
|
||||
);
|
||||
|
||||
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 (
|
||||
<div style={{ display:'flex', overflow:'hidden', background:'#111114', ...mobileStyle }}>
|
||||
|
||||
{/* ── 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>
|
||||
<div style={{ display:'flex', gap:5 }}>
|
||||
<button onClick={toggleDesign} title={mirc ? 'Zu Modern wechseln' : 'Zu mIRC wechseln'}
|
||||
style={{ background: 'rgba(255,255,255,0.05)',
|
||||
border:'1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius:7, color:'rgba(255,255,255,0.55)',
|
||||
cursor:'pointer', padding:'3px 8px', fontSize:10, fontFamily:'monospace', lineHeight:1 }}>
|
||||
{mirc ? '◈ mIRC' : '◈ Modern'}
|
||||
</button>
|
||||
<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>
|
||||
<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, minHeight:0, 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, minHeight:0, overflowY:'auto', padding: mirc ? '0' : '14px',
|
||||
display:'flex', flexDirection:'column', gap: mirc ? 0 : 10,
|
||||
background: mirc ? '#000' : undefined,
|
||||
fontFamily: mirc ? "'Courier New', Courier, monospace" : undefined }}>
|
||||
{!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.
|
||||
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: mirc ? '#808080' : 'rgba(255,255,255,0.18)',
|
||||
fontFamily: mirc ? "'Courier New', monospace" : 'monospace', fontSize:11 }}>
|
||||
{mirc ? '*** Noch keine Nachrichten ***' : 'Noch keine Nachrichten'}
|
||||
</div>
|
||||
)}
|
||||
{Object.values(decrypted).some(v => v === null) && (
|
||||
<InfoBox color="#ffe66d">
|
||||
🔑 Einige Nachrichten können nicht entschlüsselt werden.
|
||||
Importiere deinen alten Schlüssel unter Einstellungen → Sicherheit.
|
||||
</InfoBox>
|
||||
)}
|
||||
|
||||
{mirc ? (
|
||||
/* ── mIRC Design ─────────────────────────────────────── */
|
||||
<div style={{ padding:'4px 0' }}>
|
||||
{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 (
|
||||
<div key={m.id} style={{ display:'flex', alignItems:'baseline', gap:0,
|
||||
padding:'1px 8px', lineHeight:1.5,
|
||||
background: isMine ? 'rgba(0,255,0,0.03)' : 'transparent' }}
|
||||
onMouseEnter={e=>e.currentTarget.style.background='rgba(255,255,255,0.04)'}
|
||||
onMouseLeave={e=>e.currentTarget.style.background=isMine?'rgba(0,255,0,0.03)':'transparent'}>
|
||||
<span style={{ color:'#808080', fontSize:11, flexShrink:0, marginRight:2 }}>[{time}]</span>
|
||||
<span style={{ color:'#808080', fontSize:11, flexShrink:0, marginRight:2 }}><</span>
|
||||
<span style={{ color:nickColor, fontSize:11, fontWeight:700, flexShrink:0 }}>{nick}</span>
|
||||
<span style={{ color:'#808080', fontSize:11, flexShrink:0, marginRight:6 }}>></span>
|
||||
<span style={{ color: txt===null?'#404040':'#d0d0d0', fontSize:12, wordBreak:'break-word', flex:1 }}>
|
||||
{txt===undefined ? '…' : txt===null ? '[verschlüsselt]' : txt}
|
||||
</span>
|
||||
{isMine && m.read_by_recipient && <span style={{color:'#004400',fontSize:9,marginLeft:4,flexShrink:0}}>✓✓</span>}
|
||||
<button onClick={() => deleteMsg(m.id)} style={{ background:'transparent', border:'none',
|
||||
cursor:'pointer', color:'#400000', fontSize:9, padding:'0 0 0 6px', flexShrink:0, opacity:0.5 }}>✕</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
/* ── Modernes Design ─────────────────────────────────── */
|
||||
<>
|
||||
{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', fontSize:10 }}>👤</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', flexShrink:0,
|
||||
borderTop:`1px solid ${mirc?'#333':'rgba(255,255,255,0.06)'}`,
|
||||
background: mirc ? '#000' : '#0d0d0f' }}>
|
||||
{!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:'stretch', 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>
|
||||
{mirc && (
|
||||
<span style={{ color:'#00ff00', fontFamily:"'Courier New',monospace", fontSize:12,
|
||||
alignSelf:'center', flexShrink:0 }}>[du]</span>
|
||||
)}
|
||||
<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={mirc ? 'Nachricht…' : 'Nachricht… (Enter senden · Shift+Enter neue Zeile)'}
|
||||
rows={2}
|
||||
style={{ ...S.inp, resize:'none', flex:1, fontSize: mirc ? 12 : 13,
|
||||
padding:'8px 10px', lineHeight:1.45, minHeight:42,
|
||||
...(mirc ? { background:'#111', border:'1px solid #444', color:'#d0d0d0',
|
||||
fontFamily:"'Courier New',monospace", borderRadius:2 } : {}) }}
|
||||
/>
|
||||
<button onClick={() => setShowPicker(v => !v)} style={{
|
||||
background: showPicker ? 'rgba(78,205,196,0.12)' : (mirc ? '#111' : 'rgba(255,255,255,0.05)'),
|
||||
border:`1px solid ${showPicker ? 'rgba(78,205,196,0.3)' : (mirc ? '#444' : 'rgba(255,255,255,0.1)')}`,
|
||||
borderRadius: mirc ? 2 : 8, cursor:'pointer', fontSize:17,
|
||||
padding:'0 10px', flexShrink:0, alignSelf:'stretch' }}>😊</button>
|
||||
</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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user