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

617 lines
32 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 [mirc, setMirc] = useState(false);
// Preference vom Server laden
useEffect(() => {
api('/auth/preferences').then(p => { if (p.chat_design === 'mirc') setMirc(true); }).catch(()=>{});
}, []);
const toggleDesign = () => setMirc(v => {
const next = !v;
api('/auth/preferences', { method:'PUT', body:{ chat_design: next ? 'mirc' : 'modern' } }).catch(()=>{});
return next;
});
const bottomRef = useRef(null);
const pollRef = useRef(null);
const pickerRef = useRef(null);
const heartbeatRef = useRef(null);
// Presence Heartbeat: solange Nachrichten offen + Tab sichtbar → aktiv
useEffect(() => {
const sendPresence = (active) => api('/tools/nachrichten/presence', { method:'POST', body:{ active } }).catch(()=>{});
const start = () => {
if (document.visibilityState === 'visible') {
sendPresence(true);
heartbeatRef.current = setInterval(() => {
if (document.visibilityState === 'visible') sendPresence(true);
}, 30000);
}
};
const onVisibility = () => {
if (document.visibilityState === 'visible') {
sendPresence(true);
if (!heartbeatRef.current) heartbeatRef.current = setInterval(() => sendPresence(true), 30000);
} else {
sendPresence(false);
clearInterval(heartbeatRef.current);
heartbeatRef.current = null;
}
};
start();
document.addEventListener('visibilitychange', onVisibility);
return () => {
sendPresence(false);
clearInterval(heartbeatRef.current);
document.removeEventListener('visibilitychange', onVisibility);
};
}, []);
useEffect(() => {
if (!showPicker) return;
const handler = e => {
if (pickerRef.current && !pickerRef.current.contains(e.target)) setShowPicker(false);
};
document.addEventListener('mousedown', handler);
document.addEventListener('touchstart', handler);
return () => { document.removeEventListener('mousedown', handler); document.removeEventListener('touchstart', handler); };
}, [showPicker]);
// ── Initialisierung ──────────────────────────────────────────────────────────
useEffect(() => {
getOrCreateKeyPair()
.then(async k => {
setKp(k);
try {
const jwk = await getPublicKeyJwk(k);
await api('/tools/nachrichten/keys', { body: { public_key: JSON.stringify(jwk) } });
} catch {}
loadUsers();
})
.catch(() => setInitError(true));
}, []);
// ── Polling ──────────────────────────────────────────────────────────────────
useEffect(() => {
clearInterval(pollRef.current);
if (!activeUser) return;
loadMessages(activeUser.id);
pollRef.current = setInterval(() => loadMessages(activeUser.id), 10_000);
return () => clearInterval(pollRef.current);
}, [activeUser?.id]);
// ── Shared Key + Partner-Fingerprint ─────────────────────────────────────────
useEffect(() => {
setSharedKey(null);
setDecrypted({});
setPartnerFp('');
if (!kp || !activeUser?.public_key) return;
try {
const pubJwk = JSON.parse(activeUser.public_key);
deriveSharedKey(kp.privateKey, pubJwk)
.then(setSharedKey)
.catch(() => setSharedKey('error'));
// Fingerprint des Partner-Keys berechnen (aus dem auf dem Server gespeicherten Public Key)
crypto.subtle.importKey('jwk', pubJwk, { name:'X25519' }, true, [])
.then(k => getFingerprint({ publicKey: k }))
.then(setPartnerFp)
.catch(() => setPartnerFp('?'));
} catch {
setSharedKey('error');
}
}, [activeUser?.id, kp]);
// ── Entschlüsseln ────────────────────────────────────────────────────────────
useEffect(() => {
if (sharedKey === null || sharedKey === 'error' || !messages.length) {
setDecrypted({});
return;
}
let cancelled = false;
(async () => {
const result = {};
for (const m of messages) {
if (cancelled) return;
try {
result[m.id] = await decryptMsg(sharedKey, m.encrypted_content, m.iv);
} catch {
result[m.id] = null; // null = nicht entschlüsselbar
}
}
if (!cancelled) setDecrypted(result);
})();
return () => { cancelled = true; };
}, [messages, sharedKey]);
// ── Scroll to bottom ─────────────────────────────────────────────────────────
const prevLengthRef = useRef(0);
useEffect(() => {
const isFirstLoad = prevLengthRef.current === 0 && messages.length > 0;
prevLengthRef.current = messages.length;
if (!bottomRef.current) return;
bottomRef.current.scrollIntoView({ behavior: isFirstLoad ? 'instant' : 'smooth' });
}, [messages.length]);
// ── Handlers ─────────────────────────────────────────────────────────────────
const loadUsers = async () => {
try { 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({});
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, 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: 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 }}>&lt;</span>
<span style={{ color:nickColor, fontSize:11, fontWeight:700, flexShrink:0 }}>{nick}</span>
<span style={{ color:'#808080', fontSize:11, flexShrink:0, marginRight:6 }}>&gt;</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}`;
}