/* ===================================================================== chat.js — Gespräche im Workspace. Wunsch Filipe, 06.09.2026: *"jeder kann dann eine nachricht reinschreiben, die andere person bekommt eine benachrichtigung und kann dann antworten."* --------------------------------------------------------------------- DREI ENTSCHEIDUNGEN, die den Rest erklären 1. NEUE NACHRICHTEN KOMMEN VON SELBST (EventSource / SSE). Kein Nachfragen im Takt: Das wäre fünf Sekunden langsam und stellte bei zehn Angemeldeten 7200 Anfragen in der Stunde für Nachrichten, die es meistens nicht gibt. Reisst die Verbindung ab, verbindet der Browser sich von selbst neu -- und wir holen die Lücke über den normalen Verlauf nach. 2. DIE EIGENE NACHRICHT STEHT SOFORT DA, bevor der Server antwortet. Ein Chat, in dem man nach dem Abschicken eine halbe Sekunde ins Leere sieht, fühlt sich kaputt an. Kommt der Server nicht mit, wird sie sichtbar als "nicht angekommen" markiert -- nicht still entfernt. 3. GELESEN WIRD GEMELDET, wenn man wirklich hinsieht. Nicht schon beim Laden der Seite: Wer die Seite im Hintergrund offen hat, hat nichts gelesen, und eine Zahl, die von selbst verschwindet, ist schlimmer als keine. ===================================================================== */ (() => { 'use strict'; const $ = (id) => document.getElementById(id); const el = (tag, klasse, text) => { const k = document.createElement(tag); if (klasse) k.className = klasse; if (text !== undefined) k.textContent = text; return k; }; /* Aus der einen Quelle. Stand hier als eigenes Objekt und hatte 'spicy' nicht -- siehe bereiche.js. */ const ROLLENKURZ = window.Bereiche.ROLLEN_NAME; let ich = null; let raeume = []; let offen = null; // der gerade angesehene Raum let nachrichten = []; let strom = null; // die EventSource let gewaehlt = new Set(); // im Neu-Dialog /* ---------- Kleinkram ---------------------------------------------------- */ async function hole(weg, einst) { const a = await fetch(weg, { credentials: 'same-origin', ...einst }); if (a.status === 401) { location.assign('/workspace/'); throw new Error('weg'); } return a; } /** Ein bis zwei Buchstaben für den Kreis. Dieselbe Regel wie bei der * Teilnehmerwahl im Kalender -- Anfang UND Ende, weil "Patrick von * Hohenstein" sonst zu "PV" würde. */ function initialen(name) { const teile = String(name || '').trim().split(/\s+/).filter(Boolean); if (!teile.length) return '?'; const erst = [...teile[0]][0] || ''; const letzt = teile.length > 1 ? ([...teile[teile.length - 1]][0] || '') : ''; return (erst + letzt).toLocaleUpperCase('de-DE'); } /** "14:32" heute, "Gestern", sonst "3. Sep". * * ORTSZEIT, nicht UTC: Die Zeitstempel kommen als ISO-Text vom * Server. `new Date(text)` liest sie richtig, `slice()` darauf wäre * UTC und läge nachts einen Tag daneben. */ function wann(iso, lang = false) { const d = new Date(iso); if (Number.isNaN(d.getTime())) return ''; const uhr = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); if (lang) return `${d.toLocaleDateString('de-DE', { day: 'numeric', month: 'long' })}, ${uhr}`; const heute = new Date(); const gleich = (a, b) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); if (gleich(d, heute)) return uhr; const gestern = new Date(heute.getTime() - 86400000); if (gleich(d, gestern)) return 'Gestern'; return d.toLocaleDateString('de-DE', { day: 'numeric', month: 'short' }); } /* Dieselbe Zeile trägt zwei Sorten Nachricht: eine Störung (rot) und einen Zwischenstand wie "wird geschickt …" (ruhig). Beides rot zu zeigen hiesse, dass man Rot irgendwann nicht mehr ernst nimmt -- und Rot ist hier das Einzige, was Aufmerksamkeit verlangen darf. Die Farbe entscheidet das CSS über data-art, nicht diese Zeile. */ const melde = (t, art = 'fehler') => { const z = $('fehler'); z.textContent = t || ''; z.dataset.art = t ? art : 'fehler'; }; /* ---------- Die Gesprächsliste ------------------------------------------- */ async function raeumeLaden() { try { const a = await hole('/workspace/api/chat/raeume'); if (!a.ok) return; raeume = (await a.json()).raeume || []; } catch { return; } raeumeZeichnen(); ungelesenMelden(); } function raeumeZeichnen() { const ziel = $('raeume'); const suchtext = ($('suche').value || '').trim().toLowerCase(); const zeigen = suchtext ? raeume.filter((r) => r.name.toLowerCase().includes(suchtext)) : raeume; ziel.textContent = ''; $('liste-leer').hidden = raeume.length > 0; /* Das Suchfeld erst, wenn die Liste lang genug ist, um darin zu suchen. Vorher ist es ein Feld ohne Zweck. */ /* IMMER SICHTBAR SEIT DEM 09.09.2026. Hier stand `raeume.length < 8` mit der Begruendung, ein Suchfeld ueber drei Eintraegen sei Zierde. Das stimmte, solange es nur NAMEN durchsuchte. Seit es auch die Nachrichten durchsucht, ist es das Gegenteil: Zwei Gespraeche koennen tausend Nachrichten haben, und genau dann braucht man es am dringendsten. */ $('suche-zeile').hidden = false; for (const r of zeigen) { const li = el('li', 'chat-raum'); li.dataset.id = String(r.id); if (offen && r.id === offen.id) li.dataset.offen = 'ja'; if (r.ungelesen) li.dataset.neu = 'ja'; const knopf = el('button', 'chat-raum__knopf'); knopf.type = 'button'; const zeichen = el('span', 'chat-raum__zeichen'); zeichen.setAttribute('aria-hidden', 'true'); if (r.art === 'gruppe') { zeichen.dataset.gruppe = 'ja'; zeichen.textContent = String(r.teilnehmer.length); } else { zeichen.textContent = initialen(r.name); const gegen = r.teilnehmer.find((t) => t.id !== ich.id); if (gegen) { zeichen.dataset.rolle = gegen.rolle; if (gegen.bild) { const b = document.createElement('img'); b.src = gegen.bild; b.alt = ''; b.addEventListener('error', () => b.remove()); zeichen.append(b); } } } knopf.append(zeichen); const mitte = el('span', 'chat-raum__mitte'); const oben = el('span', 'chat-raum__oben'); oben.append(el('span', 'chat-raum__name', r.name)); if (r.letzte) oben.append(el('span', 'chat-raum__zeit', wann(r.letzte.erstellt))); mitte.append(oben); const vorschau = el('span', 'chat-raum__vorschau'); if (r.letzte) { const wer = r.letzte.von === ich.name ? 'Du: ' : (r.art === 'gruppe' ? `${r.letzte.von}: ` : ''); vorschau.textContent = r.letzte.zurueckgenommen ? `${wer}Nachricht zurückgenommen` : wer + r.letzte.text; if (r.letzte.zurueckgenommen) vorschau.dataset.weg = 'ja'; } else { vorschau.textContent = 'Noch nichts geschrieben'; vorschau.dataset.weg = 'ja'; } mitte.append(vorschau); knopf.append(mitte); if (r.ungelesen) { const zahl = el('span', 'chat-raum__zahl', String(r.ungelesen)); zahl.setAttribute('aria-label', `${r.ungelesen} ungelesen`); knopf.append(zahl); } knopf.addEventListener('click', () => raumOeffnen(r.id)); li.append(knopf); ziel.append(li); } if (suchtext && !zeigen.length) { const li = el('li', 'chat__leer', 'Kein Gespräch passt dazu.'); ziel.append(li); } } /* Bis wohin war beim Oeffnen gelesen -- daraus entsteht die Linie "ab hier ist es neu". Und worauf gerade geantwortet wird. */ let gelesenBeimOeffnen = 0; let antwortAuf = null; /* ---------- In Nachrichten suchen (09.09.2026, Punkt 15) ----------------- */ let suchLauf = 0; async function nachrichtenSuchen() { const frage = ($('suche').value || '').trim(); const kasten = $('funde'); const liste = $('funde-liste'); if (!kasten) return; if (frage.length < 2) { kasten.hidden = true; liste.textContent = ''; return; } /* JEDE ANFRAGE BEKOMMT EINE NUMMER, und nur die neueste darf zeichnen. Beim Tippen laufen mehrere gleichzeitig; ohne das koennte die Antwort auf "Pa" nach der auf "Patrick" ankommen und die richtige Liste ueberschreiben. Das sieht dann aus, als fuende die Suche das Falsche. */ const meine = ++suchLauf; let daten; try { const a = await hole(`/workspace/api/chat/suche?q=${encodeURIComponent(frage)}`); if (!a.ok) return; daten = await a.json(); } catch { return; } if (meine !== suchLauf) return; liste.textContent = ''; kasten.hidden = false; $('funde-schild').textContent = daten.treffer.length ? `In Nachrichten · ${daten.treffer.length}` : 'In Nachrichten'; if (!daten.treffer.length) { /* Ein leeres Ergebnis wird GESAGT. Ein Kasten, der einfach leer bleibt, sieht aus, als laufe die Suche noch. */ liste.append(el('li', 'chat-funde__leer', 'Nichts gefunden.')); return; } for (const t of daten.treffer) { const li = el('li'); const b = el('button', 'chat-fund'); b.type = 'button'; const kopf = el('span', 'chat-fund__kopf'); kopf.append(el('span', 'chat-fund__raum', t.raum)); kopf.append(el('span', 'chat-fund__wann', wann(t.erstellt))); b.append(kopf); b.append(el('span', 'chat-fund__von', t.von)); b.append(el('span', 'chat-fund__text', t.text)); b.addEventListener('click', async () => { await raumOeffnen(t.raum_id); /* Erst oeffnen, dann springen -- vorher gibt es die Zeile nicht. Steht sie nicht im geladenen Ausschnitt, sagt `zurStelle` das. */ zurStelle(t.id); }); li.append(b); liste.append(li); } } /* ---------- Ein Gespräch öffnen ------------------------------------------ */ async function raumOeffnen(id, { sanft = false } = {}) { try { const a = await hole(`/workspace/api/chat/raeume/${id}/nachrichten`); if (!a.ok) { melde('Dieses Gespräch gibt es nicht mehr.'); return; } const daten = await a.json(); offen = daten.raum; nachrichten = daten.nachrichten; /* DER STAND VON VORHIN, nicht der von jetzt. `gelesenMelden()` weiter unten setzt ihn gleich auf die letzte Nachricht -- die Linie muss also aus dem Wert gezogen werden, der beim OEFFNEN galt. Danach wuerde sie nie irgendwo stehen. */ gelesenBeimOeffnen = daten.gelesen_bis ?? 0; antwortAuf = null; } catch { return; } $('verlauf-titel').textContent = offen.name; $('verlauf-unter').textContent = offen.art === 'gruppe' ? offen.teilnehmer.map((t) => t.name).join(' · ') : (ROLLENKURZ[offen.teilnehmer.find((t) => t.id !== ich.id)?.rolle] || ''); $('schreiben').hidden = false; $('verlauf-leer').hidden = true; $('chat').dataset.ansicht = 'verlauf'; /* Erst jetzt gibt es etwas zum Wegräumen. */ $('gespraech-weg').hidden = false; /* Ein frisch geoeffnetes Gespraech steht immer am Ende -- man will das Letzte sehen, nicht das Erste. */ unten = true; neuUnten = 0; verlaufZeichnen(); /* Der Entwurf von vorhin, falls einer da ist. */ $('text').value = entwurfHolen(id); hoeheAnpassen(); zaehlerZeigen(); if (!sanft) $('text').focus(); /* Die Adresszeile merkt sich das Gespräch -- so führt die Benachrichtigung direkt hierher, und ein Neuladen bleibt am richtigen Ort. */ history.replaceState(null, '', `chat.html?raum=${id}`); gelesenMelden(); raeumeZeichnen(); } function verlaufZeichnen() { const ziel = $('verlauf'); /* NUR DIE NACHRICHTEN WEG — NICHT DER GANZE KASTEN. ------------------------------------------------------------------ Hier stand `ziel.textContent = ''`. Das loeschte auch `#verlauf-leer`, den Absatz "Links ein Gespraech auswaehlen", denn der liegt IN diesem Kasten. Nach dem ersten Zeichnen gab es ihn also nicht mehr — und `raumOeffnen` faellt sechs Zeilen weiter genau darueber: $('verlauf-leer').hidden = true; -> null Folge, und die ist die eigentliche: `raumOeffnen` bricht an dieser Stelle ab. Damit laufen `history.replaceState`, `gelesenMelden()` und `raeumeZeichnen()` beim ZWEITEN Aufruf nie. Der zweite Aufruf ist aber der Normalfall — der Ereignisstrom ruft nach jedem Verbindungsaufbau genau das (`{ sanft: true }`), um nachzuholen, was waehrend der Pause geschrieben wurde. Dieses Nachholen hat nie funktioniert; der Absturz stand still in der Konsole. Gefunden hat das nicht das Auge, sondern der Lauscher auf `pageerror` in pruef-chat-ausbau.mjs. Ohne ihn waere es geblieben: Die Seite sieht danach voellig normal aus. */ for (const alt of [...ziel.children]) if (alt.id !== 'verlauf-leer') alt.remove(); if (!nachrichten.length) { ziel.append(el('p', 'chat__leer', 'Noch nichts geschrieben. Fang an.')); return; } let letzterTag = ''; let letzterVon = null; let linieGesetzt = false; for (const n of nachrichten) { /* DIE LINIE "AB HIER IST ES NEU" (09.09.2026, Punkt 15). Sie steht vor der ersten Nachricht, die beim Oeffnen noch nicht gelesen war -- und nur EINMAL. Wer nach zwei Tagen zurueckkommt, sucht sonst die Stelle, an der er aufgehoert hat, indem er Uhrzeiten liest. Nicht bei eigenen Nachrichten: Was man selbst geschrieben hat, ist nie "neu fuer einen". */ if (!linieGesetzt && gelesenBeimOeffnen && n.id > gelesenBeimOeffnen && !n.selbst) { linieGesetzt = true; const linie = el('div', 'chat-neu'); linie.append(el('span', 'chat-neu__text', 'Ab hier neu')); ziel.append(linie); } /* Ein Datumstrenner, wenn ein neuer Tag beginnt. Ohne ihn steht die Antwort von vorgestern direkt unter der von heute. */ const tag = new Date(n.erstellt).toDateString(); if (tag !== letzterTag) { letzterTag = tag; letzterVon = null; const trenner = el('div', 'chat-tag'); trenner.append(el('span', 'chat-tag__text', tagName(n.erstellt))); ziel.append(trenner); } const zeile = el('article', 'chat-nachricht'); zeile.dataset.id = String(n.id); if (n.selbst) zeile.dataset.selbst = 'ja'; if (n.zurueckgenommen) zeile.dataset.weg = 'ja'; /* Mehrere Nachrichten derselben Person hintereinander rücken zusammen und wiederholen den Namen nicht -- sonst liest man fünfmal "Patrick" und dreimal dasselbe Gesicht. */ const gleicheFolge = letzterVon === n.von_id; if (gleicheFolge) zeile.dataset.folge = 'ja'; letzterVon = n.von_id; if (!gleicheFolge && !n.selbst) { /* DAS GESICHT STATT DER INITIALEN (07.09.2026). Der Anfangsbuchstabe bleibt als Unterlage LIEGEN und wird nicht ersetzt: Laedt das Bild nicht, steht dort weiterhin etwas Sinnvolles statt eines kaputten Bildsymbols. Dieselbe Bauweise wie in der Kopfleiste und in der Personenauswahl -- drei Stellen, ein Verhalten. */ const z = el('span', 'chat-nachricht__zeichen', initialen(n.von)); z.setAttribute('aria-hidden', 'true'); z.dataset.rolle = n.rolle || ''; if (n.von_bild) { const b = document.createElement('img'); b.src = n.von_bild; b.alt = ''; b.addEventListener('error', () => b.remove()); z.append(b); } zeile.append(z); } const blase = el('div', 'chat-nachricht__blase'); if (!gleicheFolge && !n.selbst && offen.art === 'gruppe') { blase.append(el('p', 'chat-nachricht__von', n.von)); } /* DAS ZITAT STEHT IN DER BLASE, NICHT DARUEBER. So wandert es beim Rollen mit und gehoert sichtbar zur Antwort -- eine Zeile darueber liest sich als eigene Nachricht. */ if (n.antwort) { const z = el('button', 'chat-zitat'); z.type = 'button'; z.append(el('span', 'chat-zitat__von', n.antwort.von)); z.append(el('span', 'chat-zitat__text', n.antwort.zurueckgenommen ? 'Nachricht zurückgenommen' : n.antwort.text)); z.title = 'Zur zitierten Nachricht springen'; /* Ein Zitat, das nicht zur Stelle fuehrt, ist ein Bild von einer Nachricht. Der Klick springt hin und hebt sie kurz hervor. */ z.addEventListener('click', () => zurStelle(n.antwort.id)); blase.append(z); } if (n.zurueckgenommen) { blase.append(el('p', 'chat-nachricht__text chat-nachricht__weg', 'Nachricht zurückgenommen')); } else { /* DER ANHANG STEHT ÜBER DEM TEXT. Ein Begleitsatz gehört unter das Bild, nicht darüber -- man sieht zuerst, worum es geht. */ if (n.anhang) blase.append(anhangBauen(n.anhang)); /* textContent, nie innerHTML: Eine Nachricht darf keine Auszeichnung einschleusen. Zeilenumbrüche macht das CSS (white-space: pre-wrap). NUR WENN WIRKLICH TEXT DA IST: Eine Nachricht, die nur aus einem Foto besteht, hätte sonst einen leeren Absatz darunter -- und der nimmt Platz, den niemand erklären kann. */ if (n.text) blase.append(mitLinks(n.text)); } const fuss = el('div', 'chat-nachricht__fuss'); const zeit = el('time', 'chat-nachricht__zeit', wann(n.erstellt)); zeit.dateTime = n.erstellt; zeit.title = wann(n.erstellt, true); fuss.append(zeit); /* Antworten geht auf JEDE Nachricht, auch die eigene -- in einer Gruppe bezieht man sich durchaus auf sich selbst. Nur auf eine zurueckgenommene nicht: Das Zitat waere leer. */ if (!n.zurueckgenommen && n.id > 0) { const ant = el('button', 'chat-nachricht__antwort-knopf', 'antworten'); ant.type = 'button'; ant.addEventListener('click', () => antwortSetzen(n)); fuss.append(ant); } if (n.selbst && !n.zurueckgenommen) { const weg = el('button', 'chat-nachricht__weg-knopf', 'zurücknehmen'); weg.type = 'button'; weg.addEventListener('click', () => zuruecknehmen(n.id)); fuss.append(weg); } /* KOPIEREN. Eine Adresse, ein Code, eine Uhrzeit -- man will sie weitergeben, nicht abtippen. Der Knopf bestaetigt sich selbst und faellt danach zurueck; eine Meldung anderswo waere ein zweiter Ort, an dem man nachsehen muesste. */ if (!n.zurueckgenommen && n.text) { const kopie = el('button', 'chat-nachricht__kopie-knopf', 'kopieren'); kopie.type = 'button'; kopie.addEventListener('click', async () => { try { await navigator.clipboard.writeText(n.text); kopie.textContent = 'kopiert'; } catch { /* Ohne Zwischenablage-Recht (aeltere Browser, kein HTTPS) den Text wenigstens auswaehlen -- dann geht Strg+C. */ const p = kopie.closest('.chat-nachricht')?.querySelector('.chat-nachricht__text'); if (p) { const r = document.createRange(); r.selectNodeContents(p); const sel = getSelection(); sel.removeAllRanges(); sel.addRange(r); } kopie.textContent = 'markiert'; } setTimeout(() => { kopie.textContent = 'kopieren'; }, 1600); }); fuss.append(kopie); } blase.append(fuss); zeile.append(blase); ziel.append(zeile); } /* NUR NACH UNTEN SPRINGEN, WENN MAN SCHON UNTEN WAR. Vorher sprang der Verlauf bei JEDEM Neuzeichnen ans Ende. Wer weiter oben etwas nachlas und dabei eine neue Nachricht bekam, wurde mitten im Satz weggerissen -- und das passiert im Live- Strom staendig. Jetzt gilt: Wer unten steht, bleibt unten (das ist der Normalfall). Wer liest, bleibt stehen und bekommt einen Knopf, der sagt, wie viel unten wartet. */ if (unten) { ziel.scrollTop = ziel.scrollHeight; neuUnten = 0; } springKnopfZeigen(); } /* Steht der Verlauf am Ende? 40 Pixel Nachsicht -- ein Bildlauf trifft die letzte Zeile selten auf den Punkt, und ein Knopf, der bei einem Pixel Abstand erscheint, ist ein Flackern. */ let unten = true; let neuUnten = 0; function amEnde(e) { return e.scrollHeight - e.scrollTop - e.clientHeight < 40; } function springKnopfZeigen() { const k = $('zum-neuesten'); if (!k) return; k.hidden = unten || !nachrichten.length; k.textContent = neuUnten ? (neuUnten === 1 ? '1 neue Nachricht' : `${neuUnten} neue Nachrichten`) : 'Zum neuesten'; } /* ---------- Anhänge: Fotos und PDF (09.09.2026) ------------------------- Filipe: "pdfs und fotos". Ein Foto wird GEZEIGT, ein PDF wird ANGEBOTEN. Das ist kein Schönheitsunterschied: Ein Bild erkennt man in einer Zehntelsekunde, ein PDF muss man ohnehin öffnen -- eine Vorschau davon wäre ein grauer Kasten, der so tut, als könnte man etwas lesen. `width`/`height` stehen am Bild, obwohl das CSS die Größe macht. Sie sind das Seitenverhältnis: Damit hält der Browser den Platz frei, BEVOR das Bild da ist. Ohne sie springt der ganze Verlauf, sobald ein Bild fertig geladen ist -- und zwar genau dann, wenn man gerade anfängt zu lesen. */ function grosse(bytes) { const b = Number(bytes) || 0; if (b < 1024) return `${b} B`; if (b < 1024 * 1024) return `${Math.round(b / 1024)} kB`; return `${(b / 1048576).toFixed(1)} MB`; } function anhangBauen(a) { if (a.art === 'bild') { const kasten = el('a', 'chat-bild'); kasten.href = a.weg; kasten.target = '_blank'; kasten.rel = 'noopener noreferrer'; kasten.title = `${a.name} · ${grosse(a.groesse)} – in neuem Tab öffnen`; /* DER PLATZ WIRD AM KASTEN RESERVIERT, NICHT AM BILD. ------------------------------------------------------------------ Erster Anlauf: width- und height-Attribut ans , wie man es überall liest. Gemessen war der Verlauf danach 323 px zu hoch -- genau die Höhe des Fotos. Der Grund: Die Attribute geben dem Browser nur das SEITEN- VERHÄLTNIS. Damit daraus eine Höhe wird, muss die Breite feststehen. Bei `width: auto` und einem noch nicht geladenen Bild ist sie 0 -- das Verhältnis hat nichts, womit es rechnen könnte, der Kasten ist 0 px hoch, und wenn das Foto ankommt, schiebt es alles darunter weg. Man springt beim Öffnen ans Ende und steht eine Sekunde später mittendrin. Deshalb bekommt der KASTEN das Verhältnis und eine Höchstbreite (die Breite, die das Bild nach dem Deckel von 340 px Höhe hätte). Damit steht die Breite von Anfang an fest, die Höhe folgt daraus, und es wackelt nichts. Gefunden hat das keine Überlegung, sondern eine Zahl: In der Prüfung war der Rest 0 px -- dort lag das Bild schon im Zwischenspeicher. Erst die Messung im Aufnahmeskript, beim ERSTEN Laden, zeigte die 323. */ if (a.breite > 0 && a.hoehe > 0) { const DECKEL = 340; const breit = a.hoehe > DECKEL ? Math.round(a.breite * DECKEL / a.hoehe) : a.breite; kasten.style.aspectRatio = `${a.breite} / ${a.hoehe}`; kasten.style.maxWidth = `${breit}px`; } const bild = document.createElement('img'); bild.src = a.weg; bild.alt = a.name || 'Foto'; bild.decoding = 'async'; if (a.breite > 0 && a.hoehe > 0) { bild.width = a.breite; bild.height = a.hoehe; } /* KEIN loading="lazy". In einem Verlauf, der beim Öffnen ganz nach unten springt, sind die sichtbaren Bilder die zuletzt geschriebenen -- lazy verzögert genau die, die man sehen will, und spart nichts, weil ein Chat selten hundert Fotos hat. WENN EIN MASS FEHLT (seltene WebP-Formen), ist oben nichts reserviert. Dann ist dieser Zuhörer die Sicherung: War man unten, bleibt man unten. Er wird beim Bauen festgehalten, nicht beim Laden gelesen -- bis dahin hat ein Bildlauf-Ereignis den Zustand längst umgeworfen. */ const warUnten = unten; bild.addEventListener('load', () => { if (!warUnten) return; const v = $('verlauf'); v.scrollTop = v.scrollHeight; unten = true; neuUnten = 0; springKnopfZeigen(); }); /* Ein Bild, das nicht lädt, hinterlässt sonst ein kaputtes Symbol ohne Erklärung. */ bild.addEventListener('error', () => { kasten.dataset.kaputt = 'ja'; kasten.textContent = 'Foto lässt sich gerade nicht laden'; }); kasten.append(bild); return kasten; } const karte = el('a', 'chat-datei'); karte.href = a.weg; karte.target = '_blank'; karte.rel = 'noopener noreferrer'; const zeichen = el('span', 'chat-datei__zeichen', 'PDF'); zeichen.setAttribute('aria-hidden', 'true'); const mitte = el('span', 'chat-datei__mitte'); mitte.append(el('span', 'chat-datei__name', a.name || 'Dokument')); mitte.append(el('span', 'chat-datei__mass', `${grosse(a.groesse)} · öffnen`)); karte.append(zeichen, mitte); karte.title = `${a.name} · ${grosse(a.groesse)}`; return karte; } /* ---------- Links in Nachrichten (09.09.2026) --------------------------- Eine Adresse im Text soll anklickbar sein. Gebaut wird sie als ECHTER Knoten, nie ueber innerHTML: Sonst koennte ein Absender Auszeichnung einschleusen, und in einem Chat schreibt jeder. Erkannt wird bewusst wenig -- http, https und www. Wer mehr erkennt (Mailadressen, nackte Domains), erwischt irgendwann "3.stelle" oder "z.b." und macht daraus einen Link ins Nichts. `rel="noopener noreferrer"` ist Pflicht: Ohne `noopener` kann die geoeffnete Seite auf das Fenster zurueckgreifen, aus dem sie kam. */ const LINKMUSTER = /(https?:\/\/[^\s<>"']+|www\.[^\s<>"']+)/g; function mitLinks(text) { const p = el('p', 'chat-nachricht__text'); let letzte = 0; for (const treffer of String(text).matchAll(LINKMUSTER)) { const roh = treffer[0]; /* Satzzeichen am Ende gehoeren zum Satz, nicht zur Adresse. */ const sauber = roh.replace(/[.,;:!?)\]]+$/, ''); if (treffer.index > letzte) p.append(document.createTextNode(text.slice(letzte, treffer.index))); const a = el('a', 'chat-nachricht__link', sauber); a.href = sauber.startsWith('www.') ? 'https://' + sauber : sauber; a.target = '_blank'; a.rel = 'noopener noreferrer'; p.append(a); if (sauber.length < roh.length) p.append(document.createTextNode(roh.slice(sauber.length))); letzte = treffer.index + roh.length; } if (letzte < text.length) p.append(document.createTextNode(text.slice(letzte))); return p; } /* ---------- Antworten und Springen (09.09.2026, Punkt 15) ---------------- */ function antwortSetzen(n) { antwortAuf = { id: n.id, von: n.von, text: n.text }; const leiste = $('antwort-leiste'); if (!leiste) return; leiste.hidden = false; $('antwort-von').textContent = n.von; $('antwort-text').textContent = n.text; $('text').focus(); } function antwortLoeschen() { antwortAuf = null; const leiste = $('antwort-leiste'); if (leiste) leiste.hidden = true; } /** Zur zitierten Nachricht springen und sie kurz hervorheben. * * Steht sie nicht (mehr) im geladenen Ausschnitt, wird das GESAGT -- * ein Klick, der nichts tut, laesst einen glauben, der Knopf sei * kaputt. */ function zurStelle(id) { const el2 = document.querySelector(`.chat-nachricht[data-id="${id}"]`); if (!el2) { melde('Diese Nachricht liegt weiter oben – bitte nach oben blättern.'); return; } el2.scrollIntoView({ block: 'center', behavior: 'smooth' }); el2.dataset.hervor = 'ja'; setTimeout(() => { delete el2.dataset.hervor; }, 2200); } function tagName(iso) { const d = new Date(iso); const heute = new Date(); const gleich = (a, b) => a.toDateString() === b.toDateString(); if (gleich(d, heute)) return 'Heute'; if (gleich(d, new Date(heute.getTime() - 86400000))) return 'Gestern'; return d.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long' }); } /* ---------- Schreiben ---------------------------------------------------- */ async function abschicken(e) { e.preventDefault(); const feld = $('text'); const text = feld.value.trim(); if (!text || !offen) return; /* SOFORT ANZEIGEN. Die vorläufige Nachricht bekommt eine negative Nummer -- damit lässt sie sich später zweifelsfrei durch die echte ersetzen, ohne mit einer Server-Nummer zu kollidieren. */ /* Das Antwortziel wird HIER festgehalten, nicht erst im `await`. Wer waehrend des Sendens auf eine andere Nachricht antwortet, wuerde sonst das falsche Zitat mitschicken -- die Anfrage laeuft, die Variable hat sich schon geaendert. */ const zielAntwort = antwortAuf; const vorlaeufig = { id: -Date.now(), von_id: ich.id, von: ich.name, rolle: ich.rolle, text, erstellt: new Date().toISOString(), selbst: true, unterwegs: true, antwort: zielAntwort ? { id: zielAntwort.id, von: zielAntwort.von, text: zielAntwort.text } : null, }; nachrichten.push(vorlaeufig); /* Wer selbst schreibt, will das Ergebnis sehen -- auch wenn er vorher weiter oben gelesen hat. */ unten = true; neuUnten = 0; verlaufZeichnen(); feld.value = ''; entwurfWeg(offen.id); zaehlerZeigen(); antwortLoeschen(); hoeheAnpassen(); melde(''); try { const a = await hole(`/workspace/api/chat/raeume/${offen.id}/nachrichten`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, antwort_auf: zielAntwort?.id ?? null }), }); if (!a.ok) { const d = await a.json().catch(() => ({})); markiereAlsGescheitert(vorlaeufig.id, d.fehler || 'Nicht angekommen.'); return; } const { nachricht } = await a.json(); const i = nachrichten.findIndex((n) => n.id === vorlaeufig.id); if (i >= 0) nachrichten[i] = { ...nachricht, selbst: true }; verlaufZeichnen(); raeumeLaden(); } catch { markiereAlsGescheitert(vorlaeufig.id, 'Keine Verbindung.'); } } /** NICHT STILL VERSCHWINDEN LASSEN. Wer etwas schreibt und es sieht, * glaubt, es sei angekommen. Verschwindet es wortlos, merkt er es * im schlimmsten Fall Tage später. */ function markiereAlsGescheitert(id, grund) { const i = nachrichten.findIndex((n) => n.id === id); if (i >= 0) { nachrichten[i].gescheitert = true; delete nachrichten[i].unterwegs; } verlaufZeichnen(); melde(`${grund} Die Nachricht steht noch da – kopiere sie und versuch es erneut.`); } async function zuruecknehmen(id) { if (!window.confirm('Diese Nachricht zurücknehmen? Der Text ist dann weg, ' + 'im Verlauf bleibt ein Hinweis stehen.')) return; try { const a = await hole(`/workspace/api/chat/nachrichten/${id}`, { method: 'DELETE' }); if (!a.ok) { melde('Ging nicht.'); return; } const i = nachrichten.findIndex((n) => n.id === id); if (i >= 0) { nachrichten[i].zurueckgenommen = true; nachrichten[i].text = null; } verlaufZeichnen(); raeumeLaden(); } catch { /* umgeleitet */ } } /** Das Feld wächst mit dem Text -- bis zu einer Grenze, sonst * verdrängt eine lange Nachricht den ganzen Verlauf. */ function hoeheAnpassen() { const f = $('text'); f.style.height = 'auto'; f.style.height = Math.min(f.scrollHeight, 160) + 'px'; } /* ---------- Gelesen ------------------------------------------------------ */ function gelesenMelden() { if (!offen || !nachrichten.length) return; /* NUR WENN MAN WIRKLICH HINSIEHT. Eine Seite im Hintergrund hat niemand gelesen. */ if (document.hidden) return; const echte = nachrichten.filter((n) => n.id > 0); if (!echte.length) return; const bis = echte[echte.length - 1].id; fetch(`/workspace/api/chat/raeume/${offen.id}/gelesen`, { method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ bis }), }).then(() => { const r = raeume.find((x) => x.id === offen.id); if (r) { r.ungelesen = 0; raeumeZeichnen(); } ungelesenMelden(); }).catch(() => {}); } /** Sagt der Kopfleiste, wie viele insgesamt warten. */ async function ungelesenMelden() { try { const a = await fetch('/workspace/api/chat/ungelesen', { credentials: 'same-origin' }); if (!a.ok) return; const { ungelesen } = await a.json(); window.chatZahlZeigen?.(ungelesen); } catch { /* die Zahl ist Zugabe */ } } /* ---------- Neue Nachrichten kommen von selbst --------------------------- */ function stromOeffnen() { if (strom) strom.close(); strom = new EventSource('/workspace/api/chat/strom'); strom.addEventListener('neu', (e) => { let n; try { n = JSON.parse(e.data); } catch { return; } if (offen && n.raum_id === offen.id) { /* SCHON DA? Zwei Wege führen hierher, und beide sind richtig: die Antwort auf das eigene POST und der Strom. */ if (nachrichten.some((x) => x.id === n.id)) return; /* DER WETTLAUF (gefunden 06.09.2026, im Zwei-Browser-Test): Beim Abschicken steht die eigene Nachricht sofort da -- mit einer vorläufigen negativen Nummer. Ist der Strom SCHNELLER als die Antwort auf das POST, greift die Prüfung darüber nicht (die Nummern sind verschieden), und die Nachricht stand zweimal im Verlauf. Deshalb wird eine eigene Nachricht, die noch unterwegs ist und denselben Text trägt, hier ERSETZT statt ergänzt. Damit gewinnt, wer zuerst kommt, und das Ergebnis ist dasselbe. */ if (n.von_id === ich.id) { const i = nachrichten.findIndex( (x) => x.unterwegs && x.text === n.text); if (i >= 0) { nachrichten[i] = { ...n, selbst: true }; verlaufZeichnen(); return; } } nachrichten.push({ ...n, selbst: n.von_id === ich.id }); /* Wer gerade weiter oben liest, soll sehen, WIE VIEL unten dazugekommen ist -- nicht nur, DASS etwas da ist. */ if (!unten && n.von_id !== ich.id) neuUnten++; verlaufZeichnen(); gelesenMelden(); } raeumeLaden(); }); strom.addEventListener('weg', (e) => { let n; try { n = JSON.parse(e.data); } catch { return; } const i = nachrichten.findIndex((x) => x.id === n.id); if (i >= 0) { nachrichten[i].zurueckgenommen = true; nachrichten[i].text = null; verlaufZeichnen(); } raeumeLaden(); }); /* Bei einem Abriss verbindet EventSource von selbst neu. Wir holen danach den Verlauf nach -- sonst fehlte, was während der Pause geschrieben wurde. */ strom.addEventListener('open', () => { if (offen) raumOeffnen(offen.id, { sanft: true }); else raeumeLaden(); }); } /* ---------- Neues Gespräch ----------------------------------------------- */ async function partnerLaden() { const ziel = $('partner'); ziel.textContent = ''; gewaehlt = new Set(); $('gruppenname-zeile').hidden = true; $('neu-los').disabled = true; $('neu-fehler').textContent = ''; let personen = []; try { const a = await hole('/workspace/api/chat/partner'); if (a.ok) personen = (await a.json()).personen || []; } catch { return; } if (!personen.length) { ziel.append(el('p', 'chat__leer', 'Dir ist noch niemand zugeordnet – melde dich bei DogFather.')); return; } /* Nach Rollen gruppiert, wie bei der Teilnehmerwahl im Kalender. Die Rolle steht einmal über der Gruppe statt an jedem Namen. */ /* Die Rollenfolge kommt aus bereiche.js. Stand sie hier selbst, fehlte 'spicy' -- und wer nicht in der Schleife vorkommt, taucht in der Gesprächsliste gar nicht auf. Kein Fehler, nur eine Person, die es fuer die Anderen nicht gibt. */ for (const rolle of window.Bereiche.ROLLENFOLGE) { const leute = personen.filter((p) => p.rolle === rolle); if (!leute.length) continue; const gruppe = el('div', 'chat-wahl__gruppe'); gruppe.dataset.rolle = rolle; const titel = el('p', 'chat-wahl__titel'); const punkt = el('span', 'chat-wahl__punkt'); punkt.setAttribute('aria-hidden', 'true'); titel.append(punkt, document.createTextNode( window.Bereiche.ROLLEN_GRUPPE[rolle])); gruppe.append(titel); const liste = el('div', 'chat-wahl__liste'); liste.setAttribute('role', 'group'); for (const p of leute) { const b = el('button', 'chat-wahl__knopf'); b.type = 'button'; b.dataset.id = String(p.id); b.dataset.rolle = p.rolle; b.setAttribute('aria-pressed', 'false'); const z = el('span', 'chat-wahl__zeichen', initialen(p.name)); z.setAttribute('aria-hidden', 'true'); b.append(z, el('span', 'chat-wahl__name', p.name)); b.addEventListener('click', () => { if (gewaehlt.has(p.id)) { gewaehlt.delete(p.id); delete b.dataset.an; } else { gewaehlt.add(p.id); b.dataset.an = 'ja'; } b.setAttribute('aria-pressed', gewaehlt.has(p.id) ? 'true' : 'false'); wahlGeaendert(); }); liste.append(b); } gruppe.append(liste); ziel.append(gruppe); } } function wahlGeaendert() { const n = gewaehlt.size; /* Ab zwei ist es eine Gruppe -- und die braucht einen Namen. */ $('gruppenname-zeile').hidden = n < 2; $('neu-los').disabled = n === 0; $('neu-los').textContent = n >= 2 ? 'Gruppe anlegen' : 'Schreiben'; } async function neuAbschicken(e) { e.preventDefault(); const ids = [...gewaehlt]; if (!ids.length) return; const knopf = $('neu-los'); knopf.disabled = true; try { let raumId; if (ids.length === 1) { const a = await hole('/workspace/api/chat/direkt', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mit: ids[0] }), }); if (!a.ok) { $('neu-fehler').textContent = (await a.json().catch(() => ({}))).fehler || 'Ging nicht.'; return; } raumId = (await a.json()).raum_id; } else { const name = $('gruppenname').value.trim(); if (name.length < 2) { $('neu-fehler').textContent = 'Die Gruppe braucht einen Namen.'; $('gruppenname').focus(); return; } const a = await hole('/workspace/api/chat/gruppe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, mit: ids }), }); if (!a.ok) { $('neu-fehler').textContent = (await a.json().catch(() => ({}))).fehler || 'Ging nicht.'; return; } raumId = (await a.json()).raum_id; } $('neu-dialog').close(); await raeumeLaden(); await raumOeffnen(raumId); } catch { /* umgeleitet */ } finally { knopf.disabled = false; } } /* ---------- Die Höhe der Kopfleiste MESSEN, nicht raten ------------------ Die Chatfläche ist so hoch wie das Fenster minus die Kopfleiste. Deren Höhe stand zuerst als fester Wert im CSS (62 px) -- am Handy ist sie zweizeilig und gut doppelt so hoch. Folge: Die Seite war zu lang, und das Eingabefeld stand halb unter dem Bildschirmrand. Ein fester Wert ist hier immer falsch: Die Leiste wächst mit der Schriftgröße, mit der Glocke, mit einem längeren Namen. Also wird sie gemessen -- einmal beim Start und wieder, wenn sich etwas an der Fenstergröße ändert. */ /* SEIT DEM 09.09.2026 GEHOERT DAS BAND DAZU. Ueber der Chatflaeche steht in einer fremden Sicht ein zweites Element: das Band mit dem Namen (.fremd-band, kopf.js). Wer nur die Kopfleiste misst, rechnet dessen Hoehe nicht ab -- und das Eingabefeld steht genau so weit unter dem Bildschirmrand, wie das Band hoch ist. Derselbe Fehler wie mit den festen 62 px, nur mit einem anderen Element. Gezaehlt wird deshalb ALLES, was ueber der Flaeche liegt. */ const UEBER_DER_FLAECHE = ['.kopfleiste', '.fremd-band']; function kopfMessen() { const teile = UEBER_DER_FLAECHE.map((w) => document.querySelector(w)).filter(Boolean); if (!teile.length) return; const h = Math.ceil(teile.reduce((s, e) => s + e.getBoundingClientRect().height, 0)); document.documentElement.style.setProperty('--kopf-hoehe', `${h}px`); } kopfMessen(); window.addEventListener('resize', kopfMessen); /* Das Band entsteht erst, wenn kopf.js weiss, wer angemeldet ist -- also nach dieser Messung. Ohne diesen Beobachter bliebe der erste Wert stehen, und die Flaeche waere genau um das Band zu lang. */ if (window.MutationObserver) { new MutationObserver(kopfMessen).observe(document.body, { childList: true }); } /* Auch wenn die Leiste selbst sich ändert (Glocke erscheint, Name wird eingesetzt) -- ohne das bliebe der erste Messwert stehen. */ if (window.ResizeObserver) { const kopf = document.querySelector('.kopfleiste'); if (kopf) new ResizeObserver(kopfMessen).observe(kopf); } /* ---------- Start -------------------------------------------------------- */ /* Der Verwerfen-Knopf der Antwortleiste. Zusaetzlich beendet Escape die Antwort -- wer im Eingabefeld steht, greift eher zur Taste als zur Maus. */ $('antwort-weg')?.addEventListener('click', antwortLoeschen); $('text')?.addEventListener('keydown', (e) => { if (e.key === 'Escape' && antwortAuf) { e.preventDefault(); antwortLoeschen(); } }); $('schreiben').addEventListener('submit', abschicken); $('text').addEventListener('input', () => { hoeheAnpassen(); entwurfMerken(); zaehlerZeigen(); }); /* ---------- Entwurf je Gespraech (09.09.2026) ---------------------------- Wer mitten im Satz das Gespraech wechselt, findet den Satz beim Zurueckkommen wieder. Ohne das ist jeder Wechsel eine Entscheidung: "wegwerfen oder erst fertig schreiben?" Im localStorage und nicht auf dem Server: Ein Entwurf ist nichts, was jemand anders sehen soll, und er gehoert an dieses Geraet. Abgeschickte oder geleerte Entwuerfe werden geloescht -- eine Ablage, die nur waechst, ist ein Leck. */ const ENTWURF = (id) => `dfw-chat-entwurf-${id}`; function entwurfMerken() { if (!offen) return; const t = $('text').value; try { if (t.trim()) localStorage.setItem(ENTWURF(offen.id), t); else localStorage.removeItem(ENTWURF(offen.id)); } catch { /* privater Modus: dann eben nicht */ } } function entwurfHolen(raumId) { try { return localStorage.getItem(ENTWURF(raumId)) || ''; } catch { return ''; } } function entwurfWeg(raumId) { try { localStorage.removeItem(ENTWURF(raumId)); } catch { /* egal */ } } /* ---------- Zeichenzaehler ------------------------------------------------ Er erscheint erst, wenn es eng wird. Ein Zaehler, der von Anfang an mitlaeuft, zaehlt die meiste Zeit etwas, das niemanden interessiert -- und wird dann auch dann uebersehen, wenn er zaehlt. */ /* Die Zahl steht NICHT hier. Sie steht im maxlength des Feldes, und dort steht sie, weil der Server sie so haben will (TEXT_MAX = 4000 in workspace-chat.js). Zwei Stellen fuer dieselbe Zahl laufen auseinander -- dann zaehlt der Zaehler bis 4000 herunter, waehrend das Feld schon bei 2000 dichtmacht, und niemand versteht warum. */ const GRENZE = Number($('text').getAttribute('maxlength')) || 4000; function zaehlerZeigen() { const z = $('zeichen'); if (!z) return; const n = $('text').value.length; const rest = GRENZE - n; z.hidden = rest > 400; z.textContent = `noch ${rest}`; z.dataset.eng = rest <= 100 ? 'ja' : 'nein'; } /* ---------- Emoji-Auswahl ------------------------------------------------- Eine kleine, feste Auswahl statt einer vollstaendigen Tafel: Diese dreissig decken den Alltag ab, und sie laden nichts nach. Eine Emoji-Bibliothek waere ein Megabyte fuer etwas, das die Bildschirmtastatur ohnehin kann -- am Rechner aber nicht. Eingefuegt wird an der CURSORSTELLE, nicht am Ende: Wer mitten im Satz eines setzen will, soll nicht erst umsortieren. */ const EMOJIS = ['😀','😂','🥹','😍','😎','🤔','😅','🙃','😴','🤝', '👍','👏','🙌','💪','🙏','🔥','✨','⭐','💯','🎉', '❤️','💙','💚','🖤','👀','🎮','🎧','📈','⏰','✅']; function emojiTafelBauen() { const tafel = $('emoji-tafel'); if (!tafel || tafel.childElementCount) return; for (const e of EMOJIS) { const b = el('button', 'emoji-tafel__knopf', e); b.type = 'button'; b.setAttribute('aria-label', `Emoji ${e} einfügen`); b.addEventListener('click', () => { const f = $('text'); const a = f.selectionStart ?? f.value.length; const b2 = f.selectionEnd ?? a; f.value = f.value.slice(0, a) + e + f.value.slice(b2); f.focus(); f.selectionStart = f.selectionEnd = a + e.length; hoeheAnpassen(); entwurfMerken(); zaehlerZeigen(); }); tafel.append(b); } } $('emoji-auf')?.addEventListener('click', () => { const tafel = $('emoji-tafel'); emojiTafelBauen(); const auf = tafel.hidden; tafel.hidden = !auf; $('emoji-auf').setAttribute('aria-expanded', String(auf)); if (auf) tafel.querySelector('button')?.focus(); }); /* Ein Klick daneben schliesst die Tafel -- sonst steht sie im Weg, und man sucht den Knopf, mit dem man sie aufgemacht hat. */ document.addEventListener('click', (e) => { const tafel = $('emoji-tafel'); if (!tafel || tafel.hidden) return; if (tafel.contains(e.target) || e.target.closest('#emoji-auf')) return; tafel.hidden = true; $('emoji-auf')?.setAttribute('aria-expanded', 'false'); }); /* Escape schliesst sie -- und zwar von ueberall her, nicht nur wenn die Schreibmarke zufaellig noch in der Tafel steht. Erster Anlauf hing der Zuhoerer an der Tafel selbst. Das geht gut, solange man sie gerade aufgemacht hat (dann liegt der Fokus auf dem ersten Zeichen). Wer danach ins Schreibfeld zurueckklickt, drueckt Escape aber ins Leere -- und die Tafel steht weiter im Weg. Am Dokument gilt es immer; ist die Tafel zu, tut die Zeile nichts. */ document.addEventListener('keydown', (e) => { if (e.key !== 'Escape') return; const tafel = $('emoji-tafel'); if (!tafel || tafel.hidden) return; tafel.hidden = true; $('emoji-auf')?.setAttribute('aria-expanded', 'false'); $('text').focus(); }); /* ---------- Zum neuesten springen ---------------------------------------- */ $('verlauf')?.addEventListener('scroll', () => { const v = $('verlauf'); const jetztUnten = amEnde(v); if (jetztUnten !== unten) { unten = jetztUnten; if (unten) neuUnten = 0; springKnopfZeigen(); } }); $('zum-neuesten')?.addEventListener('click', () => { const v = $('verlauf'); v.scrollTop = v.scrollHeight; unten = true; neuUnten = 0; springKnopfZeigen(); }); /* Enter schickt, Umschalt+Enter macht eine neue Zeile. Am Handy NICHT -- dort ist Enter die Zeilenschaltung der Bildschirmtastatur, und wer eine zweite Zeile will, käme sonst nie dazu. */ $('text').addEventListener('keydown', (e) => { const handy = window.matchMedia('(hover: none)').matches; if (e.key === 'Enter' && !e.shiftKey && !handy) { e.preventDefault(); $('schreiben').requestSubmit(); } }); $('suche').addEventListener('input', () => { raeumeZeichnen(); nachrichtenSuchen(); }); $('neu-oeffnen').addEventListener('click', async () => { await partnerLaden(); $('neu-dialog').showModal(); }); $('neu-zu').addEventListener('click', () => $('neu-dialog').close()); $('neu-form').addEventListener('submit', neuAbschicken); $('zur-liste').addEventListener('click', () => { $('chat').dataset.ansicht = 'liste'; history.replaceState(null, '', 'chat.html'); }); /* ======================================================================= ANHÄNGE SCHICKEN (09.09.2026) Filipe: "am besten wäre es auch wenn man da auch im chat pdfs schicken könnte. pdfs und fotos." DREI WEGE ZU DERSELBEN SACHE, weil Leute unterschiedlich arbeiten: die Büroklammer (immer da, auch am Handy), Hineinziehen (am Rechner der schnellste Weg) und Einfügen mit Strg+V (der Weg für einen Screenshot -- der liegt in der Zwischenablage und nirgendwo als Datei). Alle drei landen in derselben Funktion. Drei Fassungen desselben Ablaufs wären drei Gelegenheiten, dass eine davon die Prüfung vergisst. GEPRÜFT WIRD TROTZDEM AUCH HIER, obwohl der Server es noch einmal tut: Eine Absage nach zwölf Megabyte Wartezeit ist keine Absage, das ist eine Strafe. Die Prüfung im Browser ist die Höflichkeit, die auf dem Server ist die Sicherheit -- keine ersetzt die andere. */ const ANHANG_MAX = 12 * 1024 * 1024; const ANHANG_TYPEN = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'application/pdf']); let schicktGerade = false; async function anhangSchicken(datei) { if (!offen || !datei) return; if (schicktGerade) { melde('Einen Moment – der letzte Anhang ist noch unterwegs.'); return; } if (!ANHANG_TYPEN.has(datei.type)) { melde('Das geht nicht – erlaubt sind Fotos (PNG, JPEG, WebP, GIF) und PDF.'); return; } if (datei.size > ANHANG_MAX) { melde(`Zu groß – erlaubt sind ${Math.round(ANHANG_MAX / 1048576)} MB je Datei.`); return; } schicktGerade = true; $('anhang-auf').disabled = true; melde(`„${datei.name}“ wird geschickt …`, 'lauf'); /* Ein angefangener Begleitsatz geht mit -- wer erst tippt und dann das Bild anhängt, will beides in einer Nachricht. */ const begleittext = $('text').value.trim(); try { const a = await hole(`/workspace/api/chat/raeume/${offen.id}/anhang`, { method: 'POST', headers: { 'Content-Type': datei.type, /* Kopfzeilen sind streng genommen nur ASCII -- ohne die Kodierung käme aus "Größe.pdf" unterwegs Unsinn. */ 'X-Dateiname': encodeURIComponent(datei.name || 'anhang'), 'X-Text': encodeURIComponent(begleittext), }, body: datei, }); if (!a.ok) { let grund = 'Ging nicht.'; try { grund = (await a.json()).fehler || grund; } catch { /* nichts Lesbares */ } melde(grund); return; } const { nachricht } = await a.json(); /* Nur eintragen, wenn sie nicht schon über den Strom kam -- sonst stünde dasselbe Foto zweimal da. */ if (!nachrichten.some((x) => x.id === nachricht.id)) { nachrichten.push({ ...nachricht, selbst: true }); } $('text').value = ''; entwurfWeg(offen.id); hoeheAnpassen(); zaehlerZeigen(); unten = true; neuUnten = 0; verlaufZeichnen(); melde(''); raeumeLaden(); } catch { melde('Keine Verbindung – der Anhang wurde nicht geschickt.'); } finally { schicktGerade = false; $('anhang-auf').disabled = false; } } $('anhang-auf').addEventListener('click', () => $('anhang-feld').click()); $('anhang-feld').addEventListener('change', () => { const datei = $('anhang-feld').files?.[0]; /* Zurücksetzen, BEVOR geschickt wird: Sonst löst dieselbe Datei beim zweiten Mal kein `change` aus, und man wundert sich, warum nichts passiert. */ $('anhang-feld').value = ''; if (datei) void anhangSchicken(datei); }); /* ---- Hineinziehen ------------------------------------------------------ `dragover` MUSS abgefangen werden, sonst öffnet der Browser die Datei einfach als Seite -- der Verlauf wäre weg und das Bild bildschirmfüllend da. */ { const flaeche = document.querySelector('.chat__spalte--verlauf'); let tiefe = 0; /* dragenter/-leave feuert je Kind */ const zeigen = (ja) => { const k = $('ablage'); if (k) k.hidden = !ja || !offen; }; flaeche.addEventListener('dragenter', (e) => { if (!e.dataTransfer?.types?.includes('Files')) return; tiefe++; zeigen(true); }); flaeche.addEventListener('dragover', (e) => { if (!e.dataTransfer?.types?.includes('Files')) return; e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; }); flaeche.addEventListener('dragleave', () => { if (--tiefe <= 0) { tiefe = 0; zeigen(false); } }); flaeche.addEventListener('drop', (e) => { if (!e.dataTransfer?.files?.length) return; e.preventDefault(); tiefe = 0; zeigen(false); if (!offen) { melde('Zuerst ein Gespräch öffnen.'); return; } void anhangSchicken(e.dataTransfer.files[0]); }); /* Fällt etwas neben dem Fenster, darf der Browser es trotzdem nicht öffnen. */ window.addEventListener('dragover', (e) => e.preventDefault()); window.addEventListener('drop', (e) => e.preventDefault()); } /* ---- Einfügen (Strg+V) -------------------------------------------------- Der Weg für Screenshots. Ein kopiertes Bild hat keinen Namen -- deshalb bekommt es hier einen mit Datum, sonst hiesse jede Bildschirmaufnahme "image.png". */ $('text').addEventListener('paste', (e) => { const stuecke = [...(e.clipboardData?.items || [])]; const bild = stuecke.find((s) => s.kind === 'file' && ANHANG_TYPEN.has(s.type)); if (!bild) return; /* normaler Text: nichts tun */ const datei = bild.getAsFile(); if (!datei) return; e.preventDefault(); const endung = (datei.type.split('/')[1] || 'png').replace('jpeg', 'jpg'); const stempel = new Date().toISOString().slice(0, 16).replace(/[-:]/g, '').replace('T', '-'); void anhangSchicken(new File([datei], datei.name && datei.name !== 'image.png' ? datei.name : `bildschirmfoto-${stempel}.${endung}`, { type: datei.type })); }); /* ======================================================================= GESPRÄCH WEGRÄUMEN — NUR BEI MIR Die Rückfrage sagt ausdrücklich, was NICHT passiert. "Wirklich löschen?" liesse offen, ob der andere seinen Verlauf verliert -- und das ist genau die Frage, die man in dem Moment hat. */ $('gespraech-weg').addEventListener('click', async () => { if (!offen) return; const wer = offen.name; if (!window.confirm( `Gespräch mit ${wer} wegräumen?\n\n` + 'Es verschwindet nur bei dir. Der Verlauf beim Gegenüber bleibt ' + 'vollständig.\n\nSchreibt euch jemand wieder, kommt das Gespräch ' + 'mit dem Neuen zurück – das Alte bleibt weg.')) return; try { const a = await hole(`/workspace/api/chat/raeume/${offen.id}`, { method: 'DELETE' }); if (!a.ok) { melde('Ging nicht.'); return; } entwurfWeg(offen.id); offen = null; nachrichten = []; $('schreiben').hidden = true; $('gespraech-weg').hidden = true; $('verlauf-titel').textContent = 'Wähle ein Gespräch'; $('verlauf-unter').textContent = ''; $('verlauf-leer').hidden = false; $('chat').dataset.ansicht = 'liste'; verlaufZeichnen(); history.replaceState(null, '', 'chat.html'); await raeumeLaden(); melde(''); } catch { melde('Keine Verbindung.'); } }); /* Zurück an den Bildschirm: nachsehen, was inzwischen kam, und melden, dass jetzt wirklich jemand hinsieht. */ document.addEventListener('visibilitychange', () => { if (document.hidden) return; gelesenMelden(); raeumeLaden(); }); (async () => { try { const a = await hole('/workspace/api/ich'); if (!a.ok) { location.assign('/workspace/'); return; } ich = await a.json(); } catch { return; } window.werZeigen?.(ich); await raeumeLaden(); stromOeffnen(); /* Kommt man aus einer Benachrichtigung, steht das Gespräch in der Adresse -- dann direkt hinein. */ const suche = new URLSearchParams(location.search); const wunsch = Number(suche.get('raum')); if (Number.isInteger(wunsch) && wunsch > 0) { await raumOeffnen(wunsch); return; } /* ?mit= -- der Weg von woanders her (06.09.2026). Die Frühwarnung im Dashboard schlägt vor nachzufassen; ohne diesen Einstieg müsste man danach die Gesprächsliste durchsuchen oder ein Gespräch von Hand anlegen, das vielleicht schon existiert. `/direkt` legt beim ersten Mal an und findet beim zweiten dasselbe wieder -- ein Aufruf für beide Fälle. Die Berechtigung prüft der Server (darfSchreibenMit). Eine von Hand getippte Personennummer bringt hier nichts. */ const mit = Number(suche.get('mit')); if (!Number.isInteger(mit) || mit < 1) return; try { const a = await hole('/workspace/api/chat/direkt', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mit }), }); if (!a.ok) return; /* die Liste steht ja */ const { raum_id: raumId } = await a.json(); await raeumeLaden(); await raumOeffnen(raumId); } catch { /* die Liste steht ja */ } })(); })();