// ═══════════════════════════════════════════════════════════════ // NOAHSCHAT v4 — JSONBin.io Backend // ═══════════════════════════════════════════════════════════════ // // SETUP (5 Minuten, kostenlos): // 1. https://jsonbin.io → kostenlos registrieren // 2. Dashboard → "API Keys" → Master Key kopieren // 3. "+ Create Bin" → leeres JSON {} → Create → Bin ID kopieren // (du brauchst NUR EINEN Bin für alles!) // 4. JSONBIN_KEY und JSONBIN_BIN unten eintragen → fertig! // ───────────────────────────────────────────────────────────── const JSONBIN_KEY = '$2a$10$Hurr28g4Cy7NWpA/Abd6YOzkBLuC8PdAOPQR34g4pEkA24LpXo7NK'; // ← eintragen const JSONBIN_BIN = '69976e43ae596e708f382b97'; // ← eintragen // ─── CONFIG ─────────────────────────────────────────────────── const OWNER_NAME = 'Noah'; const OWNER_PASS = 'Noah100419!'; const POLL_MS = 3000; // Polling-Intervall in ms // ─── SESSION PERSISTENCE ────────────────────────────────────── function saveSession(user) { if (user) localStorage.setItem('nc_session', JSON.stringify({ username: user.username, ts: Date.now() })); else localStorage.removeItem('nc_session'); } function loadSession() { try { const s = localStorage.getItem('nc_session'); if (!s) return null; const parsed = JSON.parse(s); if (Date.now() - parsed.ts > 7 * 86400000) { localStorage.removeItem('nc_session'); return null; } return parsed; } catch { return null; } } // ─── STATE ──────────────────────────────────────────────────── let currentUser = null; let currentChannel = 'allgemein'; let currentDM = null; let pollTimer = null; let spamTs = []; let captchaData = {}; let pendingSpamMsg = null; let appConfig = {}; let voiceActive = false; let voiceChannel = null; let micMuted = false; let deafened = false; let translationEnabled = false; let userLanguage = 'de'; let unreadCounts = {}; let lastMsgCounts = {}; // ─── IN-MEMORY DB CACHE ─────────────────────────────────────── // Die komplette DB liegt immer im RAM. // Schreiben: sofort im Cache sichtbar, async zu JSONBin gesendet. // Lesen: aus Cache (kein Extra-Request nötig). // Polling lädt alle POLL_MS die DB neu von JSONBin. let DB = null; let isSaving = false; let saveQueued = false; const DB_DEFAULT = () => ({ users: {}, msgs: {}, dms: {}, online: {}, voice: {}, tickets: {}, pins: [], moderation: { bans:{}, banned_fps:{}, kicked:{}, timeouts:{} }, config: {} }); // ─── FINGERPRINT ────────────────────────────────────────────── function getFingerprint() { const s = navigator.userAgent + screen.width + screen.height + screen.colorDepth + (Intl.DateTimeFormat().resolvedOptions().timeZone||'') + navigator.language; let h = 0; for (let i = 0; i < s.length; i++) { h = ((h<<5)-h) + s.charCodeAt(i); h = h&h; } return Math.abs(h).toString(36); } const MY_FP = getFingerprint(); let heartbeatTimer = null; function scheduleHeartbeatSave() { // Nur alle 6 Sekunden wirklich speichern (Polling ist 3s, also jeden 2. Tick) if (heartbeatTimer) return; heartbeatTimer = setTimeout(() => { heartbeatTimer = null; apiSave(); }, 6000); } // ─── JSONBIN API ────────────────────────────────────────────── const isConfigured = () => !JSONBIN_KEY.includes('DEIN') && !JSONBIN_BIN.includes('DEINE'); async function apiLoad(isMerge = false) { if (!isConfigured()) { const v = localStorage.getItem('nc_db'); DB = v ? JSON.parse(v) : DB_DEFAULT(); ensureFields(); return; } try { const r = await fetch(`https://api.jsonbin.io/v3/b/${JSONBIN_BIN}/latest`, { headers: { 'X-Master-Key': JSONBIN_KEY, 'X-Bin-Meta': 'false' } }); if (!r.ok) throw new Error(r.status); const remote = await r.json(); if (isMerge && DB) { // Smart merge: Remote-Daten haben Vorrang, aber lokale Änderungen die // noch nicht gepusht wurden (pending saves) bleiben erhalten. // Nachrichten: Union beider Sets (remote + lokal), remote gewinnt bei Konflikt const mergedMsgs = {}; const allChannels = new Set([...Object.keys(DB.msgs || {}), ...Object.keys(remote.msgs || {})]); for (const ch of allChannels) { const localCh = DB.msgs?.[ch] || {}; const remoteCh = remote.msgs?.[ch] || {}; mergedMsgs[ch] = { ...localCh, ...remoteCh }; // Remote überschreibt lokal bei gleichem Key } const mergedDms = {}; const allDms = new Set([...Object.keys(DB.dms || {}), ...Object.keys(remote.dms || {})]); for (const dm of allDms) { const localDm = DB.dms?.[dm] || {}; const remoteDm = remote.dms?.[dm] || {}; mergedDms[dm] = { ...localDm, ...remoteDm }; } DB = { ...remote, msgs: mergedMsgs, dms: mergedDms }; } else { DB = remote; } ensureFields(); localStorage.setItem('nc_db', JSON.stringify(DB)); } catch(e) { console.warn('Load fehlgeschlagen:', e); const v = localStorage.getItem('nc_db'); if (v) DB = JSON.parse(v); else DB = DB_DEFAULT(); ensureFields(); } } async function apiSave() { // Immer lokal speichern localStorage.setItem('nc_db', JSON.stringify(DB)); if (!isConfigured()) return; if (isSaving) { saveQueued = true; return; } isSaving = true; try { const r = await fetch(`https://api.jsonbin.io/v3/b/${JSONBIN_BIN}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'X-Master-Key': JSONBIN_KEY, 'X-Bin-Versioning': 'false' // Kein Versionsverlauf → schneller }, body: JSON.stringify(DB) }); if (!r.ok) console.warn('Save Fehler:', r.status); } catch(e) { console.warn('Save Exception:', e); } finally { isSaving = false; if (saveQueued) { saveQueued = false; apiSave(); } } } // Fehlende Felder ergänzen falls DB älter ist function ensureFields() { const def = DB_DEFAULT(); for (const k of Object.keys(def)) { if (DB[k] === undefined) DB[k] = def[k]; } if (!DB.moderation.bans) DB.moderation.bans = {}; if (!DB.moderation.banned_fps) DB.moderation.banned_fps = {}; if (!DB.moderation.kicked) DB.moderation.kicked = {}; if (!DB.moderation.timeouts) DB.moderation.timeouts = {}; } // Hilfsfunktionen: Pfad-Zugriff (z.B. "users/noah") function dbGet(path) { const parts = path.split('/').filter(Boolean); let o = DB; for (const p of parts) { if (o==null) return undefined; o = o[p]; } return o; } function dbSet(path, val) { const parts = path.split('/').filter(Boolean); let o = DB; for (let i = 0; i < parts.length-1; i++) { if (o[parts[i]] == null) o[parts[i]] = {}; o = o[parts[i]]; } o[parts[parts.length-1]] = val; apiSave(); // Async, blockiert nicht } function dbDelete(path) { const parts = path.split('/').filter(Boolean); let o = DB; for (let i = 0; i < parts.length-1; i++) { if (o[parts[i]] == null) return; o = o[parts[i]]; } delete o[parts[parts.length-1]]; apiSave(); } function dbPush(path, val) { const key = Date.now().toString(36) + Math.random().toString(36).slice(2,5); val._key = key; dbSet(path+'/'+key, val); return key; } // ─── ROLE HELPERS ───────────────────────────────────────────── const ROLE_PRIORITY = { owner:5, admin:4, mod:3, vip:2, member:1 }; const ROLE_BADGE = { owner:'👑', admin:'🔰', mod:'⭐', vip:'💎', member:'' }; const ROLE_LABELS = { owner:'Owner', admin:'Admin', mod:'Moderator', vip:'VIP', member:'Mitglied' }; const ROLE_COLORS = { owner:'name-color-1', admin:'name-color-0', mod:'name-color-2', vip:'name-color-4', member:'' }; function hasRole(r) { return currentUser && ROLE_PRIORITY[currentUser.role] >= ROLE_PRIORITY[r]; } // ─── CAPTCHA ────────────────────────────────────────────────── function genCaptcha() { const a=Math.floor(Math.random()*18)+2, b=Math.floor(Math.random()*18)+2; if (Math.random()>.4) return {q:`${a} + ${b} = ?`, ans:a+b}; const big=Math.max(a,b), sm=Math.min(a,b); return {q:`${big} − ${sm} = ?`, ans:big-sm}; } function refreshCaptcha(id) { const d=genCaptcha(); captchaData[id]=d; const q=document.getElementById(id+'-q'); if(q) q.textContent=d.q; const a=document.getElementById(id+'-a'); if(a) a.value=''; } function verifyCaptcha(id) { return captchaData[id] && parseInt(document.getElementById(id+'-a')?.value)===captchaData[id].ans; } // ─── AUTH TABS ──────────────────────────────────────────────── function switchTab(tab) { ['login','register'].forEach((t,i)=>{ document.getElementById('tab-'+t).style.display=t===tab?'':'none'; document.querySelectorAll('.auth-tab')[i].classList.toggle('active',t===tab); }); document.getElementById('auth-error').textContent=''; } // ─── REGISTER ───────────────────────────────────────────────── async function doRegister() { const u=document.getElementById('reg-user').value.trim(); const p=document.getElementById('reg-pass').value; const p2=document.getElementById('reg-pass2').value; const err=t=>document.getElementById('auth-error').textContent=t; if (u.length<2) return err('Username mind. 2 Zeichen'); if (u.length>20) return err('Username max. 20 Zeichen'); if (!/^[a-zA-Z0-9_]+$/.test(u)) return err('Nur Buchstaben, Zahlen, _'); if (p.length<4) return err('Passwort mind. 4 Zeichen'); if (p!==p2) return err('Passwörter stimmen nicht überein'); if (!verifyCaptcha('reg')) { refreshCaptcha('reg'); return err('❌ Falsches Captcha!'); } if (DB.moderation.bans?.[u.toLowerCase()]) return err('Dieser Account ist gesperrt.'); if (DB.moderation.banned_fps?.[MY_FP]) return showBanned(); if (DB.users[u.toLowerCase()]) return err('Username bereits vergeben'); dbSet(`users/${u.toLowerCase()}`, { username:u, password:encode(p), color:Math.floor(Math.random()*5), role:'member', joined:Date.now(), settings:{lang:'de', translate:false, notif:true} }); refreshCaptcha('reg'); showAuthSuccess('✓ Account erstellt! Du kannst dich jetzt einloggen.'); setTimeout(()=>{ switchTab('login'); document.getElementById('login-user').value=u; }, 1300); } // ─── LOGIN ──────────────────────────────────────────────────── async function doLogin() { const u=document.getElementById('login-user').value.trim(); const p=document.getElementById('login-pass').value; const err=t=>document.getElementById('auth-error').textContent=t; if (!verifyCaptcha('login')) { refreshCaptcha('login'); return err('❌ Falsches Captcha!'); } if (DB.moderation.banned_fps?.[MY_FP]) return showBanned(); const entry=DB.users[u.toLowerCase()]; if (!entry) return err('Falscher Username oder Passwort'); if (decode(entry.password)!==p)return err('Falscher Username oder Passwort'); if (DB.moderation.bans?.[u.toLowerCase()]) return err('🚫 Du bist von NoahsChat gebannt.'); currentUser={...entry}; saveSession(currentUser); refreshCaptcha('login'); dbSet(`users/${u.toLowerCase()}/_fp`, MY_FP); dbSet(`online/${entry.username}`, Date.now()); userLanguage=entry.settings?.lang||'de'; translationEnabled=entry.settings?.translate||false; launchApp(); } // ─── LOGOUT ─────────────────────────────────────────────────── async function doLogout() { if (!currentUser) return; saveSession(null); if (voiceActive) await leaveVoice(); dbDelete(`online/${currentUser.username}`); if (voiceActive) leaveVoice(); clearInterval(pollTimer); currentUser=null; document.getElementById('app').classList.remove('visible'); document.getElementById('auth-screen').style.display='flex'; document.getElementById('login-pass').value=''; refreshCaptcha('login'); } // ─── LAUNCH APP ─────────────────────────────────────────────── async function launchApp() { document.getElementById('auth-screen').style.display='none'; document.getElementById('app').classList.add('visible'); appConfig = DB.config || {}; if (!appConfig.channels) { appConfig.channels = window.DEFAULT_CHANNELS||[]; appConfig.automod = window.DEFAULT_AUTOMOD||{}; dbSet('config', appConfig); } updateTopbar(); buildChannelList(); restoreVoiceState(); openChannel('allgemein'); startPolling(); } // ─── POLLING ────────────────────────────────────────────────── function startPolling() { pollTimer = setInterval(pollTick, POLL_MS); } async function pollTick() { if (!currentUser) return; // DB neu laden → bekommt Änderungen anderer User (mit Merge, damit lokale Msgs nicht verloren gehen) setSyncStatus('sync'); await apiLoad(true); setSyncStatus('ok'); // Eigene Daten aktualisieren (Rolle könnte geändert worden sein) const me = DB.users[currentUser.username.toLowerCase()]; if (me) { currentUser = {...me}; updateTopbar(); userLanguage = me.settings?.lang||'de'; translationEnabled = me.settings?.translate||false; } // Heartbeat — NUR den eigenen Online-Eintrag setzen, NICHT die komplette DB speichern // Direkt im Objekt setzen und gezielt speichern um Race-Conditions zu vermeiden DB.online[currentUser.username] = Date.now(); // Heartbeat-only save: kleines Debounce damit nicht zu viele Requests entstehen scheduleHeartbeatSave(); // Moderation-Checks const mod = DB.moderation; if (mod.bans?.[currentUser.username.toLowerCase()] || mod.banned_fps?.[MY_FP]) { clearInterval(pollTimer); showBanned(); return; } const kick = mod.kicked?.[currentUser.username.toLowerCase()]; if (kick && Date.now()-kick.ts < 60000) { clearInterval(pollTimer); showKickedModal(); return; } updateTimeoutBar(); // Online-User-Liste const now = Date.now(); const onlineNames = Object.entries(DB.online) .filter(([,ts])=>now-ts<12000).map(([u])=>u); document.getElementById('online-count').textContent = onlineNames.length; renderUserList(onlineNames); updateDMList(); // Nachrichten anzeigen if (currentDM) renderDMMessages(currentDM); else { renderChannelMessages(currentChannel); // Unread-Badges für andere Channels for (const ch of (appConfig.channels||[])) { if (ch.id===currentChannel) continue; const msgs = getMsgs(ch.id); const key = 'ch_'+ch.id; const prev = lastMsgCounts[key]||0; if (msgs.length>prev) { unreadCounts[key] = (unreadCounts[key]||0) + (msgs.length-prev); updateBadge('badge-'+ch.id, unreadCounts[key]); lastMsgCounts[key] = msgs.length; } } } // DM Unread-Badges for (const entry of Object.values(DB.users)) { if (entry.username===currentUser.username) continue; if (currentDM===entry.username) continue; const key = 'dm_'+entry.username.toLowerCase(); const msgs = getDMMsgs(getDMKey(currentUser.username, entry.username)); const prev = lastMsgCounts[key]||0; if (msgs.length>prev) { unreadCounts[key] = (unreadCounts[key]||0) + (msgs.length-prev); lastMsgCounts[key] = msgs.length; updateDMList(); } } // Abgelaufene Voice-Einträge aufräumen (20s Timeout) for (const [vcId, parts] of Object.entries(DB.voice||{})) { for (const [uname, data] of Object.entries(parts||{})) { if (now-(data.ts||0) > 20000) { delete DB.voice[vcId][uname]; } } } // Voice: neue User im Channel erkennen und anrufen voicePollConnect(); // Voice-Heartbeat: eigenen Eintrag aktuell halten if (voiceActive && voiceChannel && DB.voice?.[voiceChannel]?.[currentUser.username]) { DB.voice[voiceChannel][currentUser.username].ts = now; } buildChannelList(); } // ─── SYNC STATUS INDICATOR ──────────────────────────────────── function setSyncStatus(state) { /* deaktiviert – keine störende Animation */ } function restoreVoiceState() { const vcNames = {'voice-allgemein':'Allgemein','voice-gaming':'Gaming','voice-musik':'Musik'}; for (const [vcId, parts] of Object.entries(DB.voice||{})) { if (parts?.[currentUser.username]) { // Nach Reload: Voice neu joinen (stellt PeerJS-Verbindung wieder her) const name = vcNames[vcId] || vcId; joinVoice(vcId, name); break; } } } // ─── TOPBAR ─────────────────────────────────────────────────── function updateTopbar() { const av=document.getElementById('topbar-avatar'); av.textContent=currentUser.username[0].toUpperCase(); av.className=`user-avatar-sm av-color-${currentUser.color}`; document.getElementById('topbar-username').textContent=currentUser.username; document.getElementById('topbar-role-badge').textContent=ROLE_BADGE[currentUser.role]||''; } // ─── SIDEBAR ────────────────────────────────────────────────── function buildChannelList() { const channels=appConfig.channels||window.DEFAULT_CHANNELS||[]; const list=document.getElementById('channel-list'); list.innerHTML=''; const cats={info:'Info',chat:'Text-Channels',fun:'Community',staff:'Staff'}; const grouped={}; for (const ch of channels) { const cat=ch.category||'chat'; if (!grouped[cat]) grouped[cat]=[]; grouped[cat].push(ch); } for (const [catId,label] of Object.entries(cats)) { const chs=grouped[catId]; if (!chs) continue; if (catId==='staff'&&!hasRole('mod')) continue; const catEl=document.createElement('div'); catEl.className='channel-category'; catEl.textContent='— '+label; list.appendChild(catEl); for (const ch of chs) { if (ch.adminOnly&&!hasRole('admin')) { if (catId==='staff') continue; const div=document.createElement('div'); div.className='channel-item ch-locked'; div.innerHTML=`${ch.icon}${ch.name}🔒`; list.appendChild(div); continue; } if (ch.modOnly&&!hasRole('mod')) continue; const div=document.createElement('div'); div.className='channel-item'+(ch.id===currentChannel&&!currentDM?' active':''); div.dataset.channel=ch.id; div.innerHTML=`${ch.icon}${ch.name}`; div.onclick=()=>openChannel(ch.id); list.appendChild(div); } } // Voice Channels const vcCat=document.createElement('div'); vcCat.className='channel-category'; vcCat.textContent='— Voice Channels'; list.appendChild(vcCat); for (const vc of [{id:'voice-allgemein',name:'Allgemein',icon:'🔊'},{id:'voice-gaming',name:'Gaming',icon:'🎮'},{id:'voice-musik',name:'Musik',icon:'🎵'}]) { const isActive=voiceActive&&voiceChannel===vc.id; const div=document.createElement('div'); div.className='channel-item'+(isActive?' active':''); div.innerHTML=`${vc.icon}${vc.name}`; div.onclick=()=>joinVoice(vc.id,vc.name); list.appendChild(div); for (const p of Object.values(DB.voice?.[vc.id]||{})) { const pe=document.createElement('div'); pe.style.cssText='padding:3px 14px 3px 32px;font-size:.78rem;color:var(--green);display:flex;gap:6px;align-items:center;pointer-events:none'; pe.innerHTML=`🎙${escHtml(p.name)}${p.muted?'🔇':''}`; list.appendChild(pe); } } } // ─── CHANNELS ───────────────────────────────────────────────── function openChannel(chId) { currentChannel=chId; currentDM=null; document.querySelectorAll('.channel-item').forEach(el=>el.classList.remove('active')); document.querySelector(`[data-channel="${chId}"]`)?.classList.add('active'); document.querySelectorAll('.dm-item').forEach(el=>el.classList.remove('active')); const ch=(appConfig.channels||[]).find(c=>c.id===chId)||{name:chId,icon:'💬',sub:''}; document.getElementById('chat-title').textContent=ch.icon+' '+ch.name; document.getElementById('chat-sub').textContent=ch.sub||''; document.getElementById('topbar-channel-name').textContent='# '+ch.name; unreadCounts['ch_'+chId]=0; updateBadge('badge-'+chId,0); const locked=(ch.adminOnly&&!hasRole('admin'))||(ch.readOnly&&!hasRole('admin'))||(ch.modOnly&&!hasRole('mod')); document.getElementById('msg-input').disabled=locked; document.getElementById('send-btn').disabled=locked; document.getElementById('msg-input').placeholder=locked?'🔒 Du kannst hier nicht schreiben':'Nachricht schreiben...'; renderChannelMessages(chId); } function getMsgs(chId) { return Object.values(DB.msgs[chId]||{}).sort((a,b)=>a.ts-b.ts); } function renderChannelMessages(chId) { const msgs=getMsgs(chId); lastMsgCounts['ch_'+chId]=msgs.length; renderMessages(msgs); } // ─── DMs ────────────────────────────────────────────────────── function getDMKey(a,b) { return [a.toLowerCase(),b.toLowerCase()].sort().join('__'); } function getDMMsgs(key) { return Object.values(DB.dms[key]||{}).sort((a,b)=>a.ts-b.ts); } function openDM(username) { currentDM=username; currentChannel=null; document.querySelectorAll('.channel-item').forEach(el=>el.classList.remove('active')); document.querySelectorAll('.dm-item').forEach(el=>{ el.classList.toggle('active',el.dataset.user===username.toLowerCase()); }); document.getElementById('chat-title').textContent='@ '+username; document.getElementById('chat-sub').textContent='Direktnachricht – privat'; document.getElementById('topbar-channel-name').textContent='@ '+username; document.getElementById('msg-input').disabled=false; document.getElementById('send-btn').disabled=false; document.getElementById('msg-input').placeholder=`Nachricht an @${username}...`; unreadCounts['dm_'+username.toLowerCase()]=0; updateDMList(); renderDMMessages(username); } function renderDMMessages(username) { const key=getDMKey(currentUser.username,username); const msgs=getDMMsgs(key); lastMsgCounts['dm_'+username.toLowerCase()]=msgs.length; renderMessages(msgs,true); } // ─── RENDER MESSAGES ────────────────────────────────────────── function renderMessages(msgs, isDM=false) { const c=document.getElementById('messages'); const atBottom=c.scrollHeight-c.scrollTop<=c.clientHeight+80; if (!msgs.length) { c.innerHTML='
💬

Noch keine Nachrichten. Schreib was!

'; return; } let html='', lastDay='', lastAuthor='', lastTime=0; for (let i=0; i⚙ ${escHtml(m.text)}`; lastAuthor=''; continue; } if (m.automod){ html+=`
🤖 AutoMod: ${escHtml(m.text)}
`; lastAuthor=''; continue; } const date=new Date(m.ts); const day=date.toLocaleDateString('de-DE',{day:'2-digit',month:'2-digit',year:'numeric'}); const time=date.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'}); if (day!==lastDay) { html+=`
${day}
`; lastDay=day; lastAuthor=''; } const color=m.color??0; const consec=m.author===lastAuthor&&(m.ts-lastTime)<5*60000; lastAuthor=m.author; lastTime=m.ts; const initials=(m.author||'?')[0].toUpperCase(); const role=m.role||'member'; const badge=ROLE_BADGE[role]||''; const nc=ROLE_COLORS[role]||`name-color-${color}`; const canDel=hasRole('mod')||m.author===currentUser?.username; const canPin=hasRole('mod')&&!isDM; const safeT=escHtml((m.text||'').substring(0,50)); html+=`
${initials}
${escHtml(m.author)}${badge?` ${badge}`:''} ${time}
${canPin?``:''} ${canDel?``:''}
${formatMsg(m.text)}
`; } c.innerHTML=html; if (atBottom) c.scrollTop=c.scrollHeight; } function formatMsg(text) { return escHtml(text) .replace(/\*\*(.*?)\*\*/g,'$1') .replace(/\*(.*?)\*/g,'$1') .replace(/`(.*?)`/g,'$1') .replace(/(https?:\/\/[^\s]+)/g,'$1'); } // ─── SEND ───────────────────────────────────────────────────── async function sendMessage() { const input=document.getElementById('msg-input'); const text=input.value.trim(); if (!text||!currentUser) return; const mod=DB.moderation; const to=mod.timeouts?.[currentUser.username.toLowerCase()]; if (to&&to.until>Date.now()) { showToast('Du bist stummgeschaltet!','var(--warn)'); return; } if (!currentDM) { const ch=(appConfig.channels||[]).find(c=>c.id===currentChannel); if (ch?.adminOnly&&!hasRole('admin')) return; if (ch?.readOnly&&!hasRole('admin')) return; if (ch?.modOnly&&!hasRole('mod')) return; const am=autoModCheck(text); if (am.blocked) { showToast('🤖 AutoMod: '+am.reason,'var(--warn)'); input.value=''; input.style.height=''; return; } } const now=Date.now(); spamTs=spamTs.filter(t=>now-t<(appConfig.automod?.spamWindow||10000)); spamTs.push(now); if (spamTs.length>(appConfig.automod?.spamThreshold||5)) { pendingSpamMsg=text; input.value=''; input.style.height=''; showSpamCaptchaModal(); return; } input.value=''; input.style.height=''; const msg={author:currentUser.username,color:currentUser.color,role:currentUser.role,text,ts:Date.now()}; if (currentDM) { const key=getDMKey(currentUser.username,currentDM); if (!DB.dms[key]) DB.dms[key]={}; dbPush(`dms/${key}`,msg); renderDMMessages(currentDM); } else { if (!DB.msgs[currentChannel]) DB.msgs[currentChannel]={}; dbPush(`msgs/${currentChannel}`,msg); renderChannelMessages(currentChannel); document.getElementById('messages').scrollTop=9999999; } } function autoModCheck(text) { const am=appConfig.automod||{}; if (!am.enabled) return {blocked:false}; if (am.maxMsgLength&&text.length>am.maxMsgLength) return {blocked:true,reason:`Zu lang (max. ${am.maxMsgLength})`}; const lower=text.toLowerCase(); for (const w of (am.bannedWords||[])) { if(lower.includes(w.toLowerCase())) return {blocked:true,reason:'Verbotenes Wort'}; } if (am.capsFilter&&text.length>10) { const caps=(text.match(/[A-ZÄÖÜ]/g)||[]).length; if (caps/text.length>(am.capsThreshold||0.7)) return {blocked:true,reason:'Zu viele Großbuchstaben'}; } return {blocked:false}; } function deleteMsg(key,isDM=false) { if (!key) return; if (isDM&¤tDM) { const dmKey=getDMKey(currentUser.username,currentDM); const m=DB.dms[dmKey]?.[key]; if (!m||(m.author!==currentUser.username&&!hasRole('mod'))) return; dbDelete(`dms/${dmKey}/${key}`); renderDMMessages(currentDM); } else { const m=DB.msgs[currentChannel]?.[key]; if (!m||(m.author!==currentUser.username&&!hasRole('mod'))) return; dbDelete(`msgs/${currentChannel}/${key}`); renderChannelMessages(currentChannel); } } function pinMsg(author,text) { if (!Array.isArray(DB.pins)) DB.pins=[]; DB.pins.push({author,text,by:currentUser.username,ts:Date.now(),channel:currentChannel}); if (DB.pins.length>50) DB.pins.shift(); apiSave(); showToast('📌 Nachricht angepinnt','var(--green)'); } function handleKey(e) { if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();sendMessage();} } function autoResize(el) { el.style.height='auto'; el.style.height=Math.min(el.scrollHeight,120)+'px'; } // ─── TIMEOUT BAR ────────────────────────────────────────────── function updateTimeoutBar() { const t=DB.moderation.timeouts?.[currentUser.username.toLowerCase()]; const bar=document.getElementById('timeout-bar'); if (t&&t.until>Date.now()) { bar.style.display=''; document.getElementById('timeout-until').textContent= new Date(t.until).toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit',second:'2-digit'}); document.getElementById('msg-input').disabled=true; document.getElementById('send-btn').disabled=true; } else { bar.style.display='none'; if (!currentDM) { const ch=(appConfig.channels||[]).find(c=>c.id===currentChannel); const locked=(ch?.adminOnly&&!hasRole('admin'))||(ch?.readOnly&&!hasRole('admin')); document.getElementById('msg-input').disabled=!!locked; document.getElementById('send-btn').disabled=!!locked; } } } function updateBadge(id,count) { const el=document.getElementById(id); if (!el) return; if (count>0){el.style.display='';el.textContent=count>99?'99+':count;} else el.style.display='none'; } // ═══════════════════════════════════════════════════════════════ // REAL VOICE — WebRTC via PeerJS // ═══════════════════════════════════════════════════════════════ let myPeer = null; // PeerJS-Instanz let localStream = null; // Mikrofon-Stream let activeCalls = {}; // { peerId: Call } let activeAudios = {}; // { peerId: