PageSourceSearch

https://afu-base.de/wp-content/plugins/ch-wp-chat/assets/chat.js?ver=1.5.0

js afu-base.de collected 2026-10-02 07:13:45 UTC 48,720 bytes, 508 lines download raw bytes

1/* CH WP Chat by Conny Henn - https://blog.hennweb.de/ch-wp-chat/ */
2(() => {
3  'use strict';
4  const $$ = (s, r=document) => [...r.querySelectorAll(s)];
5  const configOf = el => { try { return JSON.parse(el.dataset.config || '{}'); } catch (_) { return {}; } };
6  const SESSION_KEY='chfc_session', SOUND_KEY='chfc_sound_enabled', INVITE_SOUND_KEY='chfc_invite_sound_enabled', BELL_RECEIVE_KEY='chfc_bell_receive_enabled', FAVORITES_KEY='chfc_favorites', RECENT_EMOJI_KEY='chfc_recent_emoji', DENSITY_KEY='chfc_display_density', OWN_LEFT_KEY='chfc_own_left';
7
8  class Api {
9    constructor(config){ this.config=config; }
10    session(){ try{return JSON.parse(localStorage.getItem(SESSION_KEY)||'null');}catch(_){return null;} }
11    save(v){ v?localStorage.setItem(SESSION_KEY,JSON.stringify(v)):localStorage.removeItem(SESSION_KEY); }
12    async call(path, options={}){
13      const session=this.session(), isForm=options.body instanceof FormData, headers={...(isForm?{}:{'Content-Type':'application/json'}),...(options.headers||{})};
14      const url=new URL(this.config.restUrl+path,location.href);
15      if(session){ headers['X-CHFC-Token']=session.token; url.searchParams.set('session_id',session.session_id); }
16      const response=await fetch(url,{...options,headers,credentials:'same-origin',cache:'no-store'});
17      const data=await response.json().catch(()=>({}));
18      if(!response.ok) throw new Error(data.message||'Unbekannter Fehler.');
19      return data;
20    }
21  }
22
23  class App {
24    constructor(root){
25      this.root=root; this.config=configOf(root); this.api=new Api(this.config);
26      this.login=root.querySelector('[data-chfc-login]'); this.roomEl=root.querySelector('[data-chfc-room]');
27      this.loginForm=root.querySelector('[data-action="join"]'); this.loginError=root.querySelector('[data-login-error]');
28      this.room=null; this.loginForm.addEventListener('submit',e=>this.join(e));
29      $$('[data-login-dialog]',this.login).forEach(b=>b.addEventListener('click',()=>this.login.querySelector(`[data-login-info-dialog="${b.dataset.loginDialog}"]`)?.showModal()));
30      $$('[data-action="close-login-info"]',this.login).forEach(b=>b.addEventListener('click',()=>b.closest('dialog')?.close()));
31      this.api.session()?this.showRoom():this.showLogin();
32    }
33    showLogin(message=''){
34      if(this.room)this.room.destroy(); this.room=null; this.roomEl.hidden=true; this.login.hidden=false;
35      this.loginError.textContent=message; setTimeout(()=>this.loginForm.elements.nickname?.focus(),60);
36    }
37    showRoom(){ this.login.hidden=true; this.roomEl.hidden=false; this.room=new Room(this.roomEl,this.config,this.api,m=>this.showLogin(m)); }
38    async join(e){
39      e.preventDefault(); if(!this.loginForm.reportValidity())return;
40      const submit=this.loginForm.querySelector('button[type="submit"]'), formData=new FormData(this.loginForm), nickname=String(formData.get('nickname')||'').trim(), gravatar_email=String(formData.get('gravatar_email')||'').trim();
41      submit.disabled=true; this.loginError.textContent='';
42      try{ const data=await this.api.call('/join',{method:'POST',body:JSON.stringify({nickname,gravatar_email})}); this.api.save(data); this.loginForm.reset(); this.showRoom(); }
43      catch(err){ this.loginError.textContent=err.message; }
44      finally{ submit.disabled=false; }
45    }
46  }
47
48  class Room {
49    constructor(root,config,api,onLogout){
50      this.root=root; this.config=config; this.api=api; this.onLogout=onLogout; this.session=api.session();
51      this.messages=root.querySelector('[data-messages]'); this.tabs=root.querySelector('[data-chat-tabs]'); this.newMessagesButton=root.querySelector('[data-action="new-messages"]'); this.users=root.querySelector('[data-users]');
52      this.error=root.querySelector('[data-error]'); this.typingEl=root.querySelector('[data-typing]'); this.form=root.querySelector('[data-action="send"]'); this.textarea=this.form.elements.message;
53      this.current={id:'',name:'Öffentlicher Chat'}; this.last={public:0,private:{}}; this.privateChats=new Map(); this.unread=new Map();
54      this.busy=false; this.sending=false; this.initial=true; this.pendingNew=0; this.lastRendered=null; this.renderedMessageIds=new Set(); this.timer=null; this.currentInvite=null; this.seenInvites=new Set(); this.acceptedSeen=new Set();
55      this.sound=localStorage.getItem(SOUND_KEY)!=='0'; this.inviteSound=localStorage.getItem(INVITE_SOUND_KEY)!=='0'; this.bellReceive=localStorage.getItem(BELL_RECEIVE_KEY)!=='0'; this.audio=null; this.ringtoneAudio=null; this.ringtoneUnlocked=false; this.userItems=[]; this.searchTerm='';
56      this.messageFilter=''; this.messageFilterInput=root.querySelector('[data-message-filter]'); this.resetFilterButton=root.querySelector('[data-action="reset-filter"]');
57      this.favorites=new Set(this.readJson(FAVORITES_KEY,[])); this.recentEmoji=this.readJson(RECENT_EMOJI_KEY,[]);
58      this.typingTimer=null; this.typingSent=false; this.mentionIndex=0; this.mentionMatches=[];
59      this.displayDensity=localStorage.getItem(DENSITY_KEY)||config.displayDensity||'comfortable';
60      if(!['comfortable','compact','dense'].includes(this.displayDensity))this.displayDensity='comfortable';
61      this.ownLeft=localStorage.getItem(OWN_LEFT_KEY)==='1';
62      this.applyDisplayPreferences();
63      this.bind(); this.buildEmoji(); this.renderChatTabs(); this.updateSound(); this.updateInviteSound(); this.updateBellReceive(); this.updateChars(); this.tick();
64      this.timer=setInterval(()=>this.tick(),Math.max(2,Number(config.pollSeconds||4))*1000);
65    }
66    readJson(key,fallback){try{return JSON.parse(localStorage.getItem(key)||JSON.stringify(fallback));}catch(_){return fallback;}}
67    bind(){
68      this.form.addEventListener('submit',e=>this.send(e));
69      this.textarea.addEventListener('input',()=>{this.updateChars();this.handleTyping();this.updateMentionMenu();});
70      this.textarea.addEventListener('keydown',e=>this.handleKeydown(e));
71      this.root.querySelector('[data-action="public"]').addEventListener('click',()=>this.openPublic());
72      this.root.querySelector('[data-action="leave"]').addEventListener('click',()=>this.leave());
73      this.root.querySelector('[data-action="clear-for-me"]')?.addEventListener('click',()=>this.clearForMe());
74      $$('[data-action="sound"]',this.root).forEach(b=>b.addEventListener('click',()=>this.toggleSound()));
75      $$('[data-action="invite-sound"]',this.root).forEach(b=>b.addEventListener('click',()=>this.toggleInviteSound()));
76      $$('[data-action="bell-receive"]',this.root).forEach(b=>b.addEventListener('click',()=>this.toggleBellReceive()));
77      this.root.querySelector('[data-action="emoji"]').addEventListener('click',e=>this.toggleEmoji(e));
78      const choose=this.root.querySelector('[data-action="choose-image"]'), imageInput=this.root.querySelector('[data-image-input]');
79      if(choose&&imageInput){choose.addEventListener('click',()=>imageInput.click());imageInput.addEventListener('change',()=>this.uploadImage(imageInput));this.textarea.addEventListener('paste',e=>this.handleClipboardPaste(e));}
80      this.root.querySelector('[data-emoji-search]').addEventListener('input',e=>this.filterEmoji(e.target.value));
81      this.root.querySelector('[data-user-search]').addEventListener('input',e=>{this.searchTerm=e.target.value.trim().toLocaleLowerCase();this.renderUsers(this.userItems);});
82      this.messageFilterInput.addEventListener('input',e=>{this.messageFilter=e.target.value.trim().toLocaleLowerCase();this.resetFilterButton.disabled=!this.messageFilter;this.applyMessageFilter();});
83      this.resetFilterButton.addEventListener('click',()=>this.resetMessageFilter());
84      this.root.querySelector('[data-action="apply-filter"]')?.addEventListener('click',()=>{this.messageFilter=this.messageFilterInput.value.trim().toLocaleLowerCase();this.resetFilterButton.disabled=!this.messageFilter;this.applyMessageFilter();this.messageFilterInput.focus();});
85      this.root.querySelector('[data-action="jump-latest"]').addEventListener('click',()=>this.jumpToLatest());
86      this.root.querySelector('[data-action="ring-user"]')?.addEventListener('click',()=>this.ringCurrentUser());
87      this.newMessagesButton.addEventListener('click',()=>this.jumpToLatest());
88      this.messages.addEventListener('scroll',()=>{if(this.isNearBottom())this.clearNewMessageNotice();});
89      this.root.querySelector('[data-action="invite-accept"]').addEventListener('click',()=>this.respondInvite('accepted'));
90      this.root.querySelector('[data-action="invite-decline"]').addEventListener('click',()=>this.respondInvite('declined'));
91      const menuButton=this.root.querySelector('[data-action="menu"]'),menu=this.root.querySelector('[data-menu]');
92      menuButton?.addEventListener('click',e=>{e.stopPropagation();const open=menu.hidden;menu.hidden=!open;menuButton.setAttribute('aria-expanded',String(open));});
93      $$('[data-dialog]',menu).forEach(b=>b.addEventListener('click',()=>{this.openInfoDialog(b.dataset.dialog);menu.hidden=true;menuButton.setAttribute('aria-expanded','false');}));
94      $$('[data-action="close-info"]',this.root).forEach(b=>b.addEventListener('click',()=>b.closest('dialog')?.close()));
95      const density=this.root.querySelector('[data-display-density]'),ownLeft=this.root.querySelector('[data-own-left]');
96      if(density){density.value=this.displayDensity;density.addEventListener('change',()=>{this.displayDensity=density.value;localStorage.setItem(DENSITY_KEY,this.displayDensity);this.applyDisplayPreferences();});}
97      if(ownLeft){ownLeft.checked=this.ownLeft;ownLeft.addEventListener('change',()=>{this.ownLeft=ownLeft.checked;localStorage.setItem(OWN_LEFT_KEY,this.ownLeft?'1':'0');this.applyDisplayPreferences();});}
98      this.unlockRingtone=()=>this.unlockCustomRingtone();
99      document.addEventListener('pointerdown',this.unlockRingtone,{once:true});
100      document.addEventListener('keydown',this.unlockRingtone,{once:true});
101      this.outside=e=>{if(!e.target.closest('.chfc-compose-box')){this.closeEmoji();this.closeMentionMenu();}const menu=this.root.querySelector('[data-menu]');if(menu&&!menu.hidden&&!e.target.closest('.chfc-menu-wrap')){menu.hidden=true;this.root.querySelector('[data-action="menu"]')?.setAttribute('aria-expanded','false');}}; document.addEventListener('click',this.outside);
102    }
103    applyDisplayPreferences(){
104      this.root.classList.remove('chfc-density-comfortable','chfc-density-compact','chfc-density-dense','chfc-own-left');
105      this.root.classList.add(`chfc-density-${this.displayDensity}`);
106      this.root.classList.toggle('chfc-own-left',this.ownLeft);
107    }
108    makeAvatar(url,nickname,className='chfc-avatar'){
109      const avatar=document.createElement('div');avatar.className=className;avatar.textContent=(nickname||'?').slice(0,1).toUpperCase();
110      if(url){const img=document.createElement('img');img.src=url;img.alt='';img.loading='lazy';img.referrerPolicy='no-referrer';img.onerror=()=>img.remove();avatar.append(img);}
111      return avatar;
112    }
113    destroy(){ clearInterval(this.timer); clearTimeout(this.typingTimer); document.removeEventListener('click',this.outside); document.removeEventListener('pointerdown',this.unlockRingtone); document.removeEventListener('keydown',this.unlockRingtone); if(this.ringtoneAudio){this.ringtoneAudio.pause();this.ringtoneAudio=null;} this.setTyping(false); const d=this.root.querySelector('[data-invite-dialog]'); if(d.open)d.close(); }
114    updateChars(){this.root.querySelector('[data-char-count]').textContent=`${this.textarea.value.length}/${this.textarea.maxLength}`;}
115    handleKeydown(e){
116      const menu=this.root.querySelector('[data-mention-menu]');
117      if(!menu.hidden&&this.mentionMatches.length){
118        if(e.key==='ArrowDown'){e.preventDefault();this.mentionIndex=(this.mentionIndex+1)%this.mentionMatches.length;this.renderMentionMenu();return;}
119        if(e.key==='ArrowUp'){e.preventDefault();this.mentionIndex=(this.mentionIndex-1+this.mentionMatches.length)%this.mentionMatches.length;this.renderMentionMenu();return;}
120        if(e.key==='Enter'||e.key==='Tab'){e.preventDefault();this.insertMention(this.mentionMatches[this.mentionIndex]);return;}
121        if(e.key==='Escape'){e.preventDefault();this.closeMentionMenu();return;}
122      }
123      if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();this.form.requestSubmit();}
124      if(e.key==='Escape')this.closeEmoji();
125    }
126    handleTyping(){
127      this.setTyping(true); clearTimeout(this.typingTimer); this.typingTimer=setTimeout(()=>this.setTyping(false),2200);
128    }
129    async setTyping(active){
130      if(this.typingSent===active)return; this.typingSent=active;
131      try{await this.api.call('/typing',{method:'POST',body:JSON.stringify({active,with:this.current.id||''})});}catch(_){}
132    }
133    renderTyping(items){
134      const names=(items||[]).map(i=>i.nickname).filter(Boolean);
135      if(!names.length){this.typingEl.textContent='';return;}
136      this.typingEl.textContent=names.length===1?`${names[0]} schreibt gerade …`:`${names.slice(0,2).join(' und ')} schreiben gerade …`;
137    }
138
139    emojiData(){return {
140      'Zuletzt':[],
141      'Smileys':[['😀','grinsen fröhlich'],['😃','lächeln froh'],['😄','freude lachen'],['😁','grinsen'],['😆','lachen'],['😅','erleichtert'],['😂','tränen lachen'],['🤣','laut lachen'],['😊','lächeln'],['🙂','freundlich'],['🙃','umgedreht'],['😉','zwinkern'],['😍','verliebt'],['🥰','liebe'],['😘','kuss'],['😎','cool'],['🤓','nerd'],['🤔','denken'],['😴','schlafen'],['😢','traurig'],['😭','weinen'],['😡','wütend'],['🤯','wow'],['🥳','party']],
142      'Gesten':[['👍','daumen hoch ja'],['👎','daumen runter nein'],['👌','okay'],['✌️','peace sieg'],['🤞','glück'],['👏','applaus'],['🙌','jubel'],['🤝','handschlag'],['🙏','bitte danke'],['💪','stark'],['👋','winken hallo'],['🤗','umarmung'],['🤦','facepalm'],['🤷','keine ahnung']],
143      'Herzen':[['❤️','liebe herz rot'],['🧡','herz orange'],['💛','herz gelb'],['💚','herz grün'],['💙','herz blau'],['💜','herz lila'],['🖤','herz schwarz'],['🤍','herz weiß'],['💔','gebrochenes herz'],['💕','zwei herzen'],['💯','hundert']],
144      'Tiere':[['🐶','hund'],['🐱','katze'],['🐭','maus'],['🐰','hase'],['🦊','fuchs'],['🐻','bär'],['🐼','panda'],['🐨','koala'],['🐯','tiger'],['🦁','löwe'],['🐸','frosch'],['🐵','affe']],
145      'Essen':[['🍕','pizza'],['🍔','burger'],['🍟','pommes'],['🌭','hotdog'],['🍿','popcorn'],['🥗','salat'],['🍎','apfel'],['🍓','erdbeere'],['🍰','kuchen'],['☕','kaffee'],['🍺','bier'],['🥤','getränk']],
146      'Aktivitäten':[['⚽','fußball sport'],['🏀','basketball'],['🎮','gaming spiel'],['🎯','ziel'],['🎸','gitarre musik'],['🎬','film'],['🚗','auto'],['✈️','flugzeug reisen'],['🚀','rakete'],['🏆','pokal'],['🎉','party'],['🎁','geschenk']],
147      'Symbole':[['✅','ja erledigt'],['❌','nein falsch'],['⚠️','warnung'],['✨','funkeln'],['⭐','stern'],['🔥','feuer'],['🌞','sonne'],['🌙','mond'],['🍀','glück'],['💬','chat'],['❓','frage'],['❗','ausrufezeichen']]
148    };}
149    buildEmoji(){
150      const cats=this.root.querySelector('[data-emoji-categories]'); cats.textContent='';
151      Object.keys(this.emojiData()).forEach((name,i)=>{const b=document.createElement('button');b.type='button';b.textContent=name;b.dataset.category=name;if(i===1)b.classList.add('is-active');b.onclick=()=>this.renderEmoji(name);cats.append(b);});
152      this.renderEmoji(this.recentEmoji.length?'Zuletzt':'Smileys');
153    }
154    emojiItems(category){
155      if(category==='Zuletzt')return this.recentEmoji.map(e=>[e,'zuletzt']);
156      return this.emojiData()[category]||[];
157    }
158    renderEmoji(category,filter=''){
159      const grid=this.root.querySelector('[data-emoji-grid]');grid.textContent='';
160      $$('.chfc-emoji-categories button',this.root).forEach(b=>b.classList.toggle('is-active',b.dataset.category===category));
161      let all=filter?Object.entries(this.emojiData()).flatMap(([cat,items])=>cat==='Zuletzt'?[]:items):this.emojiItems(category);
162      const q=filter.toLocaleLowerCase(); if(q)all=all.filter(([,keywords])=>keywords.toLocaleLowerCase().includes(q));
163      if(!all.length){const empty=document.createElement('p');empty.className='chfc-emoji-empty';empty.textContent='Keine Emojis gefunden.';grid.append(empty);return;}
164      all.forEach(([emoji])=>{const b=document.createElement('button');b.type='button';b.className='chfc-emoji-option';b.textContent=emoji;b.title=emoji;b.onclick=()=>this.insertEmoji(emoji);grid.append(b);});
165    }
166    filterEmoji(value){const v=value.trim();if(v){this.renderEmoji('',v);}else{const active=this.root.querySelector('.chfc-emoji-categories .is-active');this.renderEmoji(active?.dataset.category||'Smileys');}}
167    toggleEmoji(e){e.stopPropagation();const p=this.root.querySelector('[data-emoji-picker]');p.hidden=!p.hidden;this.root.querySelector('[data-action="emoji"]').setAttribute('aria-expanded',String(!p.hidden));this.closeMentionMenu();if(!p.hidden)this.root.querySelector('[data-emoji-search]').focus();}
168    closeEmoji(){const p=this.root.querySelector('[data-emoji-picker]');p.hidden=true;this.root.querySelector('[data-action="emoji"]').setAttribute('aria-expanded','false');}
169    insertEmoji(emoji){const s=this.textarea.selectionStart,e=this.textarea.selectionEnd;this.textarea.setRangeText(emoji,s,e,'end');this.textarea.focus();this.recentEmoji=[emoji,...this.recentEmoji.filter(x=>x!==emoji)].slice(0,24);localStorage.setItem(RECENT_EMOJI_KEY,JSON.stringify(this.recentEmoji));this.updateChars();}
170
171    updateMentionMenu(){
172      const pos=this.textarea.selectionStart, before=this.textarea.value.slice(0,pos), match=before.match(/(?:^|\s)@([^\s@]{0,24})$/u);
173      if(!match){this.closeMentionMenu();return;}
174      const q=(match[1]||'').toLocaleLowerCase();
175      this.mentionMatches=this.userItems.filter(u=>u.public_id!==this.session.session_id&&u.nickname.toLocaleLowerCase().includes(q)).slice(0,8);
176      this.mentionIndex=0; this.renderMentionMenu();
177    }
178    renderMentionMenu(){
179      const menu=this.root.querySelector('[data-mention-menu]');menu.textContent='';
180      if(!this.mentionMatches.length){menu.hidden=true;return;}
181      this.mentionMatches.forEach((u,i)=>{const b=document.createElement('button');b.type='button';b.className=i===this.mentionIndex?'is-active':'';b.innerHTML='<span class="chfc-mini-avatar"></span><span></span>';b.children[0].textContent=u.nickname.slice(0,1).toUpperCase();b.children[1].textContent=u.nickname;b.onmousedown=e=>{e.preventDefault();this.insertMention(u);};menu.append(b);});menu.hidden=false;
182    }
183    insertMention(user){
184      const pos=this.textarea.selectionStart,before=this.textarea.value.slice(0,pos),match=before.match(/(?:^|\s)@([^\s@]{0,24})$/u);if(!match)return;
185      const start=pos-match[0].length+(match[0].startsWith(' ')?1:0);this.textarea.setRangeText(`@${user.nickname} `,start,pos,'end');this.closeMentionMenu();this.textarea.focus();this.updateChars();
186    }
187    closeMentionMenu(){const m=this.root.querySelector('[data-mention-menu]');m.hidden=true;this.mentionMatches=[];}
188
189    updateSound(){$$('[data-action="sound"]',this.root).forEach(b=>{if(b.classList.contains('chfc-setting-toggle'))b.textContent=this.sound?'Ton an':'Ton aus';else b.innerHTML=this.sound?'🔔 <span>Ton an</span>':'🔕 <span>Ton aus</span>';b.setAttribute('aria-pressed',String(this.sound));b.classList.toggle('is-on',this.sound);});}
190    toggleSound(){this.sound=!this.sound;localStorage.setItem(SOUND_KEY,this.sound?'1':'0');this.updateSound();if(this.sound)this.beep(true);}
191    updateInviteSound(){$$('[data-action="invite-sound"]',this.root).forEach(b=>{b.textContent=this.inviteSound?'Ton an':'Ton aus';b.setAttribute('aria-pressed',String(this.inviteSound));b.classList.toggle('is-on',this.inviteSound);});}
192    toggleInviteSound(){this.inviteSound=!this.inviteSound;localStorage.setItem(INVITE_SOUND_KEY,this.inviteSound?'1':'0');this.updateInviteSound();if(this.inviteSound)this.beepInvite(true);}
193    updateBellReceive(){$$('[data-action="bell-receive"]',this.root).forEach(b=>{b.textContent=this.bellReceive?'Erlaubt':'Aus';b.setAttribute('aria-pressed',String(this.bellReceive));b.classList.toggle('is-on',this.bellReceive);});}
194    async toggleBellReceive(){const next=!this.bellReceive;try{const data=await this.api.call('/preferences',{method:'POST',body:JSON.stringify({bell_enabled:next})});this.bellReceive=Boolean(data.bell_enabled);localStorage.setItem(BELL_RECEIVE_KEY,this.bellReceive?'1':'0');this.updateBellReceive();this.renderUsers(this.userItems);this.updateRoomBell();this.toast(this.bellReceive?'Andere Nutzer können dich jetzt anklingeln.':'Anklingeln wurde deaktiviert.','success');}catch(err){this.toast(err.message,'error');}}
195    openInfoDialog(name){const d=this.root.querySelector(`[data-info-dialog="${name}"]`);if(!d)return;if(name==='settings'){this.updateSound();this.updateInviteSound();this.updateBellReceive();}typeof d.showModal==='function'?d.showModal():d.setAttribute('open','');}
196    beep(preview=false,mention=false){if(!this.sound)return;try{const AC=window.AudioContext||window.webkitAudioContext;if(!AC)return;if(!this.audio)this.audio=new AC();const n=this.audio.currentTime,o=this.audio.createOscillator(),g=this.audio.createGain();o.frequency.value=mention?1100:(preview?660:880);g.gain.setValueAtTime(.0001,n);g.gain.exponentialRampToValueAtTime(.09,n+.01);g.gain.exponentialRampToValueAtTime(.0001,n+.14);o.connect(g);g.connect(this.audio.destination);o.start(n);o.stop(n+.15);}catch(_){} }
197    beepInvite(preview=false){
198      if(!this.inviteSound)return;
199      try{
200        const AC=window.AudioContext||window.webkitAudioContext;
201        if(!AC)return;
202        if(!this.audio)this.audio=new AC();
203        if(this.audio.state==='suspended')this.audio.resume().catch(()=>{});
204        const n=this.audio.currentTime,master=this.audio.createGain();
205        master.gain.setValueAtTime(.0001,n);
206        master.gain.exponentialRampToValueAtTime(preview?.11:.16,n+.015);
207        master.gain.setValueAtTime(preview?.11:.16,n+.42);
208        master.gain.exponentialRampToValueAtTime(.0001,n+.62);
209        master.connect(this.audio.destination);
210        [[520,0,.12],[720,.16,.12],[1040,.32,.28]].forEach(([frequency,offset,duration],index)=>{
211          const o=this.audio.createOscillator(),voice=this.audio.createGain();
212          o.type=index===2?'square':'triangle';
213          o.frequency.setValueAtTime(frequency,n+offset);
214          if(index===2)o.frequency.exponentialRampToValueAtTime(1240,n+offset+duration);
215          voice.gain.setValueAtTime(.0001,n+offset);
216          voice.gain.exponentialRampToValueAtTime(index===2?.75:1,n+offset+.012);
217          voice.gain.exponentialRampToValueAtTime(.0001,n+offset+duration);
218          o.connect(voice);voice.connect(master);
219          o.start(n+offset);o.stop(n+offset+duration+.02);
220        });
221      }catch(_){}
222    }
223    toast(text,type='info'){const stack=this.root.querySelector('[data-toast-stack]'),el=document.createElement('div');el.className='chfc-toast is-'+type;el.textContent=text;stack.append(el);setTimeout(()=>el.remove(),4200);}
224    async tick(){
225      if(this.busy)return;this.busy=true;
226      try{
227        const after=this.current.id?(this.last.private[this.current.id]||0):this.last.public;
228        const data=await this.api.call(`/state?after=${after}&with=${encodeURIComponent(this.current.id)}`);
229        if(data.preferences&&typeof data.preferences.bell_enabled==='boolean'){this.bellReceive=data.preferences.bell_enabled;localStorage.setItem(BELL_RECEIVE_KEY,this.bellReceive?'1':'0');this.updateBellReceive();}
230        this.syncPrivateChats(data.private_chats||[]); this.renderUsers(data.users||[]); this.updateRoomBell(); this.updateClearAction(); this.renderTyping(data.typing||[]); this.renderClearMarker(data.cleared_at||''); this.renderMessages(data.messages||[]); this.processInvites(data.incoming_invites||[],data.accepted_invites||[]); this.processBells(data.bells||[]);
231        this.root.querySelector('[data-status]').textContent=`Angemeldet als ${this.session.nickname}`;this.error.textContent='';this.initial=false;
232      }catch(err){this.error.textContent=err.message;if(/ungültig|abgelaufen/i.test(err.message)){this.api.save(null);this.destroy();this.onLogout('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');}}
233      finally{this.busy=false;}
234    }
235    syncPrivateChats(items){const next=new Map();items.forEach(i=>{if(i.public_id&&i.nickname)next.set(i.public_id,i.nickname);});this.privateChats=next;if(this.current.id&&!this.privateChats.has(this.current.id))this.openPublic();else this.renderChatTabs();}
236    renderChatTabs(){
237      this.tabs.textContent='';
238      const make=(id,name,icon)=>{
239        const b=document.createElement('button');b.type='button';b.className='chfc-chat-tab'+(this.current.id===id?' is-active':'');
240        b.innerHTML=`<span>${icon}</span><span class="chfc-chat-tab-name"></span><span class="chfc-badge" hidden>0</span>`;
241        b.querySelector('.chfc-chat-tab-name').textContent=name;
242        const n=this.unread.get(id)||0,badge=b.querySelector('.chfc-badge');if(n){badge.hidden=false;badge.textContent=n;}
243        b.onclick=()=>id?this.openPrivate(id,name):this.openPublic();
244        if(id){const close=document.createElement('span');close.className='chfc-chat-tab-close';close.textContent='×';close.title='Privatchat beenden';close.setAttribute('role','button');close.setAttribute('aria-label',`Privatchat mit ${name} beenden`);close.tabIndex=0;const end=e=>{e.preventDefault();e.stopPropagation();this.closePrivate(id,name);};close.onclick=end;close.onkeydown=e=>{if(e.key==='Enter'||e.key===' '){end(e);}};b.append(close);}
245        this.tabs.append(b);
246      };
247      make('','Öffentlich','#');[...this.privateChats].sort((a,b)=>a[1].localeCompare(b[1],'de')).forEach(([id,name])=>make(id,name,'🔒'));
248    }
249    async closePrivate(id,name){
250      try{await this.api.call('/private/close',{method:'POST',body:JSON.stringify({with:id})});this.privateChats.delete(id);this.unread.delete(id);if(this.current.id===id)this.openPublic();else this.renderChatTabs();this.toast(`Privatchat mit ${name} beendet.`,'success');}
251      catch(err){this.toast(err.message,'error');}
252    }
253    renderPrivateList(){}
254    toggleFavorite(id){this.favorites.has(id)?this.favorites.delete(id):this.favorites.add(id);localStorage.setItem(FAVORITES_KEY,JSON.stringify([...this.favorites]));this.renderUsers(this.userItems);}
255    renderUsers(items){
256      this.userItems=items;this.users.textContent='';this.root.querySelector('[data-user-count]').textContent=items.length;
257      const filtered=items.filter(u=>!this.searchTerm||u.nickname.toLocaleLowerCase().includes(this.searchTerm)).sort((a,b)=>{
258        const ao=a.public_id===this.session.session_id,bo=b.public_id===this.session.session_id;if(ao!==bo)return ao?-1:1;
259        const af=this.favorites.has(a.public_id),bf=this.favorites.has(b.public_id);if(af!==bf)return af?-1:1;return a.nickname.localeCompare(b.nickname,'de');
260      });
261      filtered.forEach(u=>{const own=u.public_id===this.session.session_id,row=document.createElement('div');row.className='chfc-user'+(own?' is-own':'')+(this.favorites.has(u.public_id)?' is-favorite':'');
262        const main=document.createElement('div');main.className='chfc-user-main';main.innerHTML='<span class="chfc-status-dot"></span><span class="chfc-user-avatar"></span><span class="chfc-user-name"></span><small></small>';const ua=main.querySelector('.chfc-user-avatar');ua.textContent=(u.nickname||'?').slice(0,1).toUpperCase();if(u.avatar_url){const ui=document.createElement('img');ui.src=u.avatar_url;ui.alt='';ui.loading='lazy';ui.referrerPolicy='no-referrer';ui.onerror=()=>ui.remove();ua.append(ui);}main.querySelector('.chfc-user-name').textContent=u.nickname;const presence=main.querySelector('small');if(own){presence.textContent='du';presence.title='Du';presence.setAttribute('aria-label','Du');}else if(this.privateChats.has(u.public_id)){presence.textContent='🔒';presence.title='Privatchat aktiv';presence.setAttribute('aria-label','Privatchat aktiv');}else{presence.textContent='●';presence.classList.add('chfc-online-icon');presence.title='Online';presence.setAttribute('aria-label','Online');}
263        row.append(main);
264        if(!own){const actions=document.createElement('span');actions.className='chfc-user-actions';if(Number(u.bell_enabled)!==0){const bell=document.createElement('button');bell.type='button';bell.className='chfc-user-bell-button';bell.textContent='🔔';bell.title=`Bei ${u.nickname} klingeln`;bell.setAttribute('aria-label',bell.title);bell.onclick=()=>this.ringUser(u.public_id,u.nickname,bell);actions.append(bell);}if(this.config.privateChat){const priv=document.createElement('button');priv.type='button';priv.className='chfc-private-button';const active=this.privateChats.has(u.public_id);priv.textContent=active?'🔒':'💬';priv.title=active?`Privatchat mit ${u.nickname} öffnen`:`Privatchat mit ${u.nickname} anfragen`;priv.setAttribute('aria-label',priv.title);priv.onclick=()=>active?this.openPrivate(u.public_id,u.nickname):this.requestPrivate(u.public_id,u.nickname);actions.append(priv);}const fav=document.createElement('button');fav.type='button';fav.className='chfc-favorite-button';fav.textContent=this.favorites.has(u.public_id)?'★':'☆';fav.title=this.favorites.has(u.public_id)?'Aus Favoriten entfernen':'Zu Favoriten hinzufügen';fav.onclick=()=>this.toggleFavorite(u.public_id);actions.append(fav);row.append(actions);}this.users.append(row);
265      });
266      if(!filtered.length){const p=document.createElement('p');p.className='chfc-empty-private';p.textContent='Keine Benutzer gefunden.';this.users.append(p);}
267    }
268    applyMessageFilter(){
269      const q=this.messageFilter;let visible=0,total=0;
270      $$('.chfc-message',this.messages).forEach(row=>{total++;const match=!q||(row.dataset.searchText||'').includes(q);row.hidden=!match;if(match)visible++;});
271      this.messages.classList.toggle('is-filtering',Boolean(q));
272      this.messages.setAttribute('aria-label',q?`${visible} von ${total} Nachrichten entsprechen dem Filter`:'Chatnachrichten');
273    }
274    resetMessageFilter(){this.messageFilter='';this.messageFilterInput.value='';this.resetFilterButton.disabled=true;this.applyMessageFilter();this.messageFilterInput.focus();}
275    isNearBottom(){return this.messages.scrollHeight-this.messages.scrollTop-this.messages.clientHeight<110;}
276    clearNewMessageNotice(){this.pendingNew=0;this.newMessagesButton.hidden=true;this.newMessagesButton.textContent='↓ Neue Nachrichten';}
277    showNewMessageNotice(count){this.pendingNew+=count;this.newMessagesButton.textContent=`↓ ${this.pendingNew} neue Nachricht${this.pendingNew===1?'':'en'}`;this.newMessagesButton.hidden=false;}
278    jumpToLatest(){this.messages.scrollTo({top:this.messages.scrollHeight,behavior:'smooth'});this.clearNewMessageNotice();}
279
280    appendHighlightedText(container,text){
281      const nick=this.session.nickname.replace(/[.*+?^${}
281()|[\]\\]/g,'\\$&'),re=new RegExp(`(@${nick})(?=\\s|[.,!?;:]|$)`,'giu');let last=0,match;
282      while((match=re.exec(text))){container.append(document.createTextNode(text.slice(last,match.index)));const mark=document.createElement('mark');mark.className='chfc-mention';mark.textContent=match[0];container.append(mark);last=re.lastIndex;}container.append(document.createTextNode(text.slice(last)));
283    }
284    updateClearAction(){
285      const button=this.root.querySelector('[data-action="clear-for-me"]');
286      if(!button)return;
287      const allowed=this.current.id?Boolean(this.config.userClearPrivate):Boolean(this.config.userClearPublic);
288      button.hidden=!allowed;
289      const label=button.querySelector('[data-clear-label]');
290      if(label)label.textContent=this.current.id?'Privatchat für mich leeren':'Öffentlichen Chat für mich leeren';
291    }
292    renderClearMarker(value){
293      const existing=this.messages.querySelector('[data-clear-marker]');
294      if(!value){existing?.remove();return;}
295      if(existing&&existing.dataset.value===value)return;
296      existing?.remove();
297      const marker=document.createElement('div');
298      marker.className='chfc-clear-marker';marker.dataset.clearMarker='1';marker.dataset.value=value;
299      const date=new Date(String(value).replace(' ','T')+'Z');
300      marker.textContent=`Du hast diesen Chatverlauf am ${date.toLocaleDateString()} um ${date.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})} für dich geleert.`;
301      this.messages.prepend(marker);
302    }
303    async clearForMe(){
304      const privateRoom=Boolean(this.current.id);
305      const title=privateRoom?'Diesen Privatchat':'Den öffentlichen Chat';
306      if(!window.confirm(`${title} nur für dich leeren?
307
308Andere Teilnehmer behalten ihren Verlauf. Die Nachrichten werden nicht vom Server gelöscht.`))return;
309      const button=this.root.querySelector('[data-action="clear-for-me"]');
310      if(button)button.disabled=true;
311      try{
312        const data=await this.api.call('/history/clear',{method:'POST',body:JSON.stringify({with:this.current.id||''})});
313        if(privateRoom)this.last.private[this.current.id]=0;else this.last.public=0;
314        this.messages.textContent='';this.lastRendered=null;this.renderedMessageIds.clear();this.clearNewMessageNotice();
315        this.renderClearMarker(data.cleared_at||'');
316        this.toast('Der Chatverlauf wurde nur für dich geleert.','success');
317        await this.tick();
318      }catch(err){this.toast(err.message,'error');}
319      finally{if(button)button.disabled=false;}
320    }
321    renderMessages(items){
322      const nearBottom=this.isNearBottom();let incoming=false,mentioned=false;
323      items.forEach(m=>{
324        const messageId=Number(m.id)||0;
325        if(messageId&&this.renderedMessageIds.has(messageId))return;
326        if(messageId)this.renderedMessageIds.add(messageId);
327        const own=m.sender_public_id===this.session.session_id;if(!own)incoming=true;
328        const isMention=!own&&new RegExp(`@${this.session.nickname.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')}(?=\\s|[.,!?;:]|$)`,'iu').test(m.message||'');if(isMention)mentioned=true;
329        const timeValue=new Date(String(m.created_at).replace(' ','T')+'Z');
330        const grouped=this.lastRendered&&this.lastRendered.sender===m.sender_public_id&&(timeValue-this.lastRendered.time)<300000;
331        const row=document.createElement('article');row.className='chfc-message'+(own?' is-own':'')+(isMention?' is-mentioned':'')+(grouped?' is-grouped':'');row.dataset.searchText=`${m.sender_nickname||''} ${m.message||''} ${m.attachment_name||''}`.toLocaleLowerCase();
332        const avatar=this.makeAvatar(m.sender_avatar_url||'',m.sender_nickname||'?');
333        const bubble=document.createElement('div');bubble.className='chfc-bubble';const meta=document.createElement('div');meta.className='chfc-meta';const name=document.createElement('strong');name.textContent=own?'Du':m.sender_nickname;const time=document.createElement('time');time.textContent=timeValue.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'});meta.append(name,time);bubble.append(meta);
334        if(m.attachment_url){const link=document.createElement('a');link.className='chfc-image-link';link.href=m.attachment_url;link.target='_blank';link.rel='noopener noreferrer';const img=document.createElement('img');img.className='chfc-chat-image';img.src=m.attachment_url;img.alt=m.attachment_name||'Geteiltes Bild';img.loading='lazy';link.append(img);bubble.append(link);}
335        if(m.message){const body=document.createElement('div');body.className='chfc-text';this.appendHighlightedText(body,m.message);bubble.append(body);}
336        row.append(avatar,bubble);this.messages.append(row);this.lastRendered={sender:m.sender_public_id,time:timeValue};
337        if(this.current.id)this.last.private[this.current.id]=Math.max(this.last.private[this.current.id]||0,Number(m.id));else this.last.public=Math.max(this.last.public,Number(m.id));
338      });
339      if(items.length)this.applyMessageFilter();
340      if(items.length&&(nearBottom||this.initial)){this.messages.scrollTop=this.messages.scrollHeight;this.clearNewMessageNotice();}
341      else if(items.length)this.showNewMessageNotice(items.length);
342      if(incoming&&!this.initial){this.beep(false,mentioned);document.title=mentioned?`@ Erwähnung – ${this.config.brandText||'CH WP Chat'}`:`● Neue Nachricht – ${this.config.brandText||'CH WP Chat'}`;setTimeout(()=>document.title=this.config.brandText||'CH WP Chat',2500);}
343    }
344    handleClipboardPaste(event){
345      const clipboard=event.clipboardData;if(!clipboard)return;
346      let file=null;
347      if(clipboard.items){for(const item of clipboard.items){if(item.kind==='file'&&/^image\//i.test(item.type||'')){file=item.getAsFile();break;}}}
348      if(!file&&clipboard.files){file=[...clipboard.files].find(f=>/^image\//i.test(f.type||''))||null;}
349      if(!file)return;
350      event.preventDefault();
351      const extension=({ 'image/jpeg':'jpg','image/png':'png','image/gif':'gif','image/webp':'webp' })[file.type]||'png';
352      if(!file.name||file.name==='image.png')file=new File([file],`screenshot-${new Date().toISOString().replace(/[:.]/g,'-')}.${extension}`,{type:file.type||'image/png'});
353      this.uploadImageFile(file,null,true);
354    }
355    async uploadImage(input){
356      const file=input.files&&input.files[0];if(!file)return;
357      await this.uploadImageFile(file,input,false);
358    }
359    async uploadImageFile(file,input=null,fromClipboard=false){
360      if(!/^image\/(jpeg|png|gif|webp)$/i.test(file.type)){this.toast('Erlaubt sind JPG, PNG, GIF und WEBP.','error');if(input)input.value='';return;}
361      const max=Math.max(1,Number(this.config.maxImageMb||5))*1024*1024;if(file.size>max){this.toast(`Das Bild darf höchstens ${this.config.maxImageMb||5} MB groß sein.`,'error');if(input)input.value='';return;}
362      const data=new FormData();data.append('image',file,file.name||'screenshot.png');data.append('receiver_id',this.current.id||'');data.append('caption',this.textarea.value.trim());
363      const choose=this.root.querySelector('[data-action="choose-image"]');if(choose)choose.disabled=true;this.textarea.disabled=true;
364      try{await this.api.call('/upload',{method:'POST',body:data});this.textarea.value='';this.updateChars();await this.tick();this.toast(fromClipboard?'Screenshot aus der Zwischenablage gesendet.':'Bild gesendet.','success');}
365      catch(err){this.toast(err.message,'error');}finally{if(choose)choose.disabled=false;this.textarea.disabled=false;if(input)input.value='';this.textarea.focus();}
366    }
367    async ringUser(id,name,button=null){
368      if(!id)return;
369      if(button)button.disabled=true;
370      try{
371        const data=await this.api.call('/bell',{method:'POST',body:JSON.stringify({receiver_id:id})});
372        this.toast(`Bei ${data.receiver_nickname||name} wurde geklingelt.`,'success');
373      }catch(err){this.toast(err.message,'error');}
374      finally{if(button)setTimeout(()=>{button.disabled=false;},800);}
375    }
376    ringCurrentUser(){if(this.current.id)this.ringUser(this.current.id,this.current.name,this.root.querySelector('[data-action="ring-user"]'));}
377    updateRoomBell(){const bell=this.root.querySelector('[data-action="ring-user"]');if(!bell)return;if(!this.current.id){bell.hidden=true;return;}const user=this.userItems.find(u=>u.public_id===this.current.id);const enabled=!user||Number(user.bell_enabled)!==0;bell.hidden=!enabled;if(enabled){bell.title=`Bei ${this.current.name} klingeln`;bell.setAttribute('aria-label',bell.title);}}
378    processBells(items){
379      if(!this.bellReceive)return;
380      items.forEach(item=>{
381        this.beepBell();
382        this.toast(`${item.sender_nickname||'Ein Benutzer'} klingelt bei dir. 🔔`,'success');
383      });
384    }
385    customRingtone(){
386      const url=String(this.config.ringtoneUrl||'').trim();
387      if(!url)return null;
388      if(!this.ringtoneAudio||this.ringtoneAudio.src!==new URL(url,location.href).href){
389        this.ringtoneAudio=new Audio(url);
390        this.ringtoneAudio.preload='auto';
391        this.ringtoneAudio.volume=1;
392      }
393      return this.ringtoneAudio;
394    }
395    unlockCustomRingtone(){
396      const audio=this.customRingtone();
397      if(!audio||this.ringtoneUnlocked)return;
398      const oldMuted=audio.muted; audio.muted=true;
399      const promise=audio.play();
400      if(promise&&promise.then){promise.then(()=>{audio.pause();audio.currentTime=0;audio.muted=oldMuted;this.ringtoneUnlocked=true;}).catch(()=>{audio.muted=oldMuted;});}
401    }
402    beepBell(){
403      const custom=this.customRingtone();
404      if(custom){
405        try{
406          custom.pause();custom.currentTime=0;custom.muted=false;
407          const play=custom.play();
408          if(play&&play.catch)play.catch(()=>this.beepBellFallback());
409          return;
410        }catch(_){this.beepBellFallback();return;}
411      }
412      this.beepBellFallback();
413    }
414    beepBellFallback(){
415      try{
416        const AC=window.AudioContext||window.webkitAudioContext;if(!AC)return;
417        if(!this.audio)this.audio=new AC();
418        if(this.audio.state==='suspended')this.audio.resume().catch(()=>{});
419
420        const start=this.audio.currentTime+.03;
421        const ringDuration=.62;
422        const ringGap=.34;
423        const ringsPerSequence=3;
424        const sequencePause=.85;
425        const sequenceDuration=(ringsPerSequence-1)*(ringDuration+ringGap)+ringDuration;
426        const sequenceCount=4;
427        const totalDuration=sequenceCount*sequenceDuration+(sequenceCount-1)*sequencePause;
428
429        const master=this.audio.createGain();
430        master.gain.setValueAtTime(.0001,start);
431        master.gain.linearRampToValueAtTime(.24,start+.025);
432        master.gain.setValueAtTime(.24,start+totalDuration-.18);
433        master.gain.exponentialRampToValueAtTime(.0001,start+totalDuration);
434        master.connect(this.audio.destination);
435
436        for(let sequence=0;sequence<sequenceCount;sequence++){
437          const sequenceStart=start+sequence*(sequenceDuration+sequencePause);
438          for(let repeat=0;repeat<ringsPerSequence;repeat++){
439            const ringStart=sequenceStart+repeat*(ringDuration+ringGap);
440            const ringGain=this.audio.createGain();
441            ringGain.gain.setValueAtTime(.0001,ringStart);
442            ringGain.gain.linearRampToValueAtTime(1,ringStart+.018);
443            ringGain.gain.setValueAtTime(1,ringStart+ringDuration-.07);
444            ringGain.gain.exponentialRampToValueAtTime(.0001,ringStart+ringDuration);
445            ringGain.connect(master);
446
447            const tremolo=this.audio.createOscillator();
448            const tremoloDepth=this.audio.createGain();
449            tremolo.type='sine';
450            tremolo.frequency.setValueAtTime(20,ringStart);
451            tremoloDepth.gain.setValueAtTime(.38,ringStart);
452            tremolo.connect(tremoloDepth);
453            tremoloDepth.connect(ringGain.gain);
454            tremolo.start(ringStart);
455            tremolo.stop(ringStart+ringDuration+.02);
456
457            [440,480].forEach((frequency,index)=>{
458              const oscillator=this.audio.createOscillator();
459              const voice=this.audio.createGain();
460              oscillator.type=index===0?'triangle':'sine';
461              oscillator.frequency.setValueAtTime(frequency,ringStart);
462              voice.gain.setValueAtTime(index===0?.58:.46,ringStart);
463              oscillator.connect(voice);voice.connect(ringGain);
464              oscillator.start(ringStart);oscillator.stop(ringStart+ringDuration+.02);
465            });
466
467            const overtone=this.audio.createOscillator();
468            const overtoneGain=this.audio.createGain();
469            overtone.type='square';
470            overtone.frequency.setValueAtTime(960,ringStart);
471            overtoneGain.gain.setValueAtTime(.10,ringStart);
472            overtoneGain.gain.exponentialRampToValueAtTime(.0001,ringStart+ringDuration*.8);
473            overtone.connect(overtoneGain);overtoneGain.connect(ringGain);
474            overtone.start(ringStart);overtone.stop(ringStart+ringDuration+.02);
475          }
476        }
477      }catch(_){}
478    }
479    async requestPrivate(id,name){try{await this.api.call('/invite',{method:'POST',body:JSON.stringify({receiver_id:id})});this.toast(`Anfrage an ${name} gesendet.`,'success');}catch(err){this.toast(err.message,'error');}}
480    processInvites(incoming,accepted){if(!this.currentInvite){const next=incoming.find(i=>!this.seenInvites.has(Number(i.id)));if(next){this.seenInvites.add(Number(next.id));this.currentInvite=next;this.showInvite(next);this.beepInvite();}}accepted.forEach(i=>{const id=Number(i.id);if(this.acceptedSeen.has(id))return;this.acceptedSeen.add(id);this.privateChats.set(i.receiver_public_id,i.receiver_nickname||'Privatchat');this.renderPrivateList();this.toast(`${i.receiver_nickname||'Der Nutzer'} hat deine Einladung angenommen.`,'success');this.openPrivate(i.receiver_public_id,i.receiver_nickname||'Privatchat');});}
481    showInvite(i){const d=this.root.querySelector('[data-invite-dialog]');this.root.querySelector('[data-invite-name]').textContent=i.sender_nickname;typeof d.showModal==='function'?d.showModal():d.setAttribute('open','');}
482    async respondInvite(decision){if(!this.currentInvite)return;const i=this.currentInvite,d=this.root.querySelector('[data-invite-dialog]');
482$$('button',d).forEach(b=>b.disabled=true);try{const data=await this.api.call('/invite/respond',{method:'POST',body:JSON.stringify({invite_id:i.id,decision})});d.close?.();this.currentInvite=null;if(decision==='accepted'){this.privateChats.set(data.sender_id,data.sender_nickname);this.openPrivate(data.sender_id,data.sender_nickname);this.toast('Privatchat geöffnet.','success');}else this.toast('Einladung abgelehnt.');}catch(err){this.toast(err.message,'error');}finally{$$('button',d).forEach(b=>b.disabled=false);}}
483    async send(e){
484      e.preventDefault();
485      if(this.sending)return;
486      const message=this.textarea.value.trim();
487      if(!message)return;
488      this.sending=true;
489      const submit=this.form.querySelector('[type="submit"]');
490      submit.disabled=true;
491      this.setTyping(false);
492      const clientMessageId=(window.crypto&&typeof window.crypto.randomUUID==='function')?window.crypto.randomUUID():`${Date.now()}-${Math.random().toString(36).slice(2)}`;
493      try{
494        await this.api.call('/messages',{method:'POST',body:JSON.stringify({message,receiver_id:this.current.id||'',client_message_id:clientMessageId})});
495        if(this.textarea.value.trim()===message)this.textarea.value='';
496        this.updateChars();this.closeMentionMenu();await this.tick();this.textarea.focus();
497      }catch(err){this.toast(err.message,'error');}
498      finally{this.sending=false;submit.disabled=false;}
499    }
500    openPrivate(id,name){this.setTyping(false);this.resetMessageFilter();this.current={id,name};this.messages.textContent='';this.lastRendered=null;this.renderedMessageIds.clear();this.clearNewMessageNotice();this.unread.set(id,0);this.root.querySelector('[data-room-title]').textContent=name;this.updateRoomBell();this.root.querySelector('[data-room-subtitle]').textContent='Privates Gespräch – nur für euch beide sichtbar.';this.updateClearAction();this.root.querySelector('[data-action="public"]').classList.remove('is-active');this.renderPrivateList();this.renderChatTabs();this.tick();}
501    openPublic(){this.setTyping(false);this.resetMessageFilter();this.current={id:'',name:'Öffentlicher Chat'};this.messages.textContent='';this.lastRendered=null;this.renderedMessageIds.clear();this.clearNewMessageNotice();this.root.querySelector('[data-room-title]').textContent='Öffentlicher Chat';const bell=this.root.querySelector('[data-action="ring-user"]');if(bell)bell.hidden=true;this.root.querySelector('[data-room-subtitle]').textContent='Nachrichten sind für alle anwesenden Nutzer sichtbar.';this.updateClearAction();this.root.querySelector('[data-action="public"]').classList.add('is-active');this.renderPrivateList();this.renderChatTabs();this.tick();}
502    async leave(){const b=this.root.querySelector('[data-action="leave"]');b.disabled=true;try{await this.api.call('/leave',{method:'POST',body:'{}'});}catch(_){}this.api.save(null);this.destroy();this.onLogout('Du wurdest abgemeldet.');}
503  }
504
505  const init=()=>$$('[data-chfc-window]:not([data-chfc-ready])').forEach(el=>{el.dataset.chfcReady='1';new App(el);});
506  document.readyState==='loading'?document.addEventListener('DOMContentLoaded',init,{once:true}):init();
507})();
508/* Ende CH WP Chat by Conny Henn - https://blog.hennweb.de/ch-wp-chat/ */

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.