Dateien nach "frontend/src/tools" hochladen
This commit is contained in:
@@ -205,7 +205,7 @@ export default function Dateien({ toast, mobile }) {
|
||||
{/* ── Tabs + Neu-Ordner (überall gleich) ───────────────────────────── */}
|
||||
<div style={{display:'flex',gap:6,marginBottom:12,alignItems:'center',flexWrap:'wrap'}}>
|
||||
{[
|
||||
['own','Meine Dateien', null],
|
||||
['own','Dateien', null],
|
||||
['shared','Geteilt mit mir', sharedCnt||null],
|
||||
['sharedByMe','Geteilt von mir', byMeCnt||null],
|
||||
].map(([k,l,cnt])=>(
|
||||
|
||||
325
frontend/src/tools/nachrichten.jsx
Normal file
325
frontend/src/tools/nachrichten.jsx
Normal file
@@ -0,0 +1,325 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { api, S } from '../lib.js';
|
||||
import { useConfirm } from '../confirm.jsx';
|
||||
import { getOrCreateKeyPair, exportPublicKey, encryptMessage, decryptMessage } from '../crypto.js';
|
||||
import { SearchIcon, TrashIcon, ChevronIcon } from '../icons.jsx';
|
||||
|
||||
const fmtTime = s => new Date(s).toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'});
|
||||
const fmtDay = s => {
|
||||
const d = new Date(s), now = new Date();
|
||||
if (d.toDateString()===now.toDateString()) return 'Heute';
|
||||
const y = new Date(now); y.setDate(y.getDate()-1);
|
||||
if (d.toDateString()===y.toDateString()) return 'Gestern';
|
||||
return d.toLocaleDateString('de-DE',{day:'2-digit',month:'2-digit'});
|
||||
};
|
||||
|
||||
// ── Push-Subscription ─────────────────────────────────────────────────────────
|
||||
async function subscribePush(toast) {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const data = await api('/nachrichten/vapid-key');
|
||||
const sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: data.publicKey,
|
||||
});
|
||||
await api('/nachrichten/subscribe', { body: sub.toJSON() });
|
||||
} catch(e) { console.warn('Push subscription failed:', e.message); }
|
||||
}
|
||||
|
||||
// ── Setup: Schlüsselpaar + Server registrieren ────────────────────────────────
|
||||
async function ensureSetup(toast) {
|
||||
try {
|
||||
await getOrCreateKeyPair();
|
||||
const pub = await exportPublicKey();
|
||||
await api('/nachrichten/keys', { body: { publicKey: pub } });
|
||||
await subscribePush();
|
||||
return true;
|
||||
} catch(e) { toast('Schlüssel-Setup fehlgeschlagen','error'); return false; }
|
||||
}
|
||||
|
||||
// ── Conversation View ─────────────────────────────────────────────────────────
|
||||
function Conversation({ partner, myUsername, onBack, toast }) {
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [theirKey, setTheirKey] = useState(null);
|
||||
const [decrypted, setDecrypted] = useState({});
|
||||
const bottomRef = useRef(null);
|
||||
const { confirm, ConfirmDialog } = useConfirm();
|
||||
|
||||
const loadMessages = useCallback(async () => {
|
||||
try {
|
||||
const [msgs, keyData] = await Promise.all([
|
||||
api(`/nachrichten/with/${partner}`),
|
||||
api(`/nachrichten/keys/${partner}`).catch(()=>null),
|
||||
]);
|
||||
setMessages(msgs);
|
||||
if (keyData) setTheirKey(keyData.publicKey);
|
||||
|
||||
// Decrypt all messages
|
||||
const dec = {};
|
||||
for (const m of msgs) {
|
||||
try {
|
||||
const partnerKey = m.sender_name === myUsername ? keyData?.publicKey : keyData?.publicKey;
|
||||
// Both sides use the same shared secret derived from the key exchange
|
||||
// But we need to know WHICH key to use for decryption
|
||||
// For messages sent BY me: I encrypted with their public key → decrypt with my private + their public
|
||||
// For messages FROM them: they encrypted with my public key → decrypt with my private + their public
|
||||
// In both cases the shared secret is the same (ECDH is symmetric)
|
||||
if (keyData) {
|
||||
dec[m.id] = await decryptMessage(m.encrypted_content, m.iv, keyData.publicKey);
|
||||
}
|
||||
} catch { dec[m.id] = '🔒 [nicht entschlüsselbar]'; }
|
||||
}
|
||||
setDecrypted(dec);
|
||||
} catch(e) { toast(e.message,'error'); } finally { setLoading(false); }
|
||||
}, [partner, myUsername]);
|
||||
|
||||
useEffect(() => { loadMessages(); }, [loadMessages]);
|
||||
useEffect(() => { bottomRef.current?.scrollIntoView({behavior:'smooth'}); }, [messages]);
|
||||
|
||||
const send = async () => {
|
||||
if (!input.trim() || !theirKey) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const { encrypted_content, iv } = await encryptMessage(input.trim(), theirKey);
|
||||
await api('/nachrichten/send', { body: { recipient_username: partner, encrypted_content, iv } });
|
||||
setInput('');
|
||||
await loadMessages();
|
||||
} catch(e) { toast(e.message,'error'); } finally { setSending(false); }
|
||||
};
|
||||
|
||||
const delMsg = async id => {
|
||||
if (!await confirm('Nachricht für dich löschen?')) return;
|
||||
try { await api(`/nachrichten/${id}`,{method:'DELETE'}); setMessages(p=>p.filter(m=>m.id!==id)); }
|
||||
catch(e) { toast(e.message,'error'); }
|
||||
};
|
||||
|
||||
const delAll = async () => {
|
||||
if (!await confirm(`Gesamtes Gespräch mit ${partner} löschen? Es verschwindet nur bei dir.`)) return;
|
||||
try { await api(`/nachrichten/conversation/${partner}`,{method:'DELETE'}); onBack(); }
|
||||
catch(e) { toast(e.message,'error'); }
|
||||
};
|
||||
|
||||
let lastDay = null;
|
||||
|
||||
return (
|
||||
<div style={{display:'flex',flexDirection:'column',height:'calc(100vh - 56px)',maxWidth:700,margin:'0 auto'}}>
|
||||
<ConfirmDialog/>
|
||||
{/* Header */}
|
||||
<div style={{display:'flex',alignItems:'center',gap:10,padding:'14px 14px 10px',
|
||||
borderBottom:'1px solid rgba(255,255,255,0.07)',background:'#111114',flexShrink:0}}>
|
||||
<button onClick={onBack} style={{background:'transparent',border:'none',cursor:'pointer',padding:4}}>
|
||||
<ChevronIcon size={20} color="rgba(255,255,255,0.5)" dir="left"/>
|
||||
</button>
|
||||
<div style={{flex:1}}>
|
||||
<div style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:14,fontWeight:700}}>{partner}</div>
|
||||
{!theirKey && <div style={{color:'#ff6b9d',fontFamily:'monospace',fontSize:10}}>⚠ Kein Schlüssel – kann noch nicht senden</div>}
|
||||
{theirKey && <div style={{color:'#4ecdc4',fontFamily:'monospace',fontSize:10}}>🔒 Ende-zu-Ende verschlüsselt</div>}
|
||||
</div>
|
||||
<button onClick={delAll} title="Gespräch löschen" style={{background:'transparent',border:'none',cursor:'pointer',padding:4}}>
|
||||
<TrashIcon size={16} color="rgba(255,107,157,0.6)"/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div style={{flex:1,overflowY:'auto',padding:'12px 14px',display:'flex',flexDirection:'column',gap:6}}>
|
||||
{loading && <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:12,textAlign:'center'}}>Lädt…</div>}
|
||||
{!loading && messages.length===0 && (
|
||||
<div style={{textAlign:'center',marginTop:60}}>
|
||||
<div style={{fontSize:36,marginBottom:12}}>🔒</div>
|
||||
<div style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:12}}>Noch keine Nachrichten.<br/>Alle Nachrichten sind Ende-zu-Ende verschlüsselt.</div>
|
||||
</div>
|
||||
)}
|
||||
{messages.map(msg => {
|
||||
const isMe = msg.sender_name === myUsername;
|
||||
const day = fmtDay(msg.created_at);
|
||||
const showDay = day !== lastDay; lastDay = day;
|
||||
return (
|
||||
<div key={msg.id}>
|
||||
{showDay && (
|
||||
<div style={{textAlign:'center',marginBottom:8,marginTop:4}}>
|
||||
<span style={{background:'rgba(255,255,255,0.07)',borderRadius:20,padding:'2px 10px',
|
||||
color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10}}>{day}</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{display:'flex',justifyContent:isMe?'flex-end':'flex-start',alignItems:'flex-end',gap:6}}>
|
||||
{!isMe && <div style={{width:6,height:6,borderRadius:'50%',background:'#4ecdc4',flexShrink:0,marginBottom:6}}/>}
|
||||
<div style={{maxWidth:'75%'}}>
|
||||
<div style={{
|
||||
background: isMe ? 'linear-gradient(135deg,rgba(78,205,196,0.25),rgba(69,183,209,0.2))' : 'rgba(255,255,255,0.07)',
|
||||
border: `1px solid ${isMe?'rgba(78,205,196,0.3)':'rgba(255,255,255,0.1)'}`,
|
||||
borderRadius: isMe ? '12px 12px 2px 12px' : '12px 12px 12px 2px',
|
||||
padding:'8px 12px',
|
||||
}}>
|
||||
<div style={{color:'#fff',fontFamily:'monospace',fontSize:13,lineHeight:1.5,wordBreak:'break-word'}}>
|
||||
{decrypted[msg.id] || '🔒 …'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{display:'flex',alignItems:'center',gap:6,marginTop:3,justifyContent:isMe?'flex-end':'flex-start'}}>
|
||||
<span style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:9}}>{fmtTime(msg.created_at)}</span>
|
||||
<button onClick={()=>delMsg(msg.id)} style={{background:'transparent',border:'none',
|
||||
cursor:'pointer',padding:0,opacity:0.4,lineHeight:1}} title="Löschen">
|
||||
<TrashIcon size={10} color="#ff6b9d"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={bottomRef}/>
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div style={{padding:'10px 14px',borderTop:'1px solid rgba(255,255,255,0.07)',
|
||||
background:'#111114',flexShrink:0,display:'flex',gap:8,alignItems:'flex-end'}}>
|
||||
<textarea value={input} onChange={e=>setInput(e.target.value)}
|
||||
onKeyDown={e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send();}}}
|
||||
placeholder={theirKey?'Nachricht…':'Schlüssel des Empfängers fehlt'}
|
||||
disabled={!theirKey||sending}
|
||||
rows={1}
|
||||
style={{...S.inp,flex:1,resize:'none',minHeight:44,maxHeight:120,lineHeight:1.6,
|
||||
fontSize:14,padding:'10px 12px',overflowY:'auto'}}/>
|
||||
<button onClick={send} disabled={!theirKey||sending||!input.trim()} style={{
|
||||
background:theirKey&&input.trim()?'linear-gradient(135deg,#4ecdc4,#45b7d1)':'rgba(255,255,255,0.08)',
|
||||
border:'none',borderRadius:10,padding:'10px 16px',cursor:theirKey&&input.trim()?'pointer':'default',
|
||||
color:theirKey&&input.trim()?'#0d0d0f':'rgba(255,255,255,0.2)',
|
||||
fontFamily:"'Space Mono',monospace",fontSize:13,fontWeight:700,height:44,flexShrink:0,
|
||||
}}>{sending?'…':'↑'}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Hauptansicht: Gesprächsliste ──────────────────────────────────────────────
|
||||
export default function Nachrichten({ toast, mobile, user }) {
|
||||
const [convs, setConvs] = useState([]);
|
||||
const [users, setUsers] = useState([]);
|
||||
const [openPartner, setOpen] = useState(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [newChat, setNewChat] = useState(false);
|
||||
const [ready, setReady] = useState(false);
|
||||
const { confirm, ConfirmDialog } = useConfirm();
|
||||
|
||||
useEffect(() => {
|
||||
ensureSetup(toast).then(ok => {
|
||||
setReady(ok);
|
||||
if (ok) {
|
||||
api('/nachrichten/conversations').then(setConvs).catch(()=>{});
|
||||
api('/nachrichten/users').then(setUsers).catch(()=>{});
|
||||
}
|
||||
});
|
||||
// Refresh every 10s
|
||||
const t = setInterval(() => {
|
||||
api('/nachrichten/conversations').then(setConvs).catch(()=>{});
|
||||
}, 10000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
|
||||
const startChat = username => {
|
||||
setNewChat(false); setSearch(''); setOpen(username);
|
||||
};
|
||||
|
||||
if (openPartner) return (
|
||||
<Conversation partner={openPartner} myUsername={user?.username} onBack={()=>{setOpen(null);api('/nachrichten/conversations').then(setConvs);}} toast={toast}/>
|
||||
);
|
||||
|
||||
const filtered = convs.filter(c => c.partner_name.toLowerCase().includes(search.toLowerCase()));
|
||||
const newFiltered = users.filter(u => u.username.toLowerCase().includes(search.toLowerCase()) && !convs.find(c=>c.partner_name===u.username));
|
||||
|
||||
return (
|
||||
<div style={{padding:mobile?'14px 14px 90px':'36px 44px',maxWidth:700}}>
|
||||
<ConfirmDialog/>
|
||||
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:16}}>
|
||||
<div>
|
||||
<h1 style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:mobile?18:22,margin:0}}>Nachrichten</h1>
|
||||
<div style={{color:'#4ecdc4',fontFamily:'monospace',fontSize:10,marginTop:3}}>🔒 Ende-zu-Ende verschlüsselt</div>
|
||||
</div>
|
||||
<button onClick={()=>setNewChat(v=>!v)} style={{...S.btn('#4ecdc4'),display:'flex',alignItems:'center',gap:6}}>
|
||||
{newChat?'✕ Abbrechen':'+ Neu'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!ready && (
|
||||
<div style={{...S.card,padding:20,textAlign:'center',color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:12}}>
|
||||
Initialisiere Verschlüsselung…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suche */}
|
||||
<div style={{position:'relative',marginBottom:12}}>
|
||||
<span style={{position:'absolute',left:12,top:'50%',transform:'translateY(-50%)'}}>
|
||||
<SearchIcon size={14} color="rgba(255,255,255,0.3)"/>
|
||||
</span>
|
||||
<input value={search} onChange={e=>setSearch(e.target.value)}
|
||||
placeholder="Person suchen…"
|
||||
style={{...S.inp,paddingLeft:34,fontSize:15}}/>
|
||||
</div>
|
||||
|
||||
{/* Neues Gespräch – User-Picker */}
|
||||
{newChat && (
|
||||
<div style={{...S.card,marginBottom:12}}>
|
||||
<div style={{...S.head,marginBottom:0,marginTop:0}}>NEUES GESPRÄCH</div>
|
||||
{users.filter(u=>u.username.toLowerCase().includes(search.toLowerCase())).map(u=>(
|
||||
<button key={u.id} onClick={()=>startChat(u.username)} style={{
|
||||
width:'100%',padding:'10px 0',background:'transparent',border:'none',
|
||||
borderBottom:'1px solid rgba(255,255,255,0.05)',
|
||||
display:'flex',alignItems:'center',gap:10,cursor:'pointer',
|
||||
}}>
|
||||
<div style={{width:34,height:34,borderRadius:'50%',background:'linear-gradient(135deg,#4ecdc4,#ff6b9d)',
|
||||
display:'flex',alignItems:'center',justifyContent:'center',fontSize:14,flexShrink:0}}>
|
||||
{u.username[0].toUpperCase()}
|
||||
</div>
|
||||
<span style={{color:'#fff',fontFamily:'monospace',fontSize:13}}>{u.username}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gesprächsliste */}
|
||||
{filtered.length===0&&!newChat&&(
|
||||
<div style={{...S.card,textAlign:'center',padding:40}}>
|
||||
<div style={{fontSize:32,marginBottom:12}}>💬</div>
|
||||
<div style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:12}}>
|
||||
Noch keine Nachrichten.<br/>Klicke auf "+ Neu" um ein Gespräch zu starten.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{filtered.map(conv => (
|
||||
<div key={conv.partner_id} onClick={()=>startChat(conv.partner_name)}
|
||||
style={{...S.card,marginBottom:6,padding:'12px 14px',cursor:'pointer',
|
||||
display:'flex',alignItems:'center',gap:12,
|
||||
border:conv.unread>0?'1px solid rgba(78,205,196,0.2)':'1px solid rgba(255,255,255,0.07)'}}>
|
||||
<div style={{width:40,height:40,borderRadius:'50%',
|
||||
background:`linear-gradient(135deg,#4ecdc4,#ff6b9d)`,
|
||||
display:'flex',alignItems:'center',justifyContent:'center',
|
||||
fontSize:16,fontWeight:700,color:'#0d0d0f',flexShrink:0}}>
|
||||
{conv.partner_name[0].toUpperCase()}
|
||||
</div>
|
||||
<div style={{flex:1,minWidth:0}}>
|
||||
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center'}}>
|
||||
<span style={{color:'#fff',fontFamily:'monospace',fontSize:13,fontWeight:700}}>{conv.partner_name}</span>
|
||||
<span style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:10}}>
|
||||
{fmtDay(conv.last_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{display:'flex',alignItems:'center',gap:6,marginTop:3}}>
|
||||
<span style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:11}}>🔒 Verschlüsselt</span>
|
||||
{conv.unread>0&&(
|
||||
<span style={{background:'#4ecdc4',borderRadius:'50%',width:18,height:18,
|
||||
display:'flex',alignItems:'center',justifyContent:'center',
|
||||
color:'#0d0d0f',fontFamily:'monospace',fontSize:10,fontWeight:700,flexShrink:0}}>
|
||||
{conv.unread}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronIcon size={14} color="rgba(255,255,255,0.25)" dir="right"/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user