// ═══════════════════════════════════════════════════════════════
// 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+=``;
}
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: